@omgbase/core 0.8.0 → 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 +93 -26
- 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 +93 -29
- package/dist/src/oqx-js/planner.d.ts +8 -1
- package/dist/src/oqx-js/planner.js +21 -8
- 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 +127 -163
- 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.js +12 -3
- 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 { $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 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
|
*/
|
|
@@ -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
|
|
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
|
-
//
|
|
41
|
-
//
|
|
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
|
|
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
|
|
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
|
|
110
|
-
//
|
|
111
|
-
|
|
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":
|
|
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
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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 ===
|
|
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 === "$
|
|
600
|
-
return
|
|
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
|
|
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:
|
|
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
|