Files
gitmost/packages/mcp/build/lib/tree.js
vvzvlad 1e7a306f96 feat(mcp): add hierarchical tree mode to list_pages
list_pages gains an opt-in `tree` parameter on both surfaces (the
@docmost/mcp server tool and the AI-chat agent tool), which share the
same DocmostClient.listPages. Default behavior (recent-by-updatedAt flat
list) is unchanged.

- client.ts: listPages(spaceId?, limit=50, tree=false); when tree is
  true it requires spaceId (throws a specific error otherwise), walks the
  sidebar tree via the existing bounded/cycle-safe enumerateSpacePages,
  and returns a nested tree; limit is ignored in tree mode.
- lib/tree.ts: new pure buildPageTree() — lean nodes { id, slugId, title,
  children? }, children sorted by position (code-unit order), orphans
  promoted to roots, cycle-safe.
- index.ts + ai-chat-tools.service.ts: expose `tree` in the tool schemas
  and descriptions; docmost-client.loader.ts: mirror the new signature.
- tests: add packages/mcp/test/unit/tree.test.mjs (nesting, ordering,
  lean shape, orphan promotion, cycle/self-reference safety).
- rebuild @docmost/mcp (build/ is tracked and loaded at runtime).
2026-06-18 20:30:00 +03:00

90 lines
3.8 KiB
JavaScript

/**
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
* by `enumerateSpacePages`) into a nested tree.
*
* Input: a flat array of nodes. Each node is expected to carry at least
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
*
* Output: an array of ROOT nodes, each shaped as
* { id, slugId, title, children? }
* where `children` is the array of child nodes (same shape, recursively). The
* `children` key is OMITTED entirely when a node has no children — consistent
* with how `filterPage` omits an empty `subpages` array — to keep the payload
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
* are intentionally dropped from the output).
*
* Linking rule: a node is attached as a child of `parentPageId` only when that
* parent id is actually present in the input. Otherwise — including a null /
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
* — the node is promoted to a ROOT. So "orphan whose parent is missing" is the
* defined behavior: it surfaces at the top level rather than disappearing.
*
* Ordering rule: the roots array and every `children` array are sorted ascending
* by the node's `position` string. The comparator is a plain code-unit (byte)
* comparison — NOT localeCompare — because the server orders sidebar pages by
* `collate "C"` (byte order), which a raw `<`/`>` compare approximates for the
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
* `position` sort last.
*
* Pure: no I/O, no network, deterministic.
*/
export function buildPageTree(nodes) {
// Map id -> output node. Build the lean output shape up front.
const byId = new Map();
// Preserve the original position string for sorting (kept off the output).
const positionById = new Map();
for (const node of nodes) {
if (!node || typeof node !== "object" || !node.id)
continue;
// Defensive against duplicate ids: last one wins (overwrites the earlier
// entry). `enumerateSpacePages` already dedups, so this is belt-and-braces.
byId.set(node.id, {
id: node.id,
slugId: node.slugId,
title: node.title,
});
positionById.set(node.id, node.position);
}
// Stable comparator on the position string: code-unit order, missing last.
const byPosition = (aId, bId) => {
const a = positionById.get(aId);
const b = positionById.get(bId);
if (a === undefined || a === null)
return b === undefined || b === null ? 0 : 1;
if (b === undefined || b === null)
return -1;
if (a < b)
return -1;
if (a > b)
return 1;
return 0;
};
const roots = [];
const childrenIdsByParent = new Map();
for (const node of nodes) {
if (!node || typeof node !== "object" || !node.id)
continue;
const parentId = node.parentPageId;
// Child only when the parent is actually present in the input; otherwise
// (null/undefined parent, or parent capped out of the walk) -> root.
if (parentId && byId.has(parentId)) {
const list = childrenIdsByParent.get(parentId) ?? [];
list.push(node.id);
childrenIdsByParent.set(parentId, list);
}
else {
roots.push(node.id);
}
}
// Attach sorted children arrays to each parent, omitting empty ones.
for (const [parentId, childIds] of childrenIdsByParent) {
const parent = byId.get(parentId);
if (!parent)
continue;
childIds.sort(byPosition);
parent.children = childIds.map((id) => byId.get(id));
}
roots.sort(byPosition);
return roots.map((id) => byId.get(id));
}