@omgbase/core 0.15.0 → 0.19.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.
@@ -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";
@@ -17,6 +17,7 @@ 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,27 @@ 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.
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
+ }
57
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, 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");
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");
58
102
  function ok(payload) {
59
103
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
60
104
  }
@@ -100,8 +144,18 @@ export function buildServer(ctx) {
100
144
  // `WriterLockTimeout`, rendered through `fail` like every other failure — a
101
145
  // plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
102
146
  // (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
147
+ //
148
+ // The profile (`ctx.tools`, spec/surface §4 "Profiles") decides which
149
+ // registrations happen at all: a tool outside it is neither listed nor
150
+ // callable — `tools/call` on its name is the same `unknown tool <name>` a
151
+ // name that never existed gets — and a tool inside it is registered exactly
152
+ // as under `full` (a profile filters listing and calling, never semantics).
153
+ const profile = ctx.tools ?? "full";
154
+ const inProfile = PROFILES[profile];
103
155
  const registry = new Map();
104
156
  function tool(name, def, handler) {
157
+ if (!inProfile(name))
158
+ return;
105
159
  const lockDir = ctx.omgbaseDir;
106
160
  const run = WRITE_TOOLS.has(name) && lockDir !== undefined
107
161
  ? async (args) => {
@@ -211,7 +265,12 @@ export function buildServer(ctx) {
211
265
  // several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
212
266
  // the heading's id to `blocks_append` instead). Never an id lookup — the
213
267
  // id form is `blocks_append`'s (§9).
214
- function resolveSectionHeading(docId, docPath, section) {
268
+ //
269
+ // Each miss carries a `fix` (spec/surface §9, 4.4): a literal corrected call.
270
+ // Several matches → `blocks_append` with the first candidate's id; none → the
271
+ // one heading whose text matches up to ASCII case, as `blocks_append`, else
272
+ // the same `docs_append` without `section` (the document's end).
273
+ function resolveSectionHeading(docId, docPath, section, call) {
215
274
  const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
216
275
  const rows = store.db
217
276
  .prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
@@ -219,11 +278,16 @@ export function buildServer(ctx) {
219
278
  const matches = rows.filter((r) => normalizeText(r.text) === needle);
220
279
  const shown = referencePath(docPath);
221
280
  if (matches.length === 0) {
222
- throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown } });
281
+ const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
282
+ const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
283
+ const fix = near.length === 1
284
+ ? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
285
+ : fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
286
+ throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
223
287
  }
224
288
  if (matches.length > 1) {
225
289
  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 })) },
290
+ 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
291
  });
228
292
  }
229
293
  return matches[0].block_id;
@@ -351,7 +415,7 @@ export function buildServer(ctx) {
351
415
  }
352
416
  });
353
417
  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.",
418
+ 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)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
355
419
  inputSchema: {
356
420
  ...DOC_ARGS,
357
421
  include_ids: z.boolean().optional(),
@@ -457,7 +521,7 @@ export function buildServer(ctx) {
457
521
  }
458
522
  });
459
523
  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`.",
524
+ 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
525
  inputSchema: {
462
526
  path: z.string().optional(),
463
527
  depth: z.number().int().optional(),
@@ -505,15 +569,16 @@ export function buildServer(ctx) {
505
569
  }
506
570
  });
507
571
  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.",
572
+ 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
573
  inputSchema: {},
510
574
  }, async () => ok({ syntax: QUERY_SYNTAX }));
511
575
  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`).",
