@omgbase/core 0.8.0 → 0.10.1

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.
@@ -24,6 +24,8 @@ import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
24
24
  import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
25
25
  import { QUERY_SYNTAX } from "./reference.js";
26
26
  import { versionInfo } from "../version.js";
27
+ import { storagePath } from "../core/paths.js";
28
+ import { surfaceApplyResult, surfaceDocOpResult, surfaceDocMoveResult, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfaceOpset, surfaceDocsUpdate, } from "../surface-paths.js";
27
29
  // Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
28
30
  // each op a real schema (rather than z.any()) makes the required fields
29
31
  // discoverable in the tool's JSON schema and turns a malformed op — e.g. an
@@ -218,7 +220,7 @@ export function buildServer(ctx) {
218
220
  // Apply an already-built op list, honoring dry_run (preview, no drain) — the
219
221
  // shared tail of every block-level tool below.
220
222
  function applyOps(repoId, rootPath, ops, reason, dryRun) {
221
- const res = apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason }, ...(dryRun !== undefined ? { dryRun } : {}) });
223
+ const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason }, ...(dryRun !== undefined ? { dryRun } : {}) }));
222
224
  return dryRun ? ok(res) : okMutated(res);
223
225
  }
224
226
  // Guard shared by every mutating tool: a sourceless (headless-only) repo has no
@@ -344,7 +346,7 @@ export function buildServer(ctx) {
344
346
  }
345
347
  });
346
348
  server.registerTool("read_ref", {
347
- description: "Read ANY ref — a document (`d_…` id or repo-relative path) OR a block (`b_…` id, or an `n_…` node id which dereferences to its block) — and get its content, classified. Resolves the ref server-side (the polymorphic `omg cat`): a document ref returns `{ kind:\"document\", content, path, docId, … }` (complete file bytes; `resolution` does not apply); a block ref returns `{ kind:\"block\", … }` — the block subtree at `resolution` (raw|text|outline|skeleton|full, default raw). Use this when you hold a ref and want its bytes WITHOUT first knowing whether it names a document or a block. Contrast: docs_read needs a doc; nodes_get needs a block id.",
349
+ description: "Read ANY ref — a document (`d_…` id or path, with or without its leading `/`) OR a block (`b_…` id, or an `n_…` node id which dereferences to its block) — and get its content, classified. Resolves the ref server-side (the polymorphic `omg cat`): a document ref returns `{ kind:\"document\", content, path, docId, … }` (complete file bytes; `resolution` does not apply); a block ref returns `{ kind:\"block\", … }` — the block subtree at `resolution` (raw|text|outline|skeleton|full, default raw). Use this when you hold a ref and want its bytes WITHOUT first knowing whether it names a document or a block. Contrast: docs_read needs a doc; nodes_get needs a block id.",
348
350
  inputSchema: {
349
351
  ref: z.string(),
350
352
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
@@ -397,7 +399,7 @@ export function buildServer(ctx) {
397
399
  }
398
400
  });
399
401
  server.registerTool("docs_list", {
400
- description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ path, blocks, ts }], truncated, cursor }` — repo-relative path, live block count, last-commit timestamp (ISO, or null) — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `resolve`.",
402
+ description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ path, blocks, ts }], truncated, cursor }` — the document's path (`/`-rooted, as every path the surface returns), live block count, last-commit timestamp (ISO, or null) — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `resolve`.",
401
403
  inputSchema: {
402
404
  path_glob: z.string().optional(),
403
405
  limit: z.number().int().optional(),
@@ -420,11 +422,11 @@ export function buildServer(ctx) {
420
422
  }
421
423
  });
422
424
  server.registerTool("query_syntax", {
423
- description: "Reference: the full `query` syntax — targets, the CEL filter subset, absence semantics, structural functions, link-graph traversal (OQX `follow`), `select` projection, and worked examples. Call this before writing a non-trivial filter. No arguments.",
425
+ 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.",
424
426
  inputSchema: {},
425
427
  }, async () => ok({ syntax: QUERY_SYNTAX }));
