@omgbase/core 0.7.1 → 0.10.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.
- package/dist/src/core/cursor.d.ts +13 -1
- package/dist/src/core/cursor.js +21 -2
- package/dist/src/core/paths.d.ts +10 -0
- package/dist/src/core/paths.js +34 -0
- package/dist/src/core/read/document.d.ts +1 -0
- package/dist/src/core/read/document.js +3 -2
- package/dist/src/core/read/reader.d.ts +6 -3
- package/dist/src/core/read/reader.js +31 -16
- package/dist/src/graph/history.js +16 -10
- package/dist/src/graph/link-health.d.ts +2 -1
- package/dist/src/graph/link-health.js +13 -7
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +3 -1
- package/dist/src/mcp/errors.js +1 -1
- package/dist/src/mcp/graph.js +50 -15
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +102 -33
- package/dist/src/mcp/server.js +32 -28
- package/dist/src/oqx-js/context.d.ts +2 -1
- package/dist/src/oqx-js/context.js +181 -49
- package/dist/src/oqx-js/planner.d.ts +8 -1
- package/dist/src/oqx-js/planner.js +22 -42
- package/dist/src/oqx-js/root-row.d.ts +21 -0
- package/dist/src/oqx-js/root-row.js +55 -0
- package/dist/src/oqx-js/run.d.ts +3 -1
- package/dist/src/oqx-js/run.js +128 -164
- package/dist/src/oqx-js/sql/scan.d.ts +27 -0
- package/dist/src/oqx-js/sql/scan.js +62 -0
- package/dist/src/oqx-js/sql/translate.d.ts +7 -0
- package/dist/src/oqx-js/sql/translate.js +42 -11
- package/dist/src/oqx-js/store-index.d.ts +20 -0
- package/dist/src/oqx-js/store-index.js +193 -0
- package/dist/src/spec-versions.d.ts +3 -3
- package/dist/src/spec-versions.js +2 -2
- package/dist/src/surface-paths.d.ts +33 -0
- package/dist/src/surface-paths.js +75 -0
- package/package.json +2 -2
package/dist/src/mcp/server.js
CHANGED
|
@@ -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
|
|
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 }` —
|
|
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(),
|
|
@@ -424,7 +426,7 @@ export function buildServer(ctx) {
|
|
|
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 { $value values }` yields a plain array. `$value` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $value == \"pricing\" }`); inside a block `^$value` 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 `$value` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $value }`, `from nodes where kind == \"md:task\" && entries(attrs) exists { where $key == \"checked\" && $value }`; 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 always carries its keyword — a a block's leading expression is its `where` when it is predicate-shaped (`nodes exists { kind == \"md:task\" }` ≡ `nodes exists { where kind == \"md:task\" }`, since oqx 0.17); a bare name or dotted path is a projection, so filter on a bare field with `is active`; at the top level a predicate is never implicit (`age > 15 from people` is an error — `where` follows `from`), 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). `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 (`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 — `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 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, `!… 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\" && 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. 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\" && 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 (`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 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
|
|
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
|
|
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
|
|
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
|
*/
|