576
+ description: ladder("query — Read anything: rows with id and path plus your projection, from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where type == \\\"process\\\"\"} frontmatter fields by name", "{\"query\":\"select b: block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id by its text (the heading_id an insert into a section takes)", "{\"query\":\"select value from nodes where kind == \\\"md:task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\"} open tasks in one document (a task node's text is its value)", "{\"query\":\"docs count { where layer == \\\"canon\\\" }\"} a scalar", "{\"query\":\"select path from docs where \\\"process\\\" in tags order by path limit 5\"} list membership, ordering; a truncated page's cursor continues it", "Full grammar, follow, ^docs joins, text()/semantic(): call query_syntax."),
513
577
  inputSchema: {
514
578
  query: z.string(),
515
579
  limit: z.number().int().optional(),
516
580
  cursor: z.string().nullable().optional(),
581
+ bare: z.boolean().optional(),
517
582
  ...REPO_ARG,
518
583
  },
519
584
  }, async (args) => {
@@ -526,6 +591,7 @@ export function buildServer(ctx) {
526
591
  return ok(await oqxRunAsync(store, repoId, args.query, {
527
592
  ...(args.limit !== undefined ? { limit: args.limit } : {}),
528
593
  ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
594
+ ...(args.bare !== undefined ? { bare: args.bare } : {}),
529
595
  }, rankableEmbed(repoId)));
530
596
  }
531
597
  catch (e) {
@@ -569,7 +635,7 @@ export function buildServer(ctx) {
569
635
  }
570
636
  });
571
637
  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.)",
638
+ 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
639
  inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
574
640
  }, async (args) => {
575
641
  try {
@@ -586,9 +652,11 @@ export function buildServer(ctx) {
586
652
  }
587
653
  });
588
654
  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.",
655
+ 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
656
  inputSchema: {
591
657
  effects: z.array(effectSchema),
658
+ select: z.string().optional(),
659
+ expect: selectionExpectSchema.optional(),
592
660
  reason: z.string().optional(),
593
661
  ...REPO_ARG,
594
662
  },
@@ -599,7 +667,7 @@ export function buildServer(ctx) {
599
667
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
600
668
  const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
601
669
  // 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 })));
670
+ return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR })));
603
671
  }
604
672
  catch (e) {
605
673
  return fail(e);
@@ -610,22 +678,22 @@ export function buildServer(ctx) {
610
678
  // commits exactly a plan (the plan form, or the digest form `plan_id` +
611
679
  // `effects`); `apply { effects }` above is plan + execute in one call.
612
680
  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 },
681
+ description: ladder("plan — Preview a write: the frozen Plan (summary, diffs, $new.n per minted id) burns no id; execute next.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = document", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} under a heading", "{\"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."),
682
+ inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
615
683
  }, async (args) => {
616
684
  try {
617
685
  const { repoId, rootPath } = repoScope(args.repo);
618
686
  if (!rootPath)
619
687
  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 })));
688
+ return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR })));
621
689
  }
622
690
  catch (e) {
623
691
  return fail(e);
624
692
  }
625
693
  });
626
694
  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 },
695
+ 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."),
696
+ 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
697
  }, async (args) => {
630
698
  try {
631
699
  const { repoId, rootPath } = repoScope(args.repo);
@@ -633,7 +701,8 @@ export function buildServer(ctx) {
633
701
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
634
702
  const shape = registry.get("execute").shape;
635
703
  const planForm = args.plan !== undefined;
636
- const digestForm = args.plan_id !== undefined || args.effects !== undefined;
704
+ const selector = args.select !== undefined || args.expect !== undefined;
705
+ const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
637
706
  if (planForm === digestForm)
638
707
  throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
639
708
  if (digestForm && (args.plan_id === undefined || args.effects === undefined))
@@ -641,7 +710,7 @@ export function buildServer(ctx) {
641
710
  const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
642
711
  const receipt = planForm
643
712
  ? 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 });
713
+ : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
645
714
  return okMutated(surfaceReceipt(receipt));
646
715
  }
647
716
  catch (e) {
@@ -799,7 +868,7 @@ export function buildServer(ctx) {
799
868
  const root = requireRoot(rootPath);
800
869
  const block = resolveBlockRef(repoId, args.id);
801
870
  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")
871
+ .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
872
  .get(block);
804
873
  if (!row)
805
874
  throw blockMissing(args.id);
@@ -811,8 +880,9 @@ export function buildServer(ctx) {
811
880
  into = blockTarget(block);
812
881
  }
813
882
  else {
883
+ // The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
814
884
  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 },
885
+ data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
816
886
  });
817
887
  }