426
428
  server.registerTool("query", {
427
- description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); 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. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. 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 … follow … order by … limit N offset N`. 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 always carries its keyword — a predicate is NEVER implicit: a block filters with `where` (`nodes exists { where kind == \"md:task\" }`; `nodes exists { kind == \"md:task\" }` is an error pointing at `where`), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `$repo.docs count { … }` for the consumer, or `select count from docs` to project a field named count). `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 (kind/name/value; type/text) or `$`-intrinsic 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\" && !checked }` = no open tasks; also a whole-query directive (`$repo.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\" && entries(attrs) exists { where $key == \"checked\" && $it }`; works on `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `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 grouping (`layer == \"canon\" || nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `$repo.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\" && !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 explicit root relations `$repo.docs` / `$repo.nodes` / `$repo.blocks` (an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `repo.<t> exists { where … == ^k }` = semi-join, `!… 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: $repo.nodes single { where kind == \"person\" && 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 `$repo.<target>` (a root scan, from any depth). Top-level consumers: the whole query can be a consumer directive over a root receiver to change its result shape — `$repo.docs count { … }`, `$repo.docs exists { … }` and `$repo.docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `$repo.docs first { … }` / `$repo.docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (The `$repo.<target>` receiver is the same root relation used 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\" && 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 (else semantic_unavailable) 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; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` 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\" && $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in the follow-local successor `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), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). Consumers compose over the walk (`$repo.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\" && 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`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); 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_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any scope — `$repo.docs` / `$repo.nodes` / `$repo.blocks`. Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
429
+ description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); 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. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. 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 … follow … order by … limit N offset N`. 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\" }` — not after `follow`, where `{ … }` is the options block); 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 (a 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 (kind/name/value; type/text) or `$`-intrinsic 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 `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `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` / `^edges` (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`, `$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 (else semantic_unavailable) 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; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` 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 the follow-local successor `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), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). 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`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); 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_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any row — `^docs` / `^nodes` / `^blocks` / `^edges` (the root row's collections; `0^docs` from any depth). Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
428
430
  inputSchema: {
429
431
  query: z.string(),
430
432
  limit: z.number().int().optional(),
@@ -479,7 +481,7 @@ export function buildServer(ctx) {
479
481
  }, async (args) => {
480
482
  try {
481
483
  const { repoId } = repoScope(args.repo);
482
- return ok(textSearch(store, repoId, args.q, args.limit !== undefined ? { limit: args.limit } : {}));
484
+ return ok(surfaceTextSearch(textSearch(store, repoId, args.q, args.limit !== undefined ? { limit: args.limit } : {})));
483
485
  }
484
486
  catch (e) {
485
487
  return fail(e);
@@ -496,7 +498,7 @@ export function buildServer(ctx) {
496
498
  // configured (mirrors CLI `omg find`). Absent ⇒ FTS-only.
497
499
  if (ctx.embedQuery)
498
500
  input.vector = await ctx.embedQuery(args.query);
499
- return ok(resolveThing(store, input));
501
+ return ok(surfaceResolveHits(resolveThing(store, input)));
500
502
  }
501
503
  catch (e) {
502
504
  return fail(e);
@@ -515,12 +517,12 @@ export function buildServer(ctx) {
515
517
  const { repoId, rootPath } = repoScope(args.repo);
516
518
  if (!rootPath)
517
519
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
518
- const res = apply(store, {
520
+ const res = surfaceApplyResult(apply(store, {
519
521
  repoId, rootPath,
520
522
  ops: args.ops,
521
523
  origin: { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) },
522
524
  ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
523
- });
525
+ }));
524
526
  // A dry run previews without writing — don't schedule a drain for it.
525
527
  return args.dry_run ? ok(res) : okMutated(res);
526
528
  }
@@ -569,7 +571,7 @@ export function buildServer(ctx) {
569
571
  ? { ...(args.checked !== undefined ? { checked: args.checked } : {}), ...(args.attrs ?? {}) }
570
572
  : undefined;
571
573
  const ops = [{ op: "update", block, ...(args.markdown !== undefined ? { markdown: args.markdown } : {}), ...(attrs ? { attrs } : {}), ...(expect ? { expect } : {}) }];
572
- const res = apply(store, { repoId, rootPath: root, ops, origin: { actor: "agent:mcp", reason: "blocks_update" }, ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}) });
574
+ const res = surfaceApplyResult(apply(store, { repoId, rootPath: root, ops, origin: { actor: "agent:mcp", reason: "blocks_update" }, ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}) }));
573
575
  // `ids` lists every resulting block (the target first, then any siblings
574
576
  // minted from multi-block content); `id` keeps the target for convenience.
575
577
  const ids = res.results[0]?.ids ?? [block];
@@ -708,7 +710,7 @@ export function buildServer(ctx) {
708
710
  // their ids; only the appended blocks are minted).
709
711
  const docId = resolveDocId(repoId, { ...(args.doc ? { doc: args.doc } : {}), ...(args.path ? { path: args.path } : {}) });
710
712
  const ops = docsAppend(docId, args.text);
711
- return okMutated(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "docs_append" } }));
713
+ return okMutated(surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "docs_append" } })));
712
714
  }
