@omgbase/core 0.15.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/src/core/ingest.d.ts +2 -0
  2. package/dist/src/core/ingest.js +13 -0
  3. package/dist/src/core/read/document.d.ts +9 -8
  4. package/dist/src/core/read/document.js +0 -2
  5. package/dist/src/core/store/gc.d.ts +13 -1
  6. package/dist/src/core/store/gc.js +24 -5
  7. package/dist/src/core/store/size.d.ts +79 -0
  8. package/dist/src/core/store/size.js +232 -0
  9. package/dist/src/index.d.ts +4 -1
  10. package/dist/src/index.js +5 -1
  11. package/dist/src/mcp/arguments.d.ts +10 -2
  12. package/dist/src/mcp/arguments.js +37 -3
  13. package/dist/src/mcp/errors.d.ts +1 -1
  14. package/dist/src/mcp/reference.d.ts +1 -1
  15. package/dist/src/mcp/reference.js +63 -10
  16. package/dist/src/mcp/server.d.ts +34 -1
  17. package/dist/src/mcp/server.js +158 -46
  18. package/dist/src/mutate/canonical.d.ts +27 -0
  19. package/dist/src/mutate/canonical.js +138 -0
  20. package/dist/src/mutate/effects.d.ts +58 -8
  21. package/dist/src/mutate/effects.js +312 -43
  22. package/dist/src/mutate/known-ids.d.ts +19 -0
  23. package/dist/src/mutate/known-ids.js +47 -7
  24. package/dist/src/mutate/ops.d.ts +1 -0
  25. package/dist/src/mutate/ops.js +20 -1
  26. package/dist/src/mutate/plan.d.ts +26 -16
  27. package/dist/src/mutate/plan.js +152 -112
  28. package/dist/src/mutate/select.d.ts +78 -0
  29. package/dist/src/mutate/select.js +903 -0
  30. package/dist/src/mutate/tree.d.ts +2 -2
  31. package/dist/src/oqx-js/context.d.ts +9 -2
  32. package/dist/src/oqx-js/context.js +74 -28
  33. package/dist/src/oqx-js/names.d.ts +40 -0
  34. package/dist/src/oqx-js/names.js +347 -0
  35. package/dist/src/oqx-js/planner.d.ts +3 -3
  36. package/dist/src/oqx-js/planner.js +17 -9
  37. package/dist/src/oqx-js/root-row.d.ts +15 -4
  38. package/dist/src/oqx-js/root-row.js +18 -10
  39. package/dist/src/oqx-js/run.d.ts +45 -1
  40. package/dist/src/oqx-js/run.js +114 -32
  41. package/dist/src/oqx-js/sql/translate.js +1 -1
  42. package/dist/src/spec-versions.d.ts +5 -5
  43. package/dist/src/spec-versions.js +4 -4
  44. package/dist/src/sync/observe.js +9 -3
  45. package/dist/src/version.d.ts +7 -2
  46. package/dist/src/version.js +5 -2
  47. package/package.json +2 -2
@@ -1,10 +1,10 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
2
+ import { CallToolRequestSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { z } from "zod";
4
4
  import { docsOutline } from "../core/read/outline.js";
5
5
  import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
6
6
  import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
7
- import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
7
+ import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT } from "../core/read/reader.js";
8
8
  import { resolveRef } from "../core/read/refs.js";
9
9
  import { VERSION } from "../core/index.js";
10
10
  import { normalizeText, normalizeVisibleText } from "../core/hash.js";
@@ -12,11 +12,12 @@ import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
12
12
  import { graphNeighborhood } from "./graph.js";
13
13
  import { textSearch } from "../search/text.js";
14
14
  import { EngineError, errorBody } from "./errors.js";
15
- import { argumentError, validateArguments } from "./arguments.js";
15
+ import { argumentError, stringList, validateArguments } from "./arguments.js";
16
16
  import { linksRetarget, linksRepair } from "../mutate/macros.js";
17
17
  import { canonicalDocPath } from "../mutate/docs.js";
18
18
  import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
19
19
  import { appendEffects, applySugar, checkEffects, createEffects, deleteDocEffects, docsAppendEffects, insertEffects, mergeEffects, moveBlocksEffects, moveDocEffects, moveLinks, nodeSetEffects, removeBlocksEffects, requireAbsentPath, retargetEffects, setMetaEffects, splitEffects, updateBlockEffects, updateContentEffects, } from "../mutate/sugar.js";
20
+ import { fixTool } from "../mutate/canonical.js";
20
21
  import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
21
22
  import { linksStale, linksStaleSummary } from "../graph/link-health.js";
22
23
  import { resolve as resolveThing } from "../search/resolve.js";
@@ -28,6 +29,29 @@ import { versionInfo } from "../version.js";
28
29
  import { storagePath, referencePath } from "../core/paths.js";
29
30
  import { isContainerBlock } from "../format/index.js";
30
31
  import { surfaceInboundLink, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfacePlan, surfaceReceipt, } from "../surface-paths.js";
32
+ export const TOOLS_PROFILES = ["full", "minimal"];
33
+ /**
34
+ * The `minimal` profile (spec/surface §4 "Profiles", 4.3): the ten tools an
35
+ * agent works a knowledge base with — `query` for every read it covers,
36
+ * `query_syntax` (the grammar the ladders point at), `docs_read` (whole
37
+ * document, `include_ids` for ids/hashes/parents), `docs_tree` (orientation),
38
+ * `blocks_resolve` (the one ranked hybrid lookup), `changes_since`
39
+ * (re-orientation), `plan` / `execute` / `apply` (the one writer and its
40
+ * preview) and `version` (which reports the profile). The same list as the Rust
41
+ * catalog's `MINIMAL_TOOLS` (`crates/omgbase-surface/src/catalog.rs`).
42
+ */
43
+ export const MINIMAL_TOOLS = [
44
+ "query", "query_syntax", "docs_read", "docs_tree", "blocks_resolve", "changes_since", "plan", "execute", "apply", "version",
45
+ ];
46
+ /** Each profile's membership test: `full` keeps every tool, `minimal` the ten of `MINIMAL_TOOLS`. */
47
+ export const PROFILES = {
48
+ full: () => true,
49
+ minimal: (tool) => MINIMAL_TOOLS.includes(tool),
50
+ };
51
+ /** Parse a `--tools` value / the `mcp.tools` setting; null for anything but the two names. */
52
+ export function parseToolsProfile(value) {
53
+ return value === "full" || value === "minimal" ? value : null;
54
+ }
31
55
  // The placement and guard schemas the block-level sugar shares (spec/mutate