818
888
  return sugar(repoId, root, appendEffects(into, args.text));
@@ -836,7 +906,7 @@ export function buildServer(ctx) {
836
906
  let into;
837
907
  if (args.section !== undefined) {
838
908
  const info = findDoc(store, { docId });
839
- into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section) };
909
+ 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
910
  }
841
911
  else {
842
912
  into = { kind: "document", id: docId };
@@ -1155,7 +1225,7 @@ export function buildServer(ctx) {
1155
1225
  }
1156
1226
  });
1157
1227
  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.",
1228
+ 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
1229
  inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
1160
1230
  }, async (args) => {
1161
1231
  try {
@@ -1217,12 +1287,11 @@ export function buildServer(ctx) {
1217
1287
  // Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
1218
1288
  // answer is about the binary and its database, not a repo.
1219
1289
  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.",
1290
+ 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
1291
  inputSchema: {},
1223
1292
  }, async () => {
1224
1293
  try {
1225
- return ok(versionInfo(store, ctx.host));
1294
+ return ok(versionInfo(store, ctx.host, profile));
1226
1295
  }
1227
1296
  catch (e) {
1228
1297
  return fail(e);
@@ -1236,8 +1305,14 @@ export function buildServer(ctx) {
1236
1305
  server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1237
1306
  const name = request.params.name;
1238
1307
  const entry = registry.get(name);
1308
+ // An unregistered name — unknown, or hidden by the profile — is the JSON-RPC
1309
+ // `-32602` error with exactly the text both engines pin (spec/surface §4
1310
+ // "Profiles"): `unknown tool <name>`. The SDK copies a thrown error's
1311
+ // integer `code` and its `message` onto the wire verbatim; `McpError`
1312
+ // would prefix the message ("MCP error -32602: …"), so a plain Error
1313
+ // carries the code instead.
1239
1314
  if (!entry)
1240
- throw new McpError(ErrorCode.InvalidParams, `Tool ${name} not found`);
1315
+ throw unknownTool(name);
1241
1316
  let args;
1242
1317
  try {
1243
1318
  args = validateArguments(name, entry.shape, request.params.arguments);
@@ -1249,8 +1324,14 @@ export function buildServer(ctx) {
1249
1324
  });
1250
1325
  return server;
1251
1326
  }
1327
+ /** The pinned text of a `tools/call` on a name the server does not serve (spec/surface §4 "Profiles"). */
1328
+ export const UNKNOWN_TOOL_CODE = ErrorCode.InvalidParams; // -32602
1329
+ export function unknownTool(name) {
1330
+ return Object.assign(new Error(`unknown tool ${name}`), { code: UNKNOWN_TOOL_CODE });
1331
+ }
1252
1332
  /** The catalog's tool names in registration order with their descriptions —
1253
- * what `spec/surface/tool-descriptions.json` pins for both engines. */
1333
+ * what `spec/surface/tool-descriptions.json` pins for both engines (over a
1334
+ * `full` server; a profiled server lists its subset in the same order). */
1254
1335
  export async function toolDescriptions(server) {
1255
1336
  const tools = server._registeredTools;
1256
1337
  const out = {};
@@ -1258,4 +1339,8 @@ export async function toolDescriptions(server) {
1258
1339
  out[name] = t.description ?? "";
1259
1340
  return out;
1260
1341
  }
1342
+ /** The tool names a server lists, in registration order (`spec/surface/cases/profiles.json` pins them per profile). */
1343
+ export async function toolNames(server) {
1344
+ return Object.keys(await toolDescriptions(server));
1345
+ }
1261
1346
  //# sourceMappingURL=server.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
3
+ * bytes; members whose value is `undefined` omitted (an optional key absent is
4
+ * absent, never `null`; a JSON `null` value stays `null`); strings escaped only
5
+ * as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
6
+ * `\u00xx` lower-case) — everything else literal UTF-8; a number that is
7
+ * integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
8
+ * finite number by ECMAScript `Number::toString` (shortest round-trip digits);
9
+ * `true`/`false`/`null`.
10
+ */
11
+ export declare function canonicalJson(v: unknown): string;
12
+ /** A `plan` / `apply` / `execute` request as the caller gave it: `select`, `effects`, `expect` (each only when present). */
13
+ export interface FixRequest {
14
+ select?: unknown;
15
+ effects: unknown;
16
+ expect?: unknown;
17
+ }
18
+ /** The `fix` for a request to the SAME tool: the corrected arguments, canonical JSON. */
19
+ export declare function fixRequest(req: FixRequest): string;
20
+ /** The `fix` naming another tool: `<tool> <canonical args>`. */
21
+ export declare function fixTool(tool: string, args: unknown): string;
22
+ /** The selector that names exactly one row: the request narrowed to `from <root> where id == "<id>"` with `expect.max: 1` (the fix of a cross-hit collision or an ambiguous target under a selector). */
23
+ export declare function narrowedTo(root: "docs" | "blocks" | "nodes", id: string): string;
24
+ /** The root a row id belongs to, by its prefix (`d_` / `b_` / `n_`). */
25
+ export declare function rootOfId(id: string): "docs" | "blocks" | "nodes";
26
+ /** `value` with the member at `path` (keys and array indices) replaced; a copy, the input untouched. */
27
+ export declare function withMember(value: unknown, path: readonly string[], replacement: unknown): unknown;
@@ -0,0 +1,138 @@
1
+ import { MutationError } from "./tree.js";
2
+ /** The argument-shape error of the surface (`effects.ts` `effectInvalid`), spelled here to avoid the module cycle. */
3
+ function effectInvalid(message) {
4
+ return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
5
+ }
6
+ // Canonical JSON (spec/mutate §11.5): the text a plan's digest is taken over,
7
+ // and since 3.3 the text every error `fix` and every `summary` value is spelled
8
+ // in — one serialization both engines produce byte for byte. Kept apart from
9
+ // `plan.ts` so the compile (`select.ts`) and the lowering (`effects.ts`) can use
10
+ // it without a module cycle.
11
+ function cmpUtf8(a, b) {
12
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
13
+ }
14
+ // A lone surrogate has no UTF-8 encoding: it cannot be digested (nor sent as JSON text).
15
+ function isWellFormed(s) {
16
+ for (let i = 0; i < s.length; i++) {
17
+ const c = s.charCodeAt(i);
18
+ if (c >= 0xd800 && c <= 0xdbff) {
19
+ const n = s.charCodeAt(i + 1);
20
+ if (!(n >= 0xdc00 && n <= 0xdfff))
21
+ return false;
22
+ i++;
23
+ }
24
+ else if (c >= 0xdc00 && c <= 0xdfff)
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+ function canonicalString(s) {
30
+ if (!isWellFormed(s))
31
+ throw effectInvalid("strings must be well-formed Unicode (a lone surrogate cannot be digested)");
32
+ let out = '"';
33
+ for (let i = 0; i < s.length; i++) {
34
+ const c = s.charCodeAt(i);
35
+ switch (c) {
36
+ case 0x22:
37
+ out += '\\"';
38
+ break;
39
+ case 0x5c:
40
+ out += "\\\\";
41
+ break;
42
+ case 0x08:
43
+ out += "\\b";
44
+ break;
45
+ case 0x0c:
46
+ out += "\\f";
47
+ break;
48
+ case 0x0a:
49
+ out += "\\n";
50
+ break;
51
+ case 0x0d:
52
+ out += "\\r";
53
+ break;
54
+ case 0x09:
55
+ out += "\\t";
56
+ break;
57
+ default:
58
+ out += c < 0x20 ? `\\u${c.toString(16).padStart(4, "0")}` : s[i];
59
+ }
60
+ }
61
+ return out + '"';
62
+ }
63
+ function canonicalNumber(n) {
64
+ if (!Number.isFinite(n))
65
+ throw effectInvalid("a number in a plan must be finite");
66
+ if (Number.isInteger(n) && Math.abs(n) < 2 ** 53)
67
+ return String(n === 0 ? 0 : n);
68
+ return String(n);
69
+ }
70
+ /**
71
+ * Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
72
+ * bytes; members whose value is `undefined` omitted (an optional key absent is
73
+ * absent, never `null`; a JSON `null` value stays `null`); strings escaped only
74
+ * as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
75
+ * `\u00xx` lower-case) — everything else literal UTF-8; a number that is
76
+ * integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
77
+ * finite number by ECMAScript `Number::toString` (shortest round-trip digits);
78
+ * `true`/`false`/`null`.
79
+ */
80
+ export function canonicalJson(v) {
81
+ if (v === null)
82
+ return "null";
83
+ switch (typeof v) {
84
+ case "boolean":
85
+ return v ? "true" : "false";
86
+ case "number":
87
+ return canonicalNumber(v);
88
+ case "string":
89
+ return canonicalString(v);
90
+ case "object": {
91
+ if (Array.isArray(v))
92
+ return `[${v.map((x) => canonicalJson(x === undefined ? null : x)).join(",")}]`;
93
+ const o = v;
94
+ const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(cmpUtf8);
95
+ return `{${keys.map((k) => `${canonicalString(k)}:${canonicalJson(o[k])}`).join(",")}}`;
96
+ }
97
+ default:
98
+ throw effectInvalid(`a ${typeof v} cannot be digested`);
99
+ }
100
+ }
101
+ /** The `fix` for a request to the SAME tool: the corrected arguments, canonical JSON. */
102
+ export function fixRequest(req) {
103
+ return canonicalJson({
104
+ ...(req.select !== undefined ? { select: req.select } : {}),
105
+ effects: req.effects,
106
+ ...(req.expect !== undefined ? { expect: req.expect } : {}),
107
+ });
108
+ }
109
+ /** The `fix` naming another tool: `<tool> <canonical args>`. */
110
+ export function fixTool(tool, args) {
111
+ return `${tool} ${canonicalJson(args)}`;
112
+ }
113
+ /** The selector that names exactly one row: the request narrowed to `from <root> where id == "<id>"` with `expect.max: 1` (the fix of a cross-hit collision or an ambiguous target under a selector). */
114
+ export function narrowedTo(root, id) {
115
+ return `from ${root} where id == ${JSON.stringify(id)}`;
116
+ }
117
+ /** The root a row id belongs to, by its prefix (`d_` / `b_` / `n_`). */
118
+ export function rootOfId(id) {
119
+ if (id.startsWith("d_"))
120
+ return "docs";
121
+ if (id.startsWith("n_"))
122
+ return "nodes";
123
+ return "blocks";
124
+ }
125
+ /** `value` with the member at `path` (keys and array indices) replaced; a copy, the input untouched. */
126
+ export function withMember(value, path, replacement) {
127
+ if (path.length === 0)
128
+ return replacement;
129
+ const [head, ...rest] = path;
130
+ if (Array.isArray(value)) {
131
+ const copy = [...value];
132
+ copy[Number(head)] = withMember(copy[Number(head)], rest, replacement);
133
+ return copy;
134
+ }
135
+ const o = (typeof value === "object" && value !== null ? value : {});
136
+ return { ...o, [head]: withMember(o[head], rest, replacement) };
137
+ }
138
+ //# sourceMappingURL=canonical.js.map