713
715
  catch (e) {
714
716
  return fail(e);
@@ -722,9 +724,10 @@ export function buildServer(ctx) {
722
724
  const { repoId, rootPath } = repoScope(args.repo);
723
725
  if (!rootPath)
724
726
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
725
- const { ops, hits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
727
+ const { ops, hits: rawHits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
728
+ const hits = surfaceRetargetHits(rawHits);
726
729
  const dryRun = args.dry_run !== false;
727
- const res = apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_retarget" }, dryRun });
730
+ const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_retarget" }, dryRun }));
728
731
  if (dryRun)
729
732
  return ok({ hits, pairs, applied: false, ...res });
730
733
  return okMutated({ hits, pairs, applied: true, ...res });
@@ -734,7 +737,7 @@ export function buildServer(ctx) {
734
737
  }
735
738
  });
736
739
  server.registerTool("links_stale", {
737
- description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each with srcPath, srcBlock, predicate, provenance, `target` = the canonical missing path WITHOUT a leading `/` (e.g. `guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{srcPath,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
740
+ description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each with srcPath, srcBlock, predicate, provenance, `target` = the canonical missing path, `/`-rooted like every path the surface returns (e.g. `/guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{srcPath,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
738
741
  inputSchema: { path_glob: z.string().optional(), limit: z.number().int().optional(), summary: z.boolean().optional(), ...REPO_ARG },
739
742
  }, async (args) => {
740
743
  try {
@@ -773,11 +776,12 @@ export function buildServer(ctx) {
773
776
  if (!repairs || repairs.length === 0) {
774
777
  throw new EngineError("target_missing", "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair");
775
778
  }
776
- const { ops, hits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
779
+ const { ops, hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
780
+ const hits = surfaceRetargetHits(rawHits);
777
781
  // The dry run plans through the same kernel path (dryRun: no write, no
778
782
  // drain) so a preview surfaces exactly the failure an apply would hit.
779
783
  const dryRun = args.dry_run !== false;
780
- const res = apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_repair" }, dryRun });
784
+ const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_repair" }, dryRun }));
781
785
  if (dryRun)
782
786
  return ok({ hits, pairs, applied: false, ...res });
783
787
  return okMutated({ hits, pairs, applied: true, ...res });
@@ -802,18 +806,18 @@ export function buildServer(ctx) {
802
806
  inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
803
807
  }, async (args) => {
804
808
  try {
805
- return docResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter), args.dry_run);
809
+ return docResult(surfaceDocOpResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter)), args.dry_run);
806
810
  }
807
811
  catch (e) {
808
812
  return fail(e);
809
813
  }
810
814
  });
811
815
  server.registerTool("docs_move", {
812
- description: "Rename a document to a new repo-relative path; block identity and history are preserved, and the inbound links FOLLOW it: every link written against the old path (Markdown links/images, wikilinks, bare-path inline fields — anchors, link text, titles and code spans preserved, absolute vs relative style kept) is rewritten to the new path in the same call as one CAS-checked changeset, so a move never leaves the graph broken. Fails path_taken if the destination exists. The result carries `retargeted` (the touched blocks/docs) and `dangling` — what could NOT be rewritten, i.e. frontmatter relations (block null; fix with docs_set_meta). Pass `retarget_inbound:false` to opt out for the rarer intent that the old path become unbound: the links are left as written, listed in `dangling` ({doc, path, block, target, anchor, field?} per occurrence) and reported by links_stale (their edges become `phantom:<old path>`); links already written against the new path start resolving to this doc either way. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus the rewritten sources) — without renaming or committing.",
816
+ description: "Rename a document to a new path (`/`-rooted or bare; the result reports the rooted form); block identity and history are preserved, and the inbound links FOLLOW it: every link written against the old path (Markdown links/images, wikilinks, bare-path inline fields — anchors, link text, titles and code spans preserved, absolute vs relative style kept) is rewritten to the new path in the same call as one CAS-checked changeset, so a move never leaves the graph broken. Fails path_taken if the destination exists. The result carries `retargeted` (the touched blocks/docs) and `dangling` — what could NOT be rewritten, i.e. frontmatter relations (block null; fix with docs_set_meta). Pass `retarget_inbound:false` to opt out for the rarer intent that the old path become unbound: the links are left as written, listed in `dangling` ({doc, path, block, target, anchor, field?} per occurrence) and reported by links_stale (their edges become `phantom:<old path>`); links already written against the new path start resolving to this doc either way. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus the rewritten sources) — without renaming or committing.",
813
817
  inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
814
818
  }, async (args) => {
815
819
  try {
816
- return docResult(docsMove(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, args.to_path, { retargetInbound: args.retarget_inbound !== false }), args.dry_run);
820
+ return docResult(surfaceDocMoveResult(docsMove(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, args.to_path, { retargetInbound: args.retarget_inbound !== false })), args.dry_run);
817
821
  }
818
822
  catch (e) {
819
823
  return fail(e);
@@ -824,7 +828,7 @@ export function buildServer(ctx) {
824
828
  inputSchema: { doc: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
825
829
  }, async (args) => {
826
830
  try {
827
- return docResult(docsDelete(store, docCtx(repoScope(args.repo), args.dry_run), args.doc), args.dry_run);
831
+ return docResult(surfaceDocOpResult(docsDelete(store, docCtx(repoScope(args.repo), args.dry_run), args.doc)), args.dry_run);
828
832
  }
829
833
  catch (e) {
830
834
  return fail(e);
@@ -835,10 +839,10 @@ export function buildServer(ctx) {
835
839
  inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
836
840
  }, async (args) => {
837
841
  try {
838
- return docResult(docsSetMeta(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, {
842
+ return docResult(surfaceDocOpResult(docsSetMeta(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, {
839
843
  ...(args.set ? { set: args.set } : {}),
840
844
  ...(args.unset ? { unset: args.unset } : {}),
841
- }), args.dry_run);
845
+ })), args.dry_run);
842
846
  }
843
847
  catch (e) {
844
848
  return fail(e);
@@ -852,7 +856,7 @@ export function buildServer(ctx) {
852
856
  const { repoId, rootPath } = repoScope(args.repo);
853
857
  if (!rootPath)
854
858
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
855
- const opset = planUpdate(store, repoId, rootPath, args.doc, args.content);
859
+ const opset = surfaceOpset(planUpdate(store, repoId, rootPath, args.doc, args.content));
856
860
  return ok({ opset, plan: renderOpsetPlan(opset) });
857
861
  }
858
862
  catch (e) {
@@ -864,10 +868,10 @@ export function buildServer(ctx) {
864
868
  inputSchema: { doc: z.string(), content: z.string(), reason: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
865
869
  }, async (args) => {
866
870
  try {
867
- const { opset, result } = docsUpdate(store, docCtx(repoScope(args.repo)), args.doc, args.content, {
871
+ const { opset, result } = surfaceDocsUpdate(docsUpdate(store, docCtx(repoScope(args.repo)), args.doc, args.content, {
868
872
  ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
869
873
  ...(args.reason !== undefined ? { reason: args.reason } : {}),
870
- });
874
+ }));
871
875
  const payload = { opset, plan: renderOpsetPlan(opset), result };
872
876
  return args.dry_run ? ok(payload) : okMutated(payload);
873
877
  }
@@ -881,7 +885,7 @@ export function buildServer(ctx) {
881
885
  }, async (args) => {
882
886
  try {
883
887
  const { repoId } = repoScope(args.repo);
884
- return okMutated(observeFile(store, repoId, args.path, args.content));
888
+ return okMutated(surfaceObserveResult(observeFile(store, repoId, storagePath(args.path), args.content)));
885
889
  }
886
890
  catch (e) {
887
891
  return fail(e);
@@ -893,7 +897,7 @@ export function buildServer(ctx) {
893
897
  }, async (args) => {
894
898
  try {
895
899
  const { repoId } = repoScope(args.repo);
896
- return okMutated(observeMany(store, repoId, args.files));
900
+ return okMutated(observeMany(store, repoId, args.files.map((f) => ({ ...f, path: storagePath(f.path) }))).map(surfaceObserveResult));
897
901
  }
898
902
  catch (e) {
899
903
  return fail(e);
@@ -905,7 +909,7 @@ export function buildServer(ctx) {
905
909
  }, async (args) => {
906
910
  try {
907
911
  const { repoId } = repoScope(args.repo);
908
- return okMutated(observeDelete(store, repoId, args.path));
912
+ return okMutated(surfaceObserveDeleteResult(observeDelete(store, repoId, storagePath(args.path))));
909
913
  }
910
914
  catch (e) {
911
915
  return fail(e);
@@ -994,7 +998,7 @@ export function buildServer(ctx) {
994
998
  const info = findDocByRef(store, repoId, args.doc);
995
999
  const asId = isValidId(args.doc, "d");
996
1000
  const known = info || (args.include_deleted
997
- ? store.db.prepare(asId ? "SELECT 1 FROM docs WHERE doc_id = ?" : "SELECT 1 FROM docs WHERE repo_id = ? AND path = ?").get(...(asId ? [args.doc] : [repoId, args.doc]))
1001
+ ? store.db.prepare(asId ? "SELECT 1 FROM docs WHERE doc_id = ?" : "SELECT 1 FROM docs WHERE repo_id = ? AND path = ?").get(...(asId ? [args.doc] : [repoId, storagePath(args.doc)]))
998
1002
  : undefined);
999
1003
  if (!known)
1000
1004
  throw new EngineError("doc_missing", `no document for ${JSON.stringify(args.doc)}`);
@@ -23,7 +23,8 @@ export declare function tagRows(rows: Record<string, unknown>[], target: Target)
23
23
  /**
24
24
  * A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
25
25
  * values, 1.2) is rendered as `{ id, path }` — its id column and its document's
26
- * path, the same two keys every hit carries — never the store row itself.
26
+ * path in the reference form (`/a.md`, §1 "Paths"), the same two keys every hit
27
+ * carries — never the store row itself.
27
28
  * Returns undefined for anything that is not a tagged row (run.ts leaves those
28
29
  * alone).
29
30
  */
@@ -13,16 +13,18 @@
13
13
  // pushes the hot predicates down; both must agree (conformance suite).
14
14
  import { semantics } from "@omgbase/oqx";
15
15
  import { storeIndexFor } from "./store-index.js";
16
+ import { TARGETS, bareTargetMessage } from "./root-row.js";
16
17
  import { COLS, FROM, ORDER, guards } from "./sql/scan.js";
17
18
  import { detectRange } from "../core/store/properties.js";
18
19
  import { docsRead } from "../core/read/document.js";
20
+ import { referencePath, storagePath } from "../core/paths.js";
19
21
  import { FilterInvalid } from "../search/cel/parser.js";
20
22
  import { sanitizeFtsQuery } from "../search/fts-query.js";
21
23
  import { cosineBytes } from "../core/vec.js";
22
24
  import { ctxHashHex } from "../search/embeddings.js";
23
25
  import { makeBlockContextResolver } from "../search/tasks.js";
24
26
  const TARGET = Symbol("oqx.target");
25
- const REPO_ROOT = Symbol("oqx.repoRoot");
27
+ const ROOT_ROW = Symbol("oqx.rootRow");
26
28
  const PROP_SOURCE = Symbol("oqx.propSource");
27
29
  function tag(row, t) {
28
30
  if (!row)
@@ -36,9 +38,9 @@ function tagAll(rows, t) {
36
38
  return rows;
37
39
  }
38
40
  // docs intrinsics whose BARE (non-$) form is almost always a typo (10 §2); a bare
39
- // read of one is a loud error, matching the CEL guard.
40
- // (The root-scan receiver is the `$repo` intrinsic — `$repo.docs`/`$repo.nodes`/… —
41
- // so a bare `repo` is just an ordinary frontmatter key.)
41
+ // read of one is a loud error, matching the CEL guard. (A bare target name —
42
+ // `docs`, `edges`, … — is a loud error of its own, root-row.ts; `repo` is just an
43
+ // ordinary frontmatter key.)
42
44
  const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
43
45
  export function makeStoreContext(store, repoId, opts = {}) {
44
46
  const db = store.db;
@@ -61,11 +63,11 @@ export function makeStoreContext(store, repoId, opts = {}) {
61
63
  // iteration, indexing, `length`, rendering all see a plain array of tagged
62
64
  // rows) that runs its SELECT on first use. The engine's optimizer asks
63
65
  // `indexFor` about a nested block's receiver BEFORE reading it, so a
64
- // correlated probe on `$repo.docs` / `$repo.edges` / … is answered from the
66
+ // correlated probe on `^docs` / `^edges` / … is answered from the
65
67
  // store's indexes (store-index.ts) and the scan never runs; anything else
66
68
  // (a top-level `from docs`, a `count { }`, a non-indexable path) reads it
67
69
  // whole exactly as before. One handle per target per context: a context lives
68
- // for one run, during which the store does not change, so `$repo.docs` read
70
+ // for one run, during which the store does not change, so `^docs` read
69
71
  // from every outer row is one SELECT, not one per row — and the engine, which
70
72
  // keys collections by identity, sees the same collection each time.
71
73
  // `rootScans` recognizes the handles for `indexFor`. Because the handle IS an
@@ -106,9 +108,18 @@ export function makeStoreContext(store, repoId, opts = {}) {
106
108
  const rootFns = {
107
109
  docs: () => lazyRoot("docs"), blocks: () => lazyRoot("blocks"), nodes: () => lazyRoot("nodes"), edges: () => lazyRoot("edges"),
108
110
  };
109
- // The repository handle behind the `$repo` intrinsic: `$repo.<target>` is the
110
- // explicit root scan (rootFns), `$repo.$id` the repository id.
111
- const repoRoot = { [REPO_ROOT]: true };
111
+ // The root row (spec/surface §1.1, 2.0; spec/oqx 0.18 `rootObject`): the
112
+ // repository, reached as `^$it` from a top-level row (`0^$it` from any depth).
113
+ // Its own enumerable keys are the four collections — lazy getters, so
114
+ // `entries(^$it)` names them without running a scan and `^$it.docs` is the
115
+ // same handle as `^docs` — and `get` answers `$id` with the repository id (an
116
+ // intrinsic, not an entry). Tagged so `get` recognizes it.
117
+ const rootRow = Object.defineProperties({ [ROOT_ROW]: true }, {
118
+ docs: { enumerable: true, get: () => lazyRoot("docs") },
119
+ blocks: { enumerable: true, get: () => lazyRoot("blocks") },
120
+ nodes: { enumerable: true, get: () => lazyRoot("nodes") },
121
+ edges: { enumerable: true, get: () => lazyRoot("edges") },
122
+ });
112
123
  // ---- attrs / property decoding --------------------------------------------
113
124
  const parseJson = (v) => {
114
125
  if (typeof v !== "string")
@@ -316,6 +327,10 @@ export function makeStoreContext(store, repoId, opts = {}) {
316
327
  ? tag(one(`SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?`, row.block_id), "blocks")
317
328
  : undefined;
318
329
  // ---- intrinsics ($-fields) ------------------------------------------------
330
+ // `$path` / `$dst_path` are the REFERENCE form (`/a/b.md`, spec/surface §1
331
+ // "Paths", 2.0): the rows carry the storage form (`path`, the `__path` join
332
+ // column) and the intrinsic roots it on read, so nothing below the binding
333
+ // changes. run.ts roots the string literals a query compares them with.
319
334
  const intrinsic = (row, t, name) => {
320
335
  // `$self` = the current row: run.ts rewrites row-scoped domain functions
321
336
  // (text/semantic/under_*/…) to `$self.fn(…)` so they arrive via callMethod
@@ -325,7 +340,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
325
340
  if (t === "docs")
326
341
  switch (name) {
327
342
  case "$id": return row.doc_id;
328
- case "$path": return row.path;
343
+ case "$path": return referencePath(String(row.path));
329
344
  case "$content_hash": return row.file_hash == null ? null : hex(row.file_hash);
330
345
  case "$updated_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?`, row.current_rev) ?? null;
331
346
  case "$body": return docsRead(store, String(row.doc_id))?.content ?? null;
@@ -336,7 +351,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
336
351
  switch (name) {
337
352
  case "$id": return row.block_id;
338
353
  case "$doc": return row.doc_id;
339
- case "$path": return row.__path;
354
+ case "$path": return referencePath(String(row.__path));
340
355
  case "$ordinal": return row.ordinal;
341
356
  case "$depth": return row.depth;
342
357
  case "$body": return row.text;
@@ -349,7 +364,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
349
364
  case "$node_id": return row.node_id;
350
365
  case "$doc_id": return row.doc_id;
351
366
  case "$block_id": return row.block_id;
352
- case "$path": return row.__path;
367
+ case "$path": return referencePath(String(row.__path));
353
368
  }
354
369
  if (t === "edges")
355
370
  switch (name) {
@@ -359,8 +374,11 @@ export function makeStoreContext(store, repoId, opts = {}) {
359
374
  case "$src_block": return row.src_block;
360
375
  case "$via": return row.via_node;
361
376
  case "$from_commit": return row.from_commit;
362
- case "$path": return row.__path;
363
- case "$dst_path": return scalar(`SELECT path FROM docs WHERE doc_id = ?`, row.dst_node) ?? null;
377
+ case "$path": return referencePath(String(row.__path));
378
+ case "$dst_path": {
379
+ const p = scalar(`SELECT path FROM docs WHERE doc_id = ?`, row.dst_node);
380
+ return p == null ? null : referencePath(String(p));
381
+ }
364
382
  case "$dst_uri": return scalar(`SELECT uri FROM external_nodes WHERE node_id = ?`, row.dst_node) ?? null;
365
383
  }
366
384
  return undefined;
@@ -369,14 +387,9 @@ export function makeStoreContext(store, repoId, opts = {}) {
369
387
  const getFrom = (row, key) => {
370
388
  if (row == null)
371
389
  return undefined;
372
- // `$repo` is an intrinsic of EVERY scope — the root and any row, store-backed
373
- // or plain. Bare names resolve against the current row only (oqx ≥ 0.7: no
374
- // scope climbing), so a correlated subquery at any depth reaches the
375
- // repository root through this local intrinsic, never by falling through
376
- // to an enclosing scope.
377
- if (key === "$repo")
378
- return repoRoot;
379
- if (row[REPO_ROOT]) {
390
+ // The root row: `^$it.$id` is the repository id, `^$it.<target>` the lazy
391
+ // root scan (the same handle `^<target>` / `root(name)` hands out).
392
+ if (row[ROOT_ROW]) {
380
393
  if (key === "$id")
381
394
  return repoId;
382
395
  return rootFns[key] ? rootFns[key]() : undefined;
@@ -409,6 +422,13 @@ export function makeStoreContext(store, repoId, opts = {}) {
409
422
  const rels = rel[t];
410
423
  if (rels[key])
411
424
  return rels[key](r);
425
+ // A bare target name that is not one of this row's relations reads a
426
+ // property the row has none of (spec/surface §1.1, 2.0): loud, naming the
427
+ // root-row spelling. `docs`/`edges` never get here (run.ts refuses them
428
+ // statically); `blocks` on a block or edge row and `nodes` on a node or
429
+ // edge row do.
430
+ if (TARGETS.has(key))
431
+ throw new FilterInvalid(bareTargetMessage(key, null), "OQX");
412
432
  // per-target scalar fields.
413
433
  if (t === "docs") {
414
434
  if (key === "format")
@@ -551,14 +571,17 @@ export function makeStoreContext(store, repoId, opts = {}) {
551
571
  return !!one(`SELECT 1 FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
552
572
  WHERE s.doc_id = ? AND lower(hb.text) LIKE '%' || lower(?) || '%' AND s.first_ordinal <= ? AND s.last_ordinal >= ? LIMIT 1`, r.doc_id, text, top, top);
553
573
  };
574
+ // `within(t)`: a doc id, else a path in either form (`/texts/*`, `texts/*`,
575
+ // `texts/x.md`) matched against the storage path.
554
576
  const within = (r, target) => {
555
577
  if (target.startsWith("d_"))
556
578
  return r.doc_id === target;
557
- if (target.includes("*")) {
558
- const like = target.replace(/[%_]/g, "\\$&").replace(/\*/g, "%");
579
+ const path = storagePath(target);
580
+ if (path.includes("*")) {
581
+ const like = path.replace(/[%_]/g, "\\$&").replace(/\*/g, "%");
559
582
  return !!one(`SELECT 1 WHERE ? LIKE ? ESCAPE '\\'`, r.__path, like);
560
583
  }
561
- return r.__path === target;
584
+ return r.__path === path;
562
585
  };
563
586
  const underKind = (r, kind, name) => {
564
587
  const ap = String(r.ancestor_path ?? "").split("/").filter(Boolean);
@@ -577,6 +600,36 @@ export function makeStoreContext(store, repoId, opts = {}) {
577
600
  return false;
578
601
  return jattr(r, "key") === leaf || jattr(r, "key") === key;
579
602
  };
603
+ // ---- refs(x): document references held in a property (spec/surface §1.3, 1.5)
604
+ // `x` is a string, a list, or absent; every string element that names a live
605
+ // document of this repo — a doc id (`d_…`), a repo-root-absolute path
606
+ // (`/a/b.md`) or a bare repo-relative path (`a/b.md`) — resolves to that
607
+ // document's row; anything else (a dangling reference, a non-string element)
608
+ // is dropped. Order preserved, duplicates kept. Each element is one indexed
609
+ // lookup under the root scan's guards and columns (`sql/scan.ts`), so the rows
610
+ // are indistinguishable from scanned ones and the docs root is never read.
611
+ const refDoc = (ref) => {
612
+ const g = guards("docs", repoId);
613
+ const path = storagePath(ref);
614
+ const byPath = one(`SELECT ${COLS.docs} FROM ${FROM.docs} WHERE ${g.sql} AND d.path = ?`, ...g.params, path);
615
+ if (byPath)
616
+ return tag(byPath, "docs");
617
+ if (!ref.startsWith("d_"))
618
+ return undefined;
619
+ return tag(one(`SELECT ${COLS.docs} FROM ${FROM.docs} WHERE ${g.sql} AND d.doc_id = ?`, ...g.params, ref), "docs");
620
+ };
621
+ const refs = (x) => {
622
+ const items = x == null ? [] : Array.isArray(x) ? x : [x];
623
+ const out = [];
624
+ for (const item of items) {
625
+ if (typeof item !== "string")
626
+ continue;
627
+ const row = refDoc(item);
628
+ if (row)
629
+ out.push(row);
630
+ }
631
+ return out;
632
+ };
580
633
  // Store indexes by (target, path) — one object, one set of prepared statements,
581
634
  // for the whole run (`null`: the path is not indexable).
582
635
  const indexes = new Map();
@@ -593,13 +646,21 @@ export function makeStoreContext(store, repoId, opts = {}) {
593
646
  return row;
594
647
  };
595
648
  return {
649
+ // The root scope's names (spec/oqx §2 step 5 at the root): the four lazy
650
+ // scans and `$id`, the repository id — so `^docs` / `0^docs` from any row is
651
+ // the scan and `^$id` / `0^$id` the id.
596
652
  root(name) {
597
653
  if (opts.rowsRoot && name === opts.rowsRoot.name)
598
654
  return opts.rowsRoot.rows;
599
- if (name === "$repo")
600
- return repoRoot;
655
+ if (name === "$id")
656
+ return repoId;
601
657
  return rootFns[name] ? rootFns[name]() : undefined;
602
658
  },
659
+ // The root scope's row (oqx 0.18): the repository, so `^$it` from a
660
+ // top-level row is the root row and `^$it.docs` the docs scan.
661
+ rootObject() {
662
+ return rootRow;
663
+ },
603
664
  get: getFrom,
604
665
  toRows(value) {
605
666
  if (value == null)
@@ -645,6 +706,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
645
706
  const p = args[0];
646
707
  return { handled: true, value: docPropEntries(p.docId, p.source) };
647
708
  }
709
+ if (name === "refs")
710
+ return { handled: true, value: refs(args[0]) };
648
711
  if (name === "range") {
649
712
  const s = args[0];
650
713
  if (semantics.isRange(s))
@@ -681,7 +744,8 @@ export function tagRows(rows, target) {
681
744
  /**
682
745
  * A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
683
746
  * values, 1.2) is rendered as `{ id, path }` — its id column and its document's
684
- * path, the same two keys every hit carries — never the store row itself.
747
+ * path in the reference form (`/a.md`, §1 "Paths"), the same two keys every hit
748
+ * carries — never the store row itself.
685
749
  * Returns undefined for anything that is not a tagged row (run.ts leaves those
686
750
  * alone).
687
751
  */
@@ -692,7 +756,7 @@ export function rowRef(value) {
692
756
  const r = value;
693
757
  const id = t === "docs" ? r.doc_id : t === "blocks" ? r.block_id : t === "nodes" ? r.node_id : r.edge_id;
694
758
  const path = t === "docs" ? r.path : r.__path;
695
- return { id: String(id), path: String(path ?? "") };
759
+ return { id: String(id), path: path == null ? "" : referencePath(String(path)) };
696
760
  }
697
761
  export { TARGET };
698
762
  //# sourceMappingURL=context.js.map
@@ -1,7 +1,8 @@
1
- import type { Query, Where, QueryPlanner, Plan } from "@omgbase/oqx";
1
+ import type { Query, Expr, Where, QueryPlanner, Plan } from "@omgbase/oqx";
2
2
  import type { Store } from "../core/store/store.js";
3
3
  import { type StoreContextOptions } from "./context.js";
4
4
  import { type Target } from "./sql/translate.js";
5
+ export declare function rootTarget(source: Expr): Target | null;
5
6
  /**
6
7
  * True when evaluating `where` in memory could raise an OQX error the pushed
7
8
  * conjuncts might hide by emptying the scan. The walk is generic over the whole
@@ -14,6 +15,12 @@ import { type Target } from "./sql/translate.js";
14
15
  * • a bare reserved docs basename (`id path updated_at content_hash body`): as
15
16
  * an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
16
17
  * on any target at any depth (the reach-through row is a doc)
18
+ * • a bare target name (`docs blocks nodes edges`, root-row.ts) read as a
19
+ * property: as an `ident` at the ROOT scope when it is not a relation of
20
+ * the target's rows (`blocks` on blocks, `nodes` on nodes, any on edges),
21
+ * as an `ident` at any depth below (the rows there are unknown), or as
22
+ * `<head>.<name>` unless the head is a reach-through whose row has that
23
+ * relation (`doc.nodes`, `doc.blocks`, `block.nodes`, `section.blocks`)
17
24
  *
18
25
  * A residual made only of comparisons, logical operators, `in`/ranges, `!`,
19
26
  * literals, bindings and plain reads (including exists/none/count/collect/first