32
56
  // §1.1/§1.2; the effect vocabulary spells them the same way). blocks_insert /
33
57
  // blocks_move carry the op-level expectation of §1.2: only
@@ -54,7 +78,31 @@ const expectSchema = z.object({
54
78
  // checks every key, target kind and field spelling and reports the failure as
55
79
  // the same `filter_invalid { hint: "arguments" }` envelope, naming `effects[i]`
56
80
  // and the offending key — a message both engines compose from the spec.
57
- const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; any id may be $new.<n> for an identity an earlier effect creates");
81
+ /** `expect: { max }` of a query-directed request (spec/mutate §11.8, 4.1): the bound on the selection. */
82
+ const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
83
+ /** The selector half of `plan` / `apply` / `execute` arguments, as given (absent keys stay absent so the planner tells "no expect" apart). */
84
+ function selectorArgs(args) {
85
+ return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
86
+ }
87
+ const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target: document, field: \"frontmatter.<key>\", expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }. Target: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"attrs.checked\"; a document's \"frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
88
+ // An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
89
+ // each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
90
+ // a rung is a line beginning `{` with the call's arguments as JSON, three
91
+ // spaces, a comment; a line holding `…` or `<…>` is a schema line — and a last
92
+ // line pointing at `query_syntax`. The rungs are extracted mechanically
93
+ // (`mcp/examples.test.ts`) into `spec/surface/cases/examples.json` and run
94
+ // against the alchemy corpus, so every example stays true. Ids and paths are
95
+ // the alchemy fixture's (`d_14` is substances/salt.md, `b_153` the `Tasks`
96
+ // heading of processes/calcination.md). Since 4.4 the rungs are written in
97
+ // the `spec/mutate` 3.2 sugar — a bare path, id or `$new.n` as a target, the
98
+ // `-> expr` leader for an expression — and each trailing comment teaches one
99
+ // thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
100
+ // `mcp/examples.test.ts`).
101
+ const ladder = (...lines) => lines.join("\n");
102
+ // The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
103
+ // the spec minter gives `/processes/calcination.md`'s ingest revision over the
104
+ // alchemy corpus (`examples.json` runs the rung before any write).
105
+ const REVISION_RUNG = "{\"path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: from revisions where path == \"/processes/calcination.md\")";
58
106
  function ok(payload) {
59
107
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
60
108
  }
@@ -100,8 +148,18 @@ export function buildServer(ctx) {
100
148
  // `WriterLockTimeout`, rendered through `fail` like every other failure — a
101
149
  // plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
102
150
  // (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
151
+ //
152
+ // The profile (`ctx.tools`, spec/surface §4 "Profiles") decides which
153
+ // registrations happen at all: a tool outside it is neither listed nor
154
+ // callable — `tools/call` on its name is the same `unknown tool <name>` a
155
+ // name that never existed gets — and a tool inside it is registered exactly
156
+ // as under `full` (a profile filters listing and calling, never semantics).
157
+ const profile = ctx.tools ?? "full";
158
+ const inProfile = PROFILES[profile];
103
159
  const registry = new Map();
104
160
  function tool(name, def, handler) {
161
+ if (!inProfile(name))
162
+ return;
105
163
  const lockDir = ctx.omgbaseDir;
106
164
  const run = WRITE_TOOLS.has(name) && lockDir !== undefined
107
165
  ? async (args) => {
@@ -182,6 +240,15 @@ export function buildServer(ctx) {
182
240
  }
183
241
  return (byId ?? byPath).docId;
184
242
  }
243
+ // `docs_read { at }` and its sugar `docs_read_at` (spec/surface §2, 4.7): the
244
+ // document AS OF a revision, `spec/store` §6.2 reconstruct; a revision that is
245
+ // not the document's is `target_missing`.
246
+ function docsReadAt(docId, rev) {
247
+ const res = readDocumentAtRevision(store, docId, rev);
248
+ if (!res)
249
+ throw new EngineError("target_missing", `no revision ${JSON.stringify(rev)} for document ${docId}`, { data: { doc: docId, rev } });
250
+ return res;
251
+ }
185
252
  // The owning document of a block id (`blocks_read`): the block must be live.
186
253
  function docOfBlock(blockId) {
187
254
  const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
@@ -211,7 +278,12 @@ export function buildServer(ctx) {
211
278
  // several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
212
279
  // the heading's id to `blocks_append` instead). Never an id lookup — the
213
280
  // id form is `blocks_append`'s (§9).
214
- function resolveSectionHeading(docId, docPath, section) {
281
+ //
282
+ // Each miss carries a `fix` (spec/surface §9, 4.4): a literal corrected call.
283
+ // Several matches → `blocks_append` with the first candidate's id; none → the
284
+ // one heading whose text matches up to ASCII case, as `blocks_append`, else
285
+ // the same `docs_append` without `section` (the document's end).
286
+ function resolveSectionHeading(docId, docPath, section, call) {
215
287
  const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
216
288
  const rows = store.db
217
289
  .prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
@@ -219,11 +291,16 @@ export function buildServer(ctx) {
219
291
  const matches = rows.filter((r) => normalizeText(r.text) === needle);
220
292
  const shown = referencePath(docPath);
221
293
  if (matches.length === 0) {
222
- throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown } });
294
+ const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
295
+ const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
296
+ const fix = near.length === 1
297
+ ? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
298
+ : fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
299
+ throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
223
300
  }
224
301
  if (matches.length > 1) {
225
302
  throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(section)} matches ${matches.length} headings in ${shown}; pass the heading's id to blocks_append`, {
226
- data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })) },
303
+ data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })), fix: fixTool("blocks_append", { id: matches[0].block_id, text: call.text }) },
227
304
  });
228
305
  }
229
306
  return matches[0].block_id;
@@ -351,16 +428,26 @@ export function buildServer(ctx) {
351
428
  }
352
429
  });
353
430
  tool("docs_read", {
354
- description: "Read a whole document in one call: `raw` is the complete file bytes verbatim (fences/tables/list markers preserved), `properties` is the document's property bag grouped by source — `{ frontmatter, inline, computed }` — plus `id`/`path`/`rev` (the document's row attributes, spelled as the query language spells them). What `properties.frontmatter` holds is format-dependent: for markdown it's the parsed frontmatter fence; for YAML/JSON it's the parsed object the file represents; other adapters extract per their format. `inline` holds dataview-style `key:: value` fields from the body; `computed` holds engine intrinsics (`title`, `tags`). The cold-start 'read the guide before doing anything' call. Address the document by `id` or `path` (at least one; a query hit's `id`/`path` are the values to pass). Pass include_ids:true to also get the document's block ids in order (`ids`) AND `hashes` — a {block `id` → block `hash`} map giving the exact `expect.content_hash` value the raw `apply` kernel needs, so one read yields both the ids and the CAS tokens to mutate them (no follow-up blocks_read_many hydration) — AND `parents`, a {block id → parent block id | null} map (null = top level), because `ids` is a flat pre-order walk and a list is otherwise indistinguishable from its items; filter to `parents[id] === null` for the top-level blocks. For structure-only orientation use docs_outline; to hydrate a single block use blocks_read.",
431
+ description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
355
432
  inputSchema: {
356
433
  ...DOC_ARGS,
357
434
  include_ids: z.boolean().optional(),
435
+ at: z.string().optional(),
358
436
  ...REPO_ARG,
359
437
  },
360
438
  }, async (args) => {
361
439
  try {
362
440
  const { repoId } = repoScope(args.repo);
363
- const docId = locateDoc(repoId, "docs_read", registry.get("docs_read").shape, args);
441
+ const shape = registry.get("docs_read").shape;
442
+ const docId = locateDoc(repoId, "docs_read", shape, args);
443
+ if (args.at !== undefined) {
444
+ // `at` (4.7): the document AS OF that revision — `docs_read_at`'s
445
+ // code path, so the two return the same bytes. Block ids at a past
446
+ // revision are a later question: `include_ids` is refused with `at`.
447
+ if (args.include_ids)
448
+ throw argumentError("docs_read", "`include_ids` is not available with `at`", shape);
449
+ return ok(docsReadAt(docId, args.at));
450
+ }
364
451
  const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
365
452
  if (!res)
366
453
  throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
@@ -373,7 +460,7 @@ export function buildServer(ctx) {
373
460
  tool("docs_read_many", {
374
461
  description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`raw` = complete file bytes verbatim, `properties` grouped by source, plus `id`/`path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `id` → block `hash`} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/blocks_search/blocks_resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline (docs_get_many before surface 3.0).",
375
462
  inputSchema: {
376
- docs: z.array(z.string()),
463
+ docs: stringList(),
377
464
  include_ids: z.boolean().optional(),
378
465
  budget_tokens: z.number().int().optional(),
379
466
  ...REPO_ARG,
@@ -438,7 +525,7 @@ export function buildServer(ctx) {
438
525
  tool("blocks_read_many", {
439
526
  description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
440
527
  inputSchema: {
441
- ids: z.array(z.string()),
528
+ ids: stringList(),
442
529
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
443
530
  budget_tokens: z.number().int().optional(),
444
531
  ...REPO_ARG,
@@ -457,7 +544,7 @@ export function buildServer(ctx) {
457
544
  }
458
545
  });
459
546
  tool("docs_tree", {
460
- description: "ORIENTATION: the path-separator-aware shape of a repo in ONE small call — start here to answer 'what does this repo contain?', NOT with docs_list. Like `tree -L <depth>` crossed with `du`: every live document under `path` (a directory prefix; omit for the repo root) is collapsed at `depth` path segments (default 1 = immediate children), giving one entry per directory and one per shallow document. Returns `{ prefix, depth, total: { docs, blocks }, entries: [{ id?, path, kind: \"dir\"|\"doc\", docs, blocks, updated_at }], truncated, cursor }`: a `dir` entry's `path` ends in `/` (no `id`) and its `docs`/`blocks`/`updated_at` are the totals + latest last-commit timestamp UNDER it; a `doc` entry is a single document (docs = 1) with its `id`. `total` always covers everything under `prefix` regardless of paging, so you see the true size of what you're looking at. On an 800-document repo `depth:1` at the root is ~10 rows where docs_list would be ~800; descend by calling again with `path` set to a dir entry (or raise `depth`). Entries are ordered by path and paged: `limit` (default " + DOCS_TREE_DEFAULT_LIMIT + ") / `cursor` / `budget_tokens` with an honest `truncated`. For ranked or attribute-based discovery use `blocks_resolve`/`query`; to enumerate the documents themselves once you know where to look, use docs_list with a `path_glob`.",
547
+ description: ladder("docs_tree — Orientation: the shape of a repo in one small call, like tree -L depth with document and block counts per entry.", "{} the root at depth 1: one entry per directory (path ends in /) or shallow document, plus total", "{\"path\":\"/processes/\",\"depth\":1} descend into a directory entry", "{\"depth\":2,\"limit\":5} collapse deeper; limit/cursor page the entries, total always covers everything under path", "Ranked or attribute-based discovery: blocks_resolve, or query (select path from docs where ...). Grammar: query_syntax."),
461
548
  inputSchema: {
462
549
  path: z.string().optional(),
463
550
  depth: z.number().int().optional(),
@@ -505,15 +592,16 @@ export function buildServer(ctx) {
505
592
  }
506
593
  });
507
594
  tool("query_syntax", {
508
- description: "Reference: the full `query` (OQX) syntax — the clause order, the sugar (a block without a consumer is collect, where-first bodies, brackets, postfix `!`, `is`/`is not`, `and`/`or`/`not`), `/`-rooted paths, targets and field namespaces, scalar semantics and absence, domain predicates incl. `refs()`, nested queries and the root row (`^docs`, `^id`, `0^docs`), `select`/`order by`/paging, `follow`, and worked examples. Call this before writing a non-trivial query. No arguments.",
595
+ description: ladder("query_syntax — The full OQX reference: clause order, targets and fields, scalar semantics, nested queries and the root row, follow, paging, worked examples.", "{} no arguments; returns { syntax }", "Call it before writing a non-trivial query; every other tool's examples end here, at query_syntax."),
509
596
  inputSchema: {},
510
597
  }, async () => ok({ syntax: QUERY_SYNTAX }));
511
598
  tool("query", {
512
- description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Clause order is fixed: select, from, where, frontier, follow, order by, limit, offset — `select` first (`select path, era from docs where era > 1600 order by era desc limit 3`), its keyword optional only there; `from docs where … select …` is a parse error naming the order. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `dst.title`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. The clauses, in writing order: `select` projects the hit's columns (`select path, title`; an alias per computed item, `n: size(tags)`; no `select` = the bare envelope); `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|links` selects the repository's docs/blocks/nodes/links (the `links` target is the authored link graph as first-class rows — one row per link RELATION; the `md:link` node is a link's occurrence in the text, `via_id` points at it, and a frontmatter link has a row and no node — `id`/`path` plus the plain fields doc_id/block_id/via_id/dst_id/dst_path/uri/predicate/provenance/dst_kind/anchor/field, with path/doc.* reaching the SOURCE doc and `dst` the destination DOCUMENT row when the link points at one — `dst.title`, `dst.layer`, `dst_kind == \"document\"`; absent for an external or block destination and for a dangling target); `where` filters the rows; then `frontier`, `follow`, `order by`, `limit`/`offset` (each below). Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … frontier … follow … order by … limit N offset N` (`frontier <pred>` is a clause of a body that has a `follow` — it marks the body's SEEDS, oqx 0.20). Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause carries its keyword, with one piece of sugar (oqx 0.17): a block's leading expression is its `where` when it is predicate-shaped (`nodes exists { kind == \"md:task\" }` ≡ `nodes exists { where kind == \"md:task\" }`); a bare name or dotted path is a projection, so filter on a bare field with `is active`, and `{ name where … }` is the projection + filter (`{ age >= 18, name }` is an error). A top-level predicate needs `where` (`where` follows `from`, so `age > 15 from people` is an error), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `docs count { … }` for the consumer, or `select count from docs` to project a field named count). More sugar, each desugaring in the parser to the explicit directive: a receiver block without a consumer is `collect` (`docs { type == \"x\" }`, `nodes { kind == \"md:task\" }`, `size(nodes { is checked })` — wherever a value stands, and after a `follow` destination too: `follow children { depth 2 }` is that destination's block; `x distinct { … }` is `x collect distinct { … }`); a tuple `(a, b)` (two or more expressions, concatenated one level flat) is a value and a receiver (`\"x\" in (tags, aliases)`, `follow (before, after) { depth 2 }`); brackets `x[p]` = `x first { where p }` (the first match or absent), `x[p]!` = `x single { where p }` required (exactly one, else filter_invalid), `x[n]` = `x first { offset n }` (positional; out of range is absent); postfix `x!` = required (the value, or a loud error naming the expression and the row — never a filter, never a coercion; tightest: `a!.b` vs `a.b!`); prefix `is x` / `not x` (truthiness); infix `x is y` / `x is not y` compares identity (`$key`: a store row's id, else the value — `x is null` is the absence test; for scalars `is` ≡ `==`); and a `select` item may use the items to its left (`boss: ^docs[slug == ^manager], name: boss.title`). `and` / `or` / `not` are the connectives; `&&` / `||` / `!` are accepted synonyms (the canonical printer writes the words). `where` may reference the same body's `select` aliases (`select path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (doc_id, block_id, kind/name/value; doc_id, type/text, ordinal/depth) or row attribute (id, path, raw, hash, updated_at) reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $it values }` yields a plain array. `$it` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $it == \"pricing\" }`); inside a block `^$it` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" and not checked }` = no open tasks; also a whole-query directive (`docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$it` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $it }`, `from nodes where kind == \"md:task\" and entries(attrs) exists { where $key == \"checked\" and $it }`; works on `data` (the merged authored bag: frontmatter + inline) / `frontmatter` / `inline` (nested dotted keys folded back — an authored `$`-prefixed key such as `$version` is reached as `frontmatter.$version` and listed by `entries(frontmatter)`; a bare `$version` is an unknown OQX name and reads nothing), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `and`, `or`, `not`, and grouping (`layer == \"canon\" or nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select path, open from docs where nodes collect { ^open: value where kind == \"md:task\" and not attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the root row's collections `^docs` / `^nodes` / `^blocks` / `^links` (the repository is OQX's root row, one scope out from a top-level row — one caret per enclosing block, or the absolute `0^docs` from any depth; an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `^<t> exists { where … == ^k }` = semi-join, `not … exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: ^nodes single { where kind == \"person\" and attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `^docs` / `0^docs` (the root row's collections; `^id` / `0^id` is the repository id, `^$it` the root object). A bare `docs` INSIDE a block is an error (\"did you mean `^docs`\"), never an empty read; `$repo` (surface < 2.0) is refused with the replacement spelled out. PATHS are `/`-rooted on the surface (surface 2.0): every path returned — `path`, an edge's `dst_path`, a hit's `path` — is the reference form (`/a/b.md`), and a string literal compared with `path`/`dst_path` by `==`/`!=` or passed to their `startsWith` accepts both forms (`path == \"a.md\"` and `path == \"/a.md\"` both match); a property's value is the author's, so a reference held in frontmatter compares with `^path` directly (`^docs { ^path in list(after) }` = the documents whose `after` names me). `refs(field)` resolves the document REFERENCES a property holds (a string or a list of `/a.md`, `a.md` or `d_…` elements; dangling and non-string elements dropped) to live docs rows, so it is a source (`from refs(\"/a.md\")`), a receiver (`refs(before) collect { path, when }`, `refs(see_also)[0]`) or a `follow` destination (`follow refs(before), refs(after)` walks a timeline both ways); `follow before` over the strings fails — a hit must be a document, block, node or edge row. Top-level consumers: the whole query can be a consumer directive over a bare root receiver (a target name at the root scope) to change its result shape — `docs count { … }`, `docs exists { … }` and `docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `docs first { … }` / `docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (A bare target at the root scope is the same collection `^<target>` reaches from inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" and text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider that has embedded this repository (else `provider_unavailable`, naming what to configure — never a silent unranked result) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; a brace after any destination opens that destination's block (oqx 0.20 — there is no options block), an ordinary block with two clauses of its own after `where`: `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier; only occurrences this destination reached), `{ depth <n> }` bounds the walk through this destination (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). Identity is `$key` — a store row's id; a block may project `$key: <expr>` to rekey the rows it yields (a destination block's `$key:` is not a column) — e.g. `follow out_links collect { $key: group from dst }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" and $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in a destination block's `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2). The authored citation graph is walked through the LINK ROWS with a destination block (docs→docs): `follow out_links collect { from dst }` reaches the documents a doc links TO (`dst` is a link's destination document; an external or dangling link has none and contributes no successor), `follow in_links collect { from doc }` its backlinks (`doc` is the link's source); `depth`/`frontier` are clauses of the block — `follow out_links collect { from dst depth 2 }`; filter the rows first for a predicate-restricted walk (`follow out_links collect { from dst where predicate == \"depends_on\" }`). There is no `out`/`in` relation (surface 3.0 — a doc's `out`/`in` is an ordinary absent property). Consumers compose over the walk (`blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" and name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out_links`/`doc.in_links` (a doc's outgoing/incoming links as rows; the documents at their other end are `out_links collect distinct { from dst }` / `in_links collect distinct { from doc }`); from links — `doc` (the source document) and `dst` (the destination document, when there is one); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_links` (this block's outgoing links as rows — body links + inline fields; frontmatter links are doc-grain, use `doc.out_links`); from any row — `^docs` / `^nodes` / `^blocks` / `^links` (the root row's collections; `0^docs` from any depth). Returns lean hits {id, path, ...projections} — the envelope keys are the attributes, so a hit's `id` pastes into the next `where id == …` unchanged, and is the value a tool's `id` argument takes (the name rule, surface 3.0: omgbase adds no `$` names — `$` belongs to OQX's `$it`/`$key`/`$depth`…; every engine attribute is bare snake_case and WINS on collision — id/path everywhere, raw/hash/updated_at on docs and blocks, title/tags/format on docs — and an authored key of the same name is reached through `data.<k>` / `frontmatter.<k>` / `inline.<k>`; `select id, path` is simply the envelope and any other projection whose key would be id/path must be aliased (`p: data.path`); every tool result spells a row with the same names) — with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
599
+ description: ladder("query — rows (id, path, projection) from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where \\\"process\\\" in tags order by path limit 5\"} frontmatter; membership; order, limit", "{\"query\":\"select type, text from blocks where path == \\\"/processes/calcination.md\\\"\"} a document's blocks in order", "{\"query\":\"select block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id", "{\"query\":\"select path, value from nodes where kind == \\\"md:task\\\" and not checked\"} open tasks", "{\"query\":\"select path, block_id from links where dst_path == \\\"/processes/calcination.md\\\"\"} inbound links (path = source)", "{\"query\":\"select path from docs where title ~* \\\"salt\\\"\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
513
600
  inputSchema: {
514
601
  query: z.string(),
515
602
  limit: z.number().int().optional(),
516
603
  cursor: z.string().nullable().optional(),
604
+ bare: z.boolean().optional(),
517
605
  ...REPO_ARG,
518
606
  },
519
607
  }, async (args) => {
@@ -526,6 +614,7 @@ export function buildServer(ctx) {
526
614
  return ok(await oqxRunAsync(store, repoId, args.query, {
527
615
  ...(args.limit !== undefined ? { limit: args.limit } : {}),
528
616
  ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
617
+ ...(args.bare !== undefined ? { bare: args.bare } : {}),
529
618
  }, rankableEmbed(repoId)));
530
619
  }
531
620
  catch (e) {
@@ -535,11 +624,11 @@ export function buildServer(ctx) {
535
624
  tool("graph", {
536
625
  description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow out_links collect { from dst }` / `follow in_links collect { from doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"path\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `id`/`path`/`degree` = min hops from a root/`frontier` bool + your projections — a bare-name selection such as \"title\" is its own key, `id`/`path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `id`, `doc_id`/`block_id` (the source), `via_id`, `dst_id`, `dst_path`, `uri`, `dst_kind`, `predicate`, `provenance`, `anchor`, `field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ id, path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
537
626
  inputSchema: {
538
- roots: z.array(z.string()).min(1),
627
+ roots: stringList({ min: 1 }),
539
628
  degrees: z.number().int().optional(),
540
629
  direction: z.enum(["in", "out", "both"]).optional(),
541
630
  predicate: z.string().optional(),
542
- select: z.array(z.string()).optional(),
631
+ select: stringList().optional(),
543
632
  max_documents: z.number().int().optional(),
544
633
  ...REPO_ARG,
545
634
  },
@@ -569,7 +658,7 @@ export function buildServer(ctx) {
569
658
  }
570
659
  });
571
660
  tool("blocks_resolve", {
572
- description: "Resolve a name/title/concept to the concrete blocks it refers to — 'the id of the thing I mean'. Hybrid ranker (FTS + semantic when a provider is configured); best on short, entity-shaped inputs (a title, term, or phrase). Returns ranked `{ id, locator, preview, evidence }` (the block's id, its `path#type[ordinal]` locator); prefer the returned ids in follow-up calls. For open natural-language questions or passage retrieval, use `query` with `semantic` instead. (resolve before surface 3.0.)",
661
+ description: ladder("blocks_resolve — \"the id of the thing I mean\": a title or term, ranked to blocks { id, locator, preview, evidence }.", "{\"query\":\"calcination\"} best on short, entity-shaped input", "{\"query\":\"Emerald Tablet\",\"limit\":3} use the returned id in the next call (docs_read, a plan target)", "Open questions or passages: query with text(\"...\") or order by semantic(\"...\") desc. Grammar: query_syntax."),
573
662
  inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
574
663
  }, async (args) => {
575
664
  try {
@@ -586,9 +675,11 @@ export function buildServer(ctx) {
586
675
  }
587
676
  });
588
677
  tool("apply", {
589
- description: "The only real writer: an ORDERED list of `effects` — the intentional-write vocabulary `create insert set unset move remove split merge retarget` over documents, blocks, nodes and sections (the vocabulary `plan` documents, spec/mutate §11) — planned and committed as ONE transaction with one commit. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids: { \"$new.n\": id }, committed: true }` — one revision per touched document with its hash after the change (the same shape a changes_since revision has), `new_ids` the ids minted for the plan's symbolic identities, `commit` null when there was nothing to commit. To PREVIEW, call `plan` with the same `effects`: it returns the frozen Plan (diffs, results, the identity consequences) and burns no id; `execute` then commits exactly that plan. Every mutating tool is sugar over this one and returns the same Receipt. Guards: `expect.content_hash` on a block effect is the CAS of spec/mutate §1.2 (get it from docs_read include_ids' `hashes`, or omit it — effects pin the live hash for you); `expect.parent_children_hash` is the destination-parent CAS of insert/move; `expect.revision` pins a document. Conflicts carry current truth (`data.current.hash` + `markdown`) so retry from the error, don't re-read. Through surface 3.x this tool also took the six kernel ops as `ops` and a `dry_run`; both are retired (the kernel ops are a library/fixture contract since spec/mutate 3.0; the preview is `plan`) and refused by name.",
678
+ description: ladder("apply — plan and commit in one call; returns the Receipt { plan_id, commit, revisions, new_ids, committed, summary }.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/substances/salt.md\",\"field\":\"frontmatter.verified\",\"value\":false}],\"reason\":\"re-check salt\"} one scalar; a bare path is a document target", "{\"effects\":[{\"op\":\"insert\",\"into\":\"/lab/2026-02-notes.md\",\"at\":\"end\",\"markdown\":\"## Follow-ups\\n\\n- [ ] re-run cycle 4\"}]} append at a document's end", "{\"select\":\"from blocks where type == \\\"task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"attrs.checked\",\"value\":true}],\"expect\":{\"max\":10}} bulk check-off; expect.max is REQUIRED with select", "Plan first for anything you cannot undo by hand; every error's data.fix is a corrected call to copy. Grammar: query_syntax."),
590
679
  inputSchema: {
591
680
  effects: z.array(effectSchema),
681
+ select: z.string().optional(),
682
+ expect: selectionExpectSchema.optional(),
592
683
  reason: z.string().optional(),
593
684
  ...REPO_ARG,
594
685
  },
@@ -599,7 +690,7 @@ export function buildServer(ctx) {
599
690
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
600
691
  const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
601
692
  // Plan + execute under one lock and one transaction → the Receipt (spec/mutate §11.7).
602
- return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, origin, ...LOCK_DIR })));
693
+ return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR })));
603
694
  }
604
695
  catch (e) {
605
696
  return fail(e);
@@ -610,22 +701,29 @@ export function buildServer(ctx) {
610
701
  // commits exactly a plan (the plan form, or the digest form `plan_id` +
611
702
  // `effects`); `apply { effects }` above is plan + execute in one call.
612
703
  tool("plan", {
613
- description: "Plan a write without writing: lower an ORDERED list of `effects` — the intentional-write vocabulary `create insert set unset move remove split merge retarget` over documents, blocks, nodes and sections (spec/mutate §11) — to the kernel ops and document operations they mean, check their preconditions now (a stale `expect` fails here as it would at commit), reject contradictory effects (`conflicting_effects`: two writes to one scalar with different values, an effect addressing a target an earlier one removed — an identical duplicate is dropped with a diagnostic), and return the frozen Plan: `{ version: 1, planner_v, effects (targets resolved; `$new.n` kept symbolic), opset, preconditions: { docs, blocks, parents, absent_paths }, new_ids: [{ symbol, kind }], plan_id, diagnostics, preview: { results, revisions, diffs } }`. `plan_id` is the sha256 of the plan's executable core, so an identical plan over identical state has the same id and a plan burns no real id (new identities are `$new.0, $new.1, …` — a created document AND each of its blocks, in pre-order, so later effects may insert into / move into / edit a document the same request creates — minted at execute and mapped in the receipt). `preview` also carries `identity` (per `set content` document: the whole-document planner's `{ matcher_v, summary, ops: [{ op_index, disposition, blocks, confidence, reason }], diagnostics }`) and `retarget` (per `retarget` effect: `{ effect_index, hits, pairs }`) when the plan has such effects. Hand the whole Plan to `execute` (or just its `plan_id` with the same `effects`); nothing has expiry — the preconditions decide. Effect shapes: { op: \"create\", path, markdown, frontmatter? }, { op: \"insert\", into: Target, at? = \"end\", markdown, expect? }, { op: \"set\", target, field, value, expect? } (fields: a document's `frontmatter.<key>` or `content`, a block's `markdown` or `attrs.checked`, a node's editable property), { op: \"unset\", target: document, field: \"frontmatter.<key>\" }, { op: \"move\", target: document, to, retarget_inbound? = true }, { op: \"move\", targets: [block…], into, at?, expect? }, { op: \"remove\", target: document } | { targets: [block…], expect? }, { op: \"split\", target: block, at: [byte…] }, { op: \"merge\", targets: [block…], separator? }, { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; `expect` carries `content_hash`, `parent_children_hash` and/or the document's `revision`. Block content hashes you omit are pinned from the live rows. The plan's core keeps paths in the storage form (they are digested); only `preview` is rooted.",
614
- inputSchema: { effects: z.array(effectSchema), ...REPO_ARG },
704
+ description: ladder("plan — Preview a write: the Plan (summary, $new.n ids) burns no id; execute next. diffs: true for file bodies.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target: the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget; grammar: query_syntax."),
705
+ inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
615
706
  }, async (args) => {
616
707
  try {
617
708
  const { repoId, rootPath } = repoScope(args.repo);
618
709
  if (!rootPath)
619
710
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
620
- return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...LOCK_DIR })));
711
+ const plan = surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR }));
712
+ // (4.5) `preview.diffs` — whole before/after bodies of every touched
713
+ // document — only on request: `diffs: true`. The digest is over the
714
+ // core, never the preview, so `plan_id` is the same either way.
715
+ if (args.diffs === true || plan.preview === undefined)
716
+ return ok(plan);
717
+ const { diffs: _diffs, ...preview } = plan.preview;
718
+ return ok({ ...plan, preview });
621
719
  }
622
720
  catch (e) {
623
721
  return fail(e);
624
722
  }
625
723
  });
626
724
  tool("execute", {
627
- description: "Commit exactly a plan made by `plan`, as one transaction with one commit. Two forms, exactly one of them: `plan` — the Plan object as returned (`preview` may be dropped; `version` other than 1 → `unsupported_plan_version`, an edited core whose digest no longer equals its `plan_id` → `plan_mismatch`); or `plan_id` + `effects` — the engine re-plans the effects and commits only when the new plan's digest equals `plan_id` (else `plan_mismatch { expected, actual }`: the effects or the documents they target changed). Every precondition the plan froze is re-validated inside the transaction; a document that moved on fails `stale_plan` (retriable: re-plan) with nothing written, and replaying an already executed plan fails the same way. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids: { \"$new.0\": \"b_…\", … }, committed: true }` — `new_ids` maps the plan's symbolic identities to the ids minted. The commit records the plan as its intent (`commits.ops`).",
628
- inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), reason: z.string().optional(), ...REPO_ARG },
725
+ description: ladder("execute — Commit exactly a plan from plan: the plan form (the whole Plan object) or the digest form (plan_id plus the same effects, re-planned and committed only if the digest still matches).", "{\"plan_id\":\"3ece126452106400210f07047029a961691b814252ba4437f5a5f1c3301629da\",\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} digest form: the plan_id plan returned for these effects", "Or pass the whole object: {\"plan\": the Plan as returned}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
726
+ inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), select: z.string().optional(), expect: selectionExpectSchema.optional(), reason: z.string().optional(), ...REPO_ARG },
629
727
  }, async (args) => {
630
728
  try {
631
729
  const { repoId, rootPath } = repoScope(args.repo);
@@ -633,7 +731,8 @@ export function buildServer(ctx) {
633
731
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
634
732
  const shape = registry.get("execute").shape;
635
733
  const planForm = args.plan !== undefined;
636
- const digestForm = args.plan_id !== undefined || args.effects !== undefined;
734
+ const selector = args.select !== undefined || args.expect !== undefined;
735
+ const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
637
736
  if (planForm === digestForm)
638
737
  throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
639
738
  if (digestForm && (args.plan_id === undefined || args.effects === undefined))
@@ -641,7 +740,7 @@ export function buildServer(ctx) {
641
740
  const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
642
741
  const receipt = planForm
643
742
  ? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
644
- : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, origin, ...LOCK_DIR });
743
+ : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
645
744
  return okMutated(surfaceReceipt(receipt));
646
745
  }
647
746
  catch (e) {
@@ -693,7 +792,7 @@ export function buildServer(ctx) {
693
792
  });
694
793
  tool("blocks_move", {
695
794
  description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
696
- inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
795
+ inputSchema: { blocks: stringList(), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
697
796
  }, async (args) => {
698
797
  try {
699
798
  const { repoId, rootPath } = repoScope(args.repo);
@@ -714,7 +813,7 @@ export function buildServer(ctx) {
714
813
  });
715
814
  tool("blocks_remove", {
716
815
  description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
717
- inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
816
+ inputSchema: { blocks: stringList(), ...REPO_ARG },
718
817
  }, async (args) => {
719
818
  try {
720
819
  const { repoId, rootPath } = repoScope(args.repo);
@@ -744,7 +843,7 @@ export function buildServer(ctx) {
744
843
  });
745
844
  tool("blocks_merge", {
746
845
  description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
747
- inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
846
+ inputSchema: { blocks: stringList(), separator: z.string().optional(), ...REPO_ARG },
748
847
  }, async (args) => {
749
848
  try {
750
849
  const { repoId, rootPath } = repoScope(args.repo);
@@ -759,7 +858,7 @@ export function buildServer(ctx) {
759
858
  });
760
859
  tool("blocks_check", {
761
860
  description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
762
- inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
861
+ inputSchema: { ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
763
862
  }, async (args) => {
764
863
  try {
765
864
  const { repoId, rootPath } = repoScope(args.repo);
@@ -799,7 +898,7 @@ export function buildServer(ctx) {
799
898
  const root = requireRoot(rootPath);
800
899
  const block = resolveBlockRef(repoId, args.id);
801
900
  const row = store.db
802
- .prepare("SELECT b.type AS type, d.format AS format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
901
+ .prepare("SELECT b.type AS type, d.format AS format, b.parent_block AS parent, d.path AS path, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
803
902
  .get(block);
804
903
  if (!row)
805
904
  throw blockMissing(args.id);
@@ -811,8 +910,9 @@ export function buildServer(ctx) {
811
910
  into = blockTarget(block);
812
911
  }
813
912
  else {
913
+ // The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
814
914
  throw new EngineError("block_not_container", `block ${block} is a ${row.type}, not a heading or a container; append means into — insert after it with blocks_insert { to: <its parent>, at: { after: ${JSON.stringify(block)} } }`, {
815
- data: { id: block, type: row.type },
915
+ data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
816
916
  });
817
917
  }
818
918
  return sugar(repoId, root, appendEffects(into, args.text));
@@ -836,7 +936,7 @@ export function buildServer(ctx) {
836
936
  let into;
837
937
  if (args.section !== undefined) {
838
938
  const info = findDoc(store, { docId });
839
- into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section) };
939
+ into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section, { ...(args.id !== undefined ? { id: args.id } : {}), ...(args.path !== undefined ? { path: args.path } : {}), text: args.text }) };
840
940
  }
841
941
  else {
842
942
  into = { kind: "document", id: docId };
@@ -986,7 +1086,7 @@ export function buildServer(ctx) {
986
1086
  });
987
1087
  tool("docs_set_meta", {
988
1088
  description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
989
- inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
1089
+ inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
990
1090
  }, async (args) => {
991
1091
  try {
992
1092
  const scope = repoScope(args.repo);
@@ -1057,7 +1157,7 @@ export function buildServer(ctx) {
1057
1157
  }
1058
1158
  });
1059
1159
  tool("blocks_history", {
1060
- description: "A block's biography: the commits that touched the block `id` names, with disposition kind/confidence/reason, newest first (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
1160
+ description: "A block's biography: the commits that CHANGED the block `id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
1061
1161
  inputSchema: { id: z.string(), limit: z.number().int().optional() },
1062
1162
  }, async (args) => {
1063
1163
  try {
@@ -1102,16 +1202,13 @@ export function buildServer(ctx) {
1102
1202
  }
1103
1203
  });
1104
1204
  tool("docs_read_at", {
1105
- description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. `properties` are CURRENT values (propertiesAreCurrent:true), since property history is not stored. Address the document by `id` or `path` plus a `rev` id.",
1205
+ description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Sugar for `docs_read { id | path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`from revisions where path == … order by seq`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `id` or `path` plus a `rev` id.",
1106
1206
  inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
1107
1207
  }, async (args) => {
1108
1208
  try {
1109
1209
  const { repoId } = repoScope(args.repo);
1110
1210
  const docId = locateDoc(repoId, "docs_read_at", registry.get("docs_read_at").shape, args);
1111
- const res = readDocumentAtRevision(store, docId, args.rev);
1112
- if (!res)
1113
- throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
1114
- return ok(res);
1211
+ return ok(docsReadAt(docId, args.rev));
1115
1212
  }
1116
1213
  catch (e) {
1117
1214
  return fail(e);
@@ -1155,7 +1252,7 @@ export function buildServer(ctx) {
1155
1252
  }
1156
1253
  });
1157
1254
  tool("changes_since", {
1158
- description: "The change feed: commit digests after a cursor (repo commit seq) with one-line summaries. Each digest's `revisions[]` carries `{ id, path, hash }` (hash = hex of the revision's rendered file hash; the same shape an apply result's revisions have), so a synchronizer can decide 'changed vs echo' without a follow-up docs_read. Filter by `origin` (api/observed/import) to ignore commits a given writer produced. Poll with your last cursor to cheaply re-orient after time away. Paging: `cursor` is the last digest's `seq` (a dense per-REPO order — a cursor is only meaningful against the same `repo` it came from); pass it back while `truncated` is true. `head` is the repo's current max seq: an empty page with cursor == head means nothing new, cursor > head means the cursor belongs to a different repo/server.",
1255
+ description: ladder("changes_since — Re-orient after time away: the commit feed after a cursor, each digest with its revisions [{ id, path, hash }].", "{} everything from the start; head is the current max seq", "{\"cursor\":20,\"limit\":5} commits after seq 20, five at a time (cursor pages while truncated)", "{\"origin\":\"api\"} only the writes made through the engine (observed = edits on disk; import)", "Poll with your last cursor: an empty page with cursor == head means nothing new. Read a changed document with docs_read, its blocks with query (grammar: query_syntax)."),
1159
1256
  inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
1160
1257
  }, async (args) => {
1161
1258
  try {
@@ -1217,12 +1314,11 @@ export function buildServer(ctx) {
1217
1314
  // Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
1218
1315
  // answer is about the binary and its database, not a repo.
1219
1316
  tool("version", {
1220
- description: "Which engine and which versions you are talking to: `{ engine: \"typescript\"|\"rust\", version, components: { <package>: <version> }, specs: { oqx, format, reconcile, store, properties, graph, search, mutate, sync, surface, cli }, schema, mcp: { protocol, sdk? }, runtime, commit, built }`. " +
1221
- "`version` is the serving binary's own; `components` every omgbase package it is built from; `specs` the spec versions it implements (compile-time constants); `schema` the database's `PRAGMA user_version`; `commit`/`built` the build's git revision and RFC 3339 time (null when unknown). No arguments, no repo scope.",
1317
+ description: ladder("version — Which engine and which versions you are talking to, and which tools profile it serves.", "{} engine, version, components, specs, schema, mcp, runtime, commit, built and profile (\"full\" or \"minimal\")", "Under the minimal profile ten tools are listed; calling an unlisted one is the JSON-RPC error unknown tool. Reads go through query (grammar: query_syntax)."),
1222
1318
  inputSchema: {},
1223
1319
  }, async () => {
1224
1320
  try {
1225
- return ok(versionInfo(store, ctx.host));
1321
+ return ok(versionInfo(store, ctx.host, profile));
1226
1322
  }
1227
1323
  catch (e) {
1228
1324
  return fail(e);
@@ -1236,8 +1332,14 @@ export function buildServer(ctx) {
1236
1332
  server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1237
1333
  const name = request.params.name;
1238
1334
  const entry = registry.get(name);
1335
+ // An unregistered name — unknown, or hidden by the profile — is the JSON-RPC
1336
+ // `-32602` error with exactly the text both engines pin (spec/surface §4
1337
+ // "Profiles"): `unknown tool <name>`. The SDK copies a thrown error's
1338
+ // integer `code` and its `message` onto the wire verbatim; `McpError`
1339
+ // would prefix the message ("MCP error -32602: …"), so a plain Error
1340
+ // carries the code instead.
1239
1341
  if (!entry)
1240
- throw new McpError(ErrorCode.InvalidParams, `Tool ${name} not found`);
1342
+ throw unknownTool(name);
1241
1343
  let args;
1242
1344
  try {
1243
1345
  args = validateArguments(name, entry.shape, request.params.arguments);
@@ -1249,8 +1351,14 @@ export function buildServer(ctx) {
1249
1351
  });
1250
1352
  return server;
1251
1353
  }
1354
+ /** The pinned text of a `tools/call` on a name the server does not serve (spec/surface §4 "Profiles"). */
1355
+ export const UNKNOWN_TOOL_CODE = ErrorCode.InvalidParams; // -32602
1356
+ export function unknownTool(name) {
1357
+ return Object.assign(new Error(`unknown tool ${name}`), { code: UNKNOWN_TOOL_CODE });
1358
+ }
1252
1359
  /** The catalog's tool names in registration order with their descriptions —
1253
- * what `spec/surface/tool-descriptions.json` pins for both engines. */
1360
+ * what `spec/surface/tool-descriptions.json` pins for both engines (over a
1361
+ * `full` server; a profiled server lists its subset in the same order). */
1254
1362
  export async function toolDescriptions(server) {
1255
1363
  const tools = server._registeredTools;
1256
1364
  const out = {};
@@ -1258,4 +1366,8 @@ export async function toolDescriptions(server) {
1258
1366
  out[name] = t.description ?? "";
1259
1367
  return out;
1260
1368
  }
1369
+ /** The tool names a server lists, in registration order (`spec/surface/cases/profiles.json` pins them per profile). */
1370
+ export async function toolNames(server) {
1371
+ return Object.keys(await toolDescriptions(server));
1372
+ }
1261
1373
  //# sourceMappingURL=server.js.map