@omgbase/core 0.1.0 → 0.2.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/README.md +172 -0
- package/dist/src/cli/eval-matcher.d.ts +6 -1
- package/dist/src/cli/eval-matcher.js +14 -2
- package/dist/src/core/hash.d.ts +25 -1
- package/dist/src/core/hash.js +110 -10
- package/dist/src/core/ids.d.ts +12 -0
- package/dist/src/core/ids.js +43 -1
- package/dist/src/core/ingest.d.ts +22 -2
- package/dist/src/core/ingest.js +92 -18
- package/dist/src/core/parse/parse.d.ts +1 -1
- package/dist/src/core/parse/parse.js +31 -9
- package/dist/src/core/store/nodes.d.ts +13 -0
- package/dist/src/core/store/nodes.js +24 -0
- package/dist/src/core/utf8.d.ts +29 -0
- package/dist/src/core/utf8.js +96 -0
- package/dist/src/format/adapter.d.ts +7 -1
- package/dist/src/format/markdown.js +9 -18
- package/dist/src/graph/history.d.ts +6 -0
- package/dist/src/graph/history.js +13 -2
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +8 -5
- package/dist/src/mcp/graph.js +8 -4
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +17 -6
- package/dist/src/mcp/server.js +12 -7
- package/dist/src/mutate/apply.d.ts +7 -0
- package/dist/src/mutate/apply.js +17 -7
- package/dist/src/mutate/docs.d.ts +2 -0
- package/dist/src/mutate/docs.js +10 -6
- package/dist/src/mutate/known-ids.js +9 -1
- package/dist/src/mutate/macros.js +9 -2
- package/dist/src/mutate/ops.d.ts +2 -1
- package/dist/src/mutate/ops.js +24 -2
- package/dist/src/mutate/plan-update.d.ts +7 -1
- package/dist/src/mutate/plan-update.js +3 -1
- package/dist/src/oqx-js/context.js +43 -6
- package/dist/src/reconcile/eval/generator.d.ts +24 -1
- package/dist/src/reconcile/eval/generator.js +248 -0
- package/dist/src/reconcile/eval/metrics.d.ts +33 -9
- package/dist/src/reconcile/eval/metrics.js +55 -32
- package/dist/src/reconcile/flatten.js +7 -5
- package/dist/src/reconcile/phase5.d.ts +2 -1
- package/dist/src/reconcile/phase5.js +27 -10
- package/dist/src/reconcile/phase6a.js +17 -5
- package/dist/src/reconcile/phases.d.ts +44 -6
- package/dist/src/reconcile/phases.js +0 -0
- package/dist/src/reconcile/reconcile.js +16 -9
- package/dist/src/reconcile/types.d.ts +4 -1
- package/dist/src/reconcile/types.js +11 -2
- package/dist/src/search/external.d.ts +10 -0
- package/dist/src/search/external.js +18 -1
- package/dist/src/search/provider.d.ts +7 -3
- package/dist/src/search/provider.js +8 -7
- package/dist/src/search/rrf.js +3 -0
- package/dist/src/search/vector.js +21 -6
- package/dist/src/sync/attach.d.ts +7 -2
- package/dist/src/sync/attach.js +10 -8
- package/dist/src/sync/checkpoint.d.ts +18 -1
- package/dist/src/sync/checkpoint.js +31 -29
- package/dist/src/sync/driver.d.ts +15 -4
- package/dist/src/sync/driver.js +18 -43
- package/dist/src/sync/external-source.js +7 -1
- package/dist/src/sync/freshness.d.ts +65 -14
- package/dist/src/sync/freshness.js +107 -96
- package/dist/src/sync/fs-util.d.ts +19 -0
- package/dist/src/sync/fs-util.js +24 -1
- package/dist/src/sync/observe.d.ts +53 -5
- package/dist/src/sync/observe.js +162 -21
- package/dist/src/sync/reconciling-ingest.d.ts +42 -2
- package/dist/src/sync/reconciling-ingest.js +82 -10
- package/dist/src/sync/recovery.d.ts +5 -1
- package/dist/src/sync/recovery.js +8 -8
- package/dist/src/sync/workspace.d.ts +11 -3
- package/dist/src/sync/workspace.js +39 -30
- package/dist/src/sync/writer-lock.js +16 -26
- package/package.json +3 -3
package/dist/src/index.js
CHANGED
|
@@ -10,19 +10,20 @@ export { docPropertiesMerged, docPropertiesGrouped, writeDocProperties, flattenF
|
|
|
10
10
|
import "./format/index.js";
|
|
11
11
|
// Workspace + sync surface used by the CLI (second client, 11 §1).
|
|
12
12
|
export { Store } from "./core/store/store.js";
|
|
13
|
-
export { Workspace, RepoSelectionError } from "./sync/workspace.js";
|
|
13
|
+
export { Workspace, RepoSelectionError, selectRepo } from "./sync/workspace.js";
|
|
14
14
|
export { resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, deepMerge, } from "./sync/settings.js";
|
|
15
|
-
export { freshnessSweep, rebuildFileStats, recordFileStat, detectDiskDrift } from "./sync/freshness.js";
|
|
15
|
+
export { freshnessSweep, rebuildFileStats, recordFileStat, recordStat, detectDiskDrift, sweepPlan, sweepCandidates, loadStatCache, snapshotOf, } from "./sync/freshness.js";
|
|
16
|
+
export { recoverRepo } from "./sync/recovery.js";
|
|
16
17
|
export { withWriterLock, writerLockFree, WriterLockTimeout } from "./sync/writer-lock.js";
|
|
17
18
|
export { WatchLease, watchLeaseLive } from "./sync/watch-lease.js";
|
|
18
19
|
export { ingestDirectory } from "./sync/attach.js";
|
|
19
20
|
export { ensureRepo } from "./core/attach.js";
|
|
20
|
-
export { walkMarkdown, walkMarkdownAsync } from "./sync/fs-util.js";
|
|
21
|
+
export { walkMarkdown, walkMarkdownAsync, nodeFs } from "./sync/fs-util.js";
|
|
21
22
|
export { Watcher } from "./sync/watcher.js";
|
|
22
23
|
export { createExternalSource } from "./sync/external-source.js";
|
|
23
24
|
export { ensureAdapter, listAdapters, createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, renderConfigFlags, } from "./sync/sources.js";
|
|
24
25
|
export { reconcileChanges, attachSource } from "./sync/driver.js";
|
|
25
|
-
export { observeFile, observeMany, observeOne, observeDelete } from "./sync/observe.js";
|
|
26
|
+
export { observeFile, observeMany, observeOne, observeDelete, observeBatch } from "./sync/observe.js";
|
|
26
27
|
export { buildServer } from "./mcp/server.js";
|
|
27
28
|
export { serveStdio } from "./mcp/stdio.js";
|
|
28
29
|
export { processCheckpoint } from "./sync/checkpoint.js";
|
|
@@ -52,7 +53,9 @@ export { docLinks } from "./graph/links.js";
|
|
|
52
53
|
export { FilterInvalid } from "./search/cel/parser.js";
|
|
53
54
|
export { EngineError } from "./mcp/errors.js";
|
|
54
55
|
export { MutationError } from "./mutate/tree.js";
|
|
55
|
-
export { isValidId, prefixOf } from "./core/ids.js";
|
|
56
|
+
export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter } from "./core/ids.js";
|
|
57
|
+
export { sha256 } from "./core/hash.js";
|
|
58
|
+
export { ingestFile } from "./core/ingest.js";
|
|
56
59
|
// Mutation surface consumed by CLI write commands (11 §5.6).
|
|
57
60
|
export { apply } from "./mutate/apply.js";
|
|
58
61
|
export { FsDocStore, NullDocStore, resolveDocStore } from "./mutate/doc-store.js";
|
package/dist/src/mcp/graph.js
CHANGED
|
@@ -29,6 +29,10 @@ import { EngineError } from "./errors.js";
|
|
|
29
29
|
const DEFAULT_DEGREES = 1;
|
|
30
30
|
const DEFAULT_MAX_DOCUMENTS = 200;
|
|
31
31
|
const MAX_DEPTH = 8; // OQX follow depth cap (1..8)
|
|
32
|
+
// Bytewise (UTF-8) order — the order every path-ordered surface uses (spec/surface §4).
|
|
33
|
+
function cmpBytes(a, b) {
|
|
34
|
+
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
35
|
+
}
|
|
32
36
|
// Build the extra `select` items for caller projections. Each expression gets a
|
|
33
37
|
// stable internal alias (_u0, _u1, …) so it can never collide with a reserved
|
|
34
38
|
// word or the macro's own aliases; the clean output name is derived separately.
|
|
@@ -59,8 +63,8 @@ const EDGE_COLLECT = "{ id: $id, src: $src, dst: $dst, dst_path: $dst_path, dst_
|
|
|
59
63
|
"dst_kind: dst_kind, predicate: predicate, provenance: provenance, anchor: anchor, src_field: src_field }";
|
|
60
64
|
function buildQuery(seed, dir, depth, userSelect) {
|
|
61
65
|
const edgesRel = dir === "out" ? "doc.out_edges" : "doc.in_edges";
|
|
62
|
-
return (`
|
|
63
|
-
`
|
|
66
|
+
return (`select _depth: $depth, _stop: $stop, _edges: ${edgesRel} collect ${EDGE_COLLECT}${userSelect} ` +
|
|
67
|
+
`from docs where ${seed} ` +
|
|
64
68
|
`follow distinct doc.${dir} { depth ${depth} }`);
|
|
65
69
|
}
|
|
66
70
|
/**
|
|
@@ -168,7 +172,7 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
|
|
|
168
172
|
docMap.set(id, d);
|
|
169
173
|
}
|
|
170
174
|
// Cap the distinct document set (nearest-first), then bound edges to it.
|
|
171
|
-
const allDocs = [...docMap.values()].sort((a, b) => a.degree - b.degree || a.path
|
|
175
|
+
const allDocs = [...docMap.values()].sort((a, b) => a.degree - b.degree || cmpBytes(a.path, b.path));
|
|
172
176
|
const capped = allDocs.length > maxDocuments;
|
|
173
177
|
const documents = allDocs.slice(0, maxDocuments);
|
|
174
178
|
const reached = new Set(documents.map((d) => d.id));
|
|
@@ -197,7 +201,7 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
|
|
|
197
201
|
if (srcIn && dstIn)
|
|
198
202
|
edges.push(e);
|
|
199
203
|
}
|
|
200
|
-
edges.sort((a, b) => (a.src
|
|
204
|
+
edges.sort((a, b) => cmpBytes(a.src, b.src) || cmpBytes(a.id, b.id));
|
|
201
205
|
return {
|
|
202
206
|
roots: rootIds,
|
|
203
207
|
degrees: effectiveDegrees,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const QUERY_SYNTAX = "# query \u2014 OQX syntax reference\n\nThe `query` tool takes ONE plain OQX string (+ optional `limit`/`cursor`). There\nis no {from, filter, order} envelope: every concern is a clause of the string.\n\n from <target> [where <pred>] [select <items>] [order by <expr> [asc|desc], \u2026]\n [limit N] [offset N] [follow <relation> [{ \u2026 }]]\n $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)\n\nResults are LEAN hits \u2014 {id, path} + whatever `select` projects (or `values`,\n`count`, `exists`, `none`). Hydrate full content by id via nodes_get/docs_read.\n\n## Targets & field namespaces\n\nThe sigil rule: `$`-prefixed names are engine intrinsics; BARE identifiers are\nyour content. On docs a bare first segment that collides with an intrinsic base\nname (id/path/repo/updated_at/content_hash/body) is REJECTED with a \"did you mean\n$X?\" hint (bare `path` would silently read an absent frontmatter key). Force the\nproperty with `frontmatter.<k>`. `title`/`tags` are NOT reserved: `$title`/`$tags`\nare computed and never shadow an authored `title:`/`tags:` key.\n\ndocs:\n - bare identifier = a document PROPERTY (frontmatter + inline `key:: value`,\n unioned; nested via dots: meta.owner). YAML/JSON docs\n expose the parsed object's keys the same way.\n - source-scoped = frontmatter.<k> / inline.<k>; the whole bag as a collection\n via entries(frontmatter) / entries(inline) (see entries()).\n - computed = $title (first H1), $tags (body #hashtags), format\n (the doc's format \u2014 \"markdown\"/\"yaml\"/\"json\"; a bare\n computed field, present on EVERY doc, so has(format) is\n always true and `format == \"yaml\"` is the filter).\n - intrinsics = $id, $path, $updated_at (ISO-8601, sorts chronologically),\n $body (whole file), $content_hash.\n - relations = nodes, blocks, doc.out / doc.in (citation graph),\n doc.out_edges / doc.in_edges (a doc's edges as rows).\n\nblocks:\n - bare fields = type, text, and every attrs key FLATTENED onto the row\n (bare `checked` reads attrs.checked; `attrs.<k>` still\n works; type/text win on collision; absent \u21D2 false).\n - intrinsics = $id, $doc, $path, $ordinal, $depth, $updated_at, $body\n (the block text), $content_hash (the block's OWN raw hash \u2014\n the value update/split expect).\n - reach-through = doc.<key> / doc.$path / doc.format = the CONTAINING doc.\n - relations = block.children, block.nodes, block.out_edges, section.\n\nnodes:\n - bare fields = kind (md:task / md:link / md:section / yaml:\u2026), name,\n value, + attrs keys flattened (checked, level).\n - intrinsics = $id (=$node_id), $doc_id, $block_id, $path.\n - reach-through = doc.<key>, block.type / block.text.\n - relations = section.blocks, section.children, section.subsections.\n\nedges: (the authored link graph as rows \u2014 one per open edge)\n - bare fields = predicate (\"references\"/\"embeds\"/freeform), provenance\n (\"link\"/\"frontmatter\"/\"inline_field\"/\u2026), dst_kind\n (\"document\"/\"external\"/\"collection\"), anchor, src_field\n - intrinsics = $id, $src, $dst, $dst_path (target doc path; NULL when\n dangling/external), $dst_uri (external URL; NULL\n otherwise), $src_block, $via, $from_commit; $path and\n doc.<key> reach the SOURCE document.\n - text()/semantic() are N/A on edges.\n\n## Scoping (ADR-015): bare names are LOCAL\n\n name the CURRENT row only \u2014 never falls through to an enclosing row\n or the repository; an absent field is simply absent.\n ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);\n ^^name two scopes out. `^$path` = the enclosing row's path.\n $repo.<t> the root scan \u2014 $repo.docs / $repo.nodes / $repo.blocks /\n $repo.edges \u2014 available from ANY depth (uncorrelated until you\n add a ^ predicate). $repo.$id = the repository id.\n $value the current item itself (a row, or each ELEMENT when the\n receiver is a list property); ^$value = the enclosing row.\n $key inside an entries(x) block: the entry's key.\n\n## Scalar semantics (@omgbase/oqx)\n\n comparisons == != < <= > >= strict & typed (5 == \"5\" is false); either\n side may be a field (field-vs-field OK).\n absence null and a missing field are the same \"absent\" value; two absents\n are EQUAL, so `absent != \"x\"` is TRUE and `absent == null` is TRUE.\n Relational (< <= > >=) on an absent operand \u2192 FALSE. Bare `f` in\n boolean position uses JS truthiness (absent/false/0/\"\" \u21D2 false;\n NOTE: \"false\", [] and {} are truthy). has(f) = explicit presence.\n boolean && || ! grouping ( \u2026 )\n arithmetic + - * / % (+ concatenates if either side is a string)\n membership x in y array \u2192 typed membership; string \u2192 substring;\n object \u2192 key exists; range \u2192 coverage. Any list\n property works directly: \"pricing\" in tags. list(f)\n coerces scalar-or-list (absent \u2192 []) \u2014 use it when a\n key is sometimes scalar, sometimes a list.\n ranges lo..hi (inclusive) lo...hi (exclusive hi) ..hi lo..\n era in 800..1680; works over ISO dates. range(prop) reads a\n range-shaped STRING property as an interval: \"2026-01-15\" in range(window).\n strings x.contains(\"s\") x.startsWith(\"p\") x.endsWith(\"s\") x.matches(\"^re$\")\n (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE \u2014\n fold with .lower(): $title.lower().contains(\"aurora\").\n functions has(f) size(x) list(x) entries(x) range(s)\n literals \"str\" 'str' 123 1.5 true false null\n determinism no now()/clock/random. Lists/structs are not literals; ternary is\n not supported. Errors surface as filter_invalid.\n scalars vs lists a list-authored key never == a scalar (tags == \"x\" is false\n when tags is a list) \u2014 use `\"x\" in tags` / `in list(tags)`.\n\n## Domain predicates (omgbase)\n\n text(\"terms\") FTS5 PRUNING predicate (docs match via their blocks, nodes\n via the node index); composes anywhere, incl. nested scopes.\n semantic(\"phrase\") embedding cosine SCORE (docs/blocks): threshold it\n (semantic(\"x\") > 0.6), project it, or `order by semantic(\"x\")\n desc` for top-K. Needs a provider; else semantic_unavailable.\n blocks target only:\n under(id_or_heading) block in the containment subtree / section range\n under_heading(\"Launch\") an ancestor section heading contains the text (ci)\n under_kind(type[, name]) an ancestor block has that type (name: its text\n contains / its key equals name)\n within(\"path\" | \"d_..\") containing doc = id, exact path, or path glob (*)\n yaml_path(\"a.b\") YAML docs: the block at that key path\n json_pointer(\"#/a/b\") JSON docs: the block at that pointer\n has_edge(\"pred\"[, target]) an open authored edge with that predicate leaves here\n has_anchor() block carries an authored ^ref\n parent_type() == \"x\" parent block's type (must be compared)\n child_count() > 0 number of direct children (must be compared)\n\n## Nested queries & consumers\n\n <receiver> collect|exists|none|count|first|single { <block> }\n receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, \u2026), a\n list property (tags), entries(x), or a root ($repo.docs).\n block = [where \u2026] [select \u2026] [order by \u2026] [limit N] [offset N]; a leading\n predicate is an implicit where, a leading name/alias list an\n implicit select.\n exists \u22651 row nodes exists { where kind == \"md:task\" && !checked }\n none 0 rows nodes none { where kind == \"md:task\" && !checked } (\"every task done\")\n count a number nodes count { where kind == \"md:task\" } >= 2\n collect rows (default) \u2014 in select for nested results; in where with a lift\n first / single zero-or-one (single errors on >1) \u2014 lookups in select\n distinct on any consumer: dedup by projected value \u2014 nodes count distinct { select kind }\n limit/offset bound the block's rows AFTER where/order/distinct, BEFORE the\n consumer reduces: nodes exists { offset 1 } = at least two;\n first { \u2026 offset 1 } = the second.\n lift `^name:` in a where-collect binds values outward:\n from docs where nodes collect { ^open: value where kind == \"md:task\" && !checked } select $path, open\n joins $repo.<t> exists { where slug == ^ref } semi-join; !\u2026 exists anti-join;\n $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } lookup;\n x in ^keys membership over a lifted set.\n\n## select \u2014 projection\n\n select a, alias: expr, nested: nodes collect { \u2026 }\n bare key \u2192 the property (scalar-authored \u2192 scalar; list \u2192 array); $-intrinsics;\n a call/arithmetic needs an alias (n: size(tags)) unless in values mode.\n \"$body\" whole file bytes (docs) \u2014 docs_read is cheaper for one doc.\n distinct select distinct type (dedup hits by projected value)\n values select <ONE item> values \u2192 bare values instead of records:\n from docs select distinct type values \u2192 values: [\"hub\",\"lab-note\",\u2026]\n tags: tags collect { $value values } \u2192 a plain array\n h: nodes first { name values where kind == \"md:section\" order by first_ordinal }\n entries(x) a record as a collection ($key / $value per entry), key order:\n select fm: entries(frontmatter) collect { k: $key, v: $value }\n where entries(frontmatter) exists { where $key == \"era\" && $value > 1600 }\n entries(inline), entries(attrs), a nested map, or a list (index keys)\n $semantic_score is not a field \u2014 project semantic(\"\u2026\") under an alias instead.\n\n## order by, limit/offset, pagination\n\n order by era desc, $path asc absent values sort LAST (asc); ties \u2192 (path, id)\n limit N / offset N define the result SET (integer literals);\n the tool's limit/cursor then page WITHIN it\n cursor opaque keyset on (path,id); a custom order by\n disables it (you get the top page + truncated)\n\n## Graph traversal (follow) and edges\n\n from docs where <seed> follow doc.out docs the seed links TO\n from docs where <seed> follow doc.in backlinks\n follow doc.out { depth 3 } bound (1..8, default 8)\n follow doc.out { where layer == \"canon\" } keep only matching successors\n follow doc.out { frontier type == \"practitioner\" } cut, keeping the frontier row\n follow distinct doc.out / follow doc.out { by group } identity control\n Per-occurrence metadata: $depth (seed = 1), $stop (interior|leaf|frontier|\n depth|cycle), $leaf/$frontier, $ordinal \u2014 usable in select/order by and the\n top-level post-walk where, NOT in the follow-local where/frontier.\n Type-preserving relations: doc.out/doc.in, block.children, section.children,\n section.subsections.\n from edges where $dst_path == \"notes/x.md\" inbound edges to a doc, as rows\n from edges where predicate == \"depends_on\" select $src, $dst_path\n from docs where !doc.in_edges exists { } orphans (nothing links in)\n (The `graph` tool is a convenience wrapper that compiles to a follow query.)\n\n## semantic grain\n\nfrom docs ranks whole DOCUMENTS (one hit per file); from blocks ranks PASSAGES.\ntext()/where prune the candidate set; only order by reweights.\n\n## Examples\n\n from docs where layer == \"working\"\n from docs where $path.startsWith(\"guides/\") && $updated_at >= \"2026-08-01\"\n from docs where \"pricing\" in list(tags) select layer, tags\n from docs where inline.owner == \"alice\"\n from docs where $title.lower().contains(\"q3 plan\")\n from docs where format == \"yaml\"\n from docs where type == \"practitioner\" order by era desc limit 2\n from docs where nodes none { where kind == \"md:task\" && !checked }\n from docs where tags exists { where $value == \"tria-prima\" }\n from docs where $path == \"x.md\" select fm: entries(frontmatter) collect { k: $key, v: $value }\n from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }\n from blocks where type == \"task\" && !checked && under_heading(\"Launch\") && doc.layer == \"working\"\n from blocks where type == \"paragraph\" && has_edge(\"references\", \"d_92aaaaa\")\n from blocks order by semantic(\"identity preservation across edits\") desc limit 10\n from edges where dst_kind == \"external\" select $dst_uri\n $repo.docs count { where layer == \"canon\" }\n\nCommon mistake: bare `path.startsWith(...)` meant the intrinsic \u2014 it fails loud\nwith a hint; write `$path.startsWith(...)`. Another: `where tags == \"x\"` on a\nlist-valued key is false \u2014 write `\"x\" in tags`.\n";
|
|
1
|
+
export declare const QUERY_SYNTAX = "# query \u2014 OQX syntax reference\n\nThe `query` tool takes ONE plain OQX string (+ optional `limit`/`cursor`). There\nis no {from, filter, order} envelope: every concern is a clause of the string.\n\n [select <items>] from <target> [where <pred>] [follow <relation> [{ \u2026 }]]\n [order by <expr> [asc|desc], \u2026] [limit N] [offset N]\n $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)\n\nCLAUSE ORDER IS FIXED: select, from, where, follow, order by, limit, offset \u2014\neach at most once; an out-of-order clause is a parse error naming the order.\nOnly `select` may drop its keyword, and only as the first clause\n(`$path, era from docs where \u2026`). Every other clause always carries its keyword:\na predicate is NEVER implicit (`nodes exists { where kind == \"md:task\" }`, not\n`{ kind == \"md:task\" }`), and `from docs count` is an error (write\n`$repo.docs count { \u2026 }`). `where` may use the same body's `select` aliases\n(`select $path, old: era < 1000 from docs where old`; an alias shadows a\nsame-named field there).\n\nResults are LEAN hits \u2014 {id, path} + whatever `select` projects (or `values`,\n`count`, `exists`, `none`). Hydrate full content by id via nodes_get/docs_read.\n\n## Targets & field namespaces\n\nThe sigil rule: `$`-prefixed names are engine intrinsics; BARE identifiers are\nyour content. On docs a bare first segment that collides with an intrinsic base\nname (id/path/repo/updated_at/content_hash/body) is REJECTED with a \"did you mean\n$X?\" hint (bare `path` would silently read an absent frontmatter key). Force the\nproperty with `frontmatter.<k>`. `title`/`tags` are NOT reserved: `$title`/`$tags`\nare computed and never shadow an authored `title:`/`tags:` key.\n\ndocs:\n - bare identifier = a document PROPERTY (frontmatter + inline `key:: value`,\n unioned; nested via dots: meta.owner). YAML/JSON docs\n expose the parsed object's keys the same way.\n - source-scoped = frontmatter.<k> / inline.<k>; the whole bag as a collection\n via entries(frontmatter) / entries(inline) (see entries()).\n - computed = $title (first H1), $tags (body #hashtags), format\n (the doc's format \u2014 \"markdown\"/\"yaml\"/\"json\"; a bare\n computed field, present on EVERY doc, so has(format) is\n always true and `format == \"yaml\"` is the filter).\n - intrinsics = $id, $path, $updated_at (ISO-8601, sorts chronologically),\n $body (whole file), $content_hash.\n - relations = nodes, blocks, doc.out / doc.in (citation graph),\n doc.out_edges / doc.in_edges (a doc's edges as rows).\n\nblocks:\n - bare fields = type, text, and every attrs key FLATTENED onto the row\n (bare `checked` reads attrs.checked; `attrs.<k>` still\n works; type/text win on collision; absent \u21D2 false).\n - intrinsics = $id, $doc, $path, $ordinal, $depth, $updated_at, $body\n (the block text), $content_hash (the block's OWN raw hash \u2014\n the value update/split expect).\n - reach-through = doc.<key> / doc.$path / doc.format = the CONTAINING doc.\n - relations = block.children, block.nodes, block.out_edges, section.\n\nnodes:\n - bare fields = kind (md:task / md:link / md:section / yaml:\u2026), name,\n value, + attrs keys flattened (checked, level).\n - intrinsics = $id (=$node_id), $doc_id, $block_id, $path.\n - reach-through = doc.<key>, block.type / block.text.\n - relations = section.blocks, section.children, section.subsections.\n\nedges: (the authored link graph as rows \u2014 one per open edge)\n - bare fields = predicate (\"references\"/\"embeds\"/freeform), provenance\n (\"link\"/\"frontmatter\"/\"inline_field\"/\u2026), dst_kind\n (\"document\"/\"external\"/\"collection\"), anchor, src_field\n - intrinsics = $id, $src, $dst, $dst_path (target doc path; NULL when\n dangling/external), $dst_uri (external URL; NULL\n otherwise), $src_block, $via, $from_commit; $path and\n doc.<key> reach the SOURCE document.\n - text()/semantic() are N/A on edges.\n\n## Scoping (ADR-015): bare names are LOCAL\n\n name the CURRENT row only \u2014 never falls through to an enclosing row\n or the repository; an absent field is simply absent.\n ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);\n ^^name two scopes out. `^$path` = the enclosing row's path.\n $repo.<t> the root scan \u2014 $repo.docs / $repo.nodes / $repo.blocks /\n $repo.edges \u2014 available from ANY depth (uncorrelated until you\n add a ^ predicate). $repo.$id = the repository id.\n $value the current item itself (a row, or each ELEMENT when the\n receiver is a list property); ^$value = the enclosing row.\n $key inside an entries(x) block: the entry's key.\n\n## Scalar semantics (@omgbase/oqx)\n\n comparisons == != < <= > >= strict & typed (5 == \"5\" is false); either\n side may be a field (field-vs-field OK).\n absence null and a missing field are the same \"absent\" value; two absents\n are EQUAL, so `absent != \"x\"` is TRUE and `absent == null` is TRUE.\n Relational (< <= > >=) on an absent operand \u2192 FALSE. Bare `f` in\n boolean position uses JS truthiness (absent/false/0/\"\" \u21D2 false;\n NOTE: \"false\", [] and {} are truthy). has(f) = explicit presence.\n boolean && || ! grouping ( \u2026 )\n arithmetic + - * / % (+ concatenates if either side is a string)\n membership x in y array \u2192 typed membership; string \u2192 substring;\n object \u2192 key exists; range \u2192 coverage. Any list\n property works directly: \"pricing\" in tags. list(f)\n coerces scalar-or-list (absent \u2192 []) \u2014 use it when a\n key is sometimes scalar, sometimes a list.\n ranges lo..hi (inclusive) lo...hi (exclusive hi) ..hi lo..\n era in 800..1680; works over ISO dates. range(prop) reads a\n range-shaped STRING property as an interval: \"2026-01-15\" in range(window).\n strings x.contains(\"s\") x.startsWith(\"p\") x.endsWith(\"s\") x.matches(\"^re$\")\n (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE \u2014\n fold with .lower(): $title.lower().contains(\"aurora\").\n functions has(f) size(x) list(x) entries(x) range(s)\n literals \"str\" 'str' 123 1.5 true false null\n determinism no now()/clock/random. Lists/structs are not literals; ternary is\n not supported. Errors surface as filter_invalid.\n scalars vs lists a list-authored key never == a scalar (tags == \"x\" is false\n when tags is a list) \u2014 use `\"x\" in tags` / `in list(tags)`.\n\n## Domain predicates (omgbase)\n\n text(\"terms\") FTS5 PRUNING predicate (docs match via their blocks, nodes\n via the node index); composes anywhere, incl. nested scopes.\n semantic(\"phrase\") embedding cosine SCORE (docs/blocks): threshold it\n (semantic(\"x\") > 0.6), project it, or `order by semantic(\"x\")\n desc` for top-K. Needs a provider; else semantic_unavailable.\n blocks target only:\n under(id_or_heading) block in the containment subtree / section range\n under_heading(\"Launch\") an ancestor section heading contains the text (ci)\n under_kind(type[, name]) an ancestor block has that type (name: its text\n contains / its key equals name)\n within(\"path\" | \"d_..\") containing doc = id, exact path, or path glob (*)\n yaml_path(\"a.b\") YAML docs: the block at that key path\n json_pointer(\"#/a/b\") JSON docs: the block at that pointer\n has_edge(\"pred\"[, target]) an open authored edge with that predicate leaves here\n has_anchor() block carries an authored ^ref\n parent_type() == \"x\" parent block's type (must be compared)\n child_count() > 0 number of direct children (must be compared)\n\n## Nested queries & consumers\n\n <receiver> collect|exists|none|count|first|single { <block> }\n receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, \u2026), a\n list property (tags), entries(x), or a root ($repo.docs).\n block = [select \u2026] [where \u2026] [follow \u2026] [order by \u2026] [limit N] [offset N]\n \u2014 the same fixed order as the top level (`from` optional; the\n receiver supplies the rows). A leading name/alias list is the\n projection (`select` dropped); a predicate always needs `where`.\n exists \u22651 row nodes exists { where kind == \"md:task\" && !checked }\n none 0 rows nodes none { where kind == \"md:task\" && !checked } (\"every task done\")\n count a number nodes count { where kind == \"md:task\" } >= 2\n collect rows (default) \u2014 in select for nested results; in where with a lift\n first / single zero-or-one (single errors on >1) \u2014 lookups in select\n distinct on any consumer: dedup by projected value \u2014 nodes count distinct { select kind }\n limit/offset bound the block's rows AFTER where/order/distinct, BEFORE the\n consumer reduces: nodes exists { offset 1 } = at least two;\n first { \u2026 offset 1 } = the second.\n lift `^name:` in a where-collect binds values outward:\n select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !checked }\n joins $repo.<t> exists { where slug == ^ref } semi-join; !\u2026 exists anti-join;\n $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } lookup;\n x in ^keys membership over a lifted set.\n\n## select \u2014 projection\n\n select a, alias: expr, nested: nodes collect { \u2026 }\n bare key \u2192 the property (scalar-authored \u2192 scalar; list \u2192 array); $-intrinsics;\n a call/arithmetic needs an alias (n: size(tags)) unless in values mode.\n \"$body\" whole file bytes (docs) \u2014 docs_read is cheaper for one doc.\n distinct select distinct type (dedup hits by projected value)\n values select <ONE item> values \u2192 bare values instead of records:\n from docs select distinct type values \u2192 values: [\"hub\",\"lab-note\",\u2026]\n tags: tags collect { $value values } \u2192 a plain array\n h: nodes first { name values where kind == \"md:section\" order by first_ordinal }\n entries(x) a record as a collection ($key / $value per entry), key order:\n select fm: entries(frontmatter) collect { k: $key, v: $value }\n where entries(frontmatter) exists { where $key == \"era\" && $value > 1600 }\n entries(inline), entries(attrs), a nested map, or a list (index keys)\n $semantic_score is not a field \u2014 project semantic(\"\u2026\") under an alias instead.\n\n## order by, limit/offset, pagination\n\n order by era desc, $path asc absent values sort LAST (asc); ties \u2192 (path, id)\n limit N / offset N define the result SET (integer literals);\n the tool's limit/cursor then page WITHIN it\n cursor opaque keyset on (path,id); a custom order by\n disables it (you get the top page + truncated)\n\n## Graph traversal (follow) and edges\n\n from docs where <seed> follow doc.out docs the seed links TO\n from docs where <seed> follow doc.in backlinks\n follow doc.out { depth 3 } bound (1..8, default 8)\n follow doc.out { where layer == \"canon\" } keep only matching successors\n follow doc.out { frontier type == \"practitioner\" } cut, keeping the frontier row\n follow distinct doc.out / follow doc.out { by group } identity control\n Per-occurrence metadata: $depth (seed = 1), $stop (interior|leaf|frontier|\n depth|cycle), $leaf/$frontier, $ordinal \u2014 usable in select/order by and the\n top-level post-walk where, NOT in the follow-local where/frontier.\n Type-preserving relations: doc.out/doc.in, block.children, section.children,\n section.subsections.\n from edges where $dst_path == \"notes/x.md\" inbound edges to a doc, as rows\n from edges where predicate == \"depends_on\" select $src, $dst_path\n from docs where !doc.in_edges exists { } orphans (nothing links in)\n (The `graph` tool is a convenience wrapper that compiles to a follow query.)\n\n## semantic grain\n\nfrom docs ranks whole DOCUMENTS (one hit per file); from blocks ranks PASSAGES.\ntext()/where prune the candidate set; only order by reweights.\n\n## Examples\n\n from docs where layer == \"working\"\n from docs where $path.startsWith(\"guides/\") && $updated_at >= \"2026-08-01\"\n from docs where \"pricing\" in list(tags) select layer, tags\n from docs where inline.owner == \"alice\"\n from docs where $title.lower().contains(\"q3 plan\")\n from docs where format == \"yaml\"\n from docs where type == \"practitioner\" order by era desc limit 2\n from docs where nodes none { where kind == \"md:task\" && !checked }\n from docs where tags exists { where $value == \"tria-prima\" }\n from docs where $path == \"x.md\" select fm: entries(frontmatter) collect { k: $key, v: $value }\n from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }\n from blocks where type == \"task\" && !checked && under_heading(\"Launch\") && doc.layer == \"working\"\n from blocks where type == \"paragraph\" && has_edge(\"references\", \"d_92aaaaa\")\n from blocks order by semantic(\"identity preservation across edits\") desc limit 10\n from edges where dst_kind == \"external\" select $dst_uri\n $repo.docs count { where layer == \"canon\" }\n\nCommon mistake: bare `path.startsWith(...)` meant the intrinsic \u2014 it fails loud\nwith a hint; write `$path.startsWith(...)`. Another: `where tags == \"x\"` on a\nlist-valued key is false \u2014 write `\"x\" in tags`.\n";
|
|
@@ -10,10 +10,20 @@ export const QUERY_SYNTAX = `# query — OQX syntax reference
|
|
|
10
10
|
The \`query\` tool takes ONE plain OQX string (+ optional \`limit\`/\`cursor\`). There
|
|
11
11
|
is no {from, filter, order} envelope: every concern is a clause of the string.
|
|
12
12
|
|
|
13
|
-
from <target> [where <pred>] [
|
|
14
|
-
|
|
13
|
+
[select <items>] from <target> [where <pred>] [follow <relation> [{ … }]]
|
|
14
|
+
[order by <expr> [asc|desc], …] [limit N] [offset N]
|
|
15
15
|
$repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)
|
|
16
16
|
|
|
17
|
+
CLAUSE ORDER IS FIXED: select, from, where, follow, order by, limit, offset —
|
|
18
|
+
each at most once; an out-of-order clause is a parse error naming the order.
|
|
19
|
+
Only \`select\` may drop its keyword, and only as the first clause
|
|
20
|
+
(\`$path, era from docs where …\`). Every other clause always carries its keyword:
|
|
21
|
+
a predicate is NEVER implicit (\`nodes exists { where kind == "md:task" }\`, not
|
|
22
|
+
\`{ kind == "md:task" }\`), and \`from docs count\` is an error (write
|
|
23
|
+
\`$repo.docs count { … }\`). \`where\` may use the same body's \`select\` aliases
|
|
24
|
+
(\`select $path, old: era < 1000 from docs where old\`; an alias shadows a
|
|
25
|
+
same-named field there).
|
|
26
|
+
|
|
17
27
|
Results are LEAN hits — {id, path} + whatever \`select\` projects (or \`values\`,
|
|
18
28
|
\`count\`, \`exists\`, \`none\`). Hydrate full content by id via nodes_get/docs_read.
|
|
19
29
|
|
|
@@ -135,9 +145,10 @@ edges: (the authored link graph as rows — one per open edge)
|
|
|
135
145
|
<receiver> collect|exists|none|count|first|single { <block> }
|
|
136
146
|
receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, …), a
|
|
137
147
|
list property (tags), entries(x), or a root ($repo.docs).
|
|
138
|
-
block = [where …] [
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
block = [select …] [where …] [follow …] [order by …] [limit N] [offset N]
|
|
149
|
+
— the same fixed order as the top level (\`from\` optional; the
|
|
150
|
+
receiver supplies the rows). A leading name/alias list is the
|
|
151
|
+
projection (\`select\` dropped); a predicate always needs \`where\`.
|
|
141
152
|
exists ≥1 row nodes exists { where kind == "md:task" && !checked }
|
|
142
153
|
none 0 rows nodes none { where kind == "md:task" && !checked } ("every task done")
|
|
143
154
|
count a number nodes count { where kind == "md:task" } >= 2
|
|
@@ -148,7 +159,7 @@ edges: (the authored link graph as rows — one per open edge)
|
|
|
148
159
|
consumer reduces: nodes exists { offset 1 } = at least two;
|
|
149
160
|
first { … offset 1 } = the second.
|
|
150
161
|
lift \`^name:\` in a where-collect binds values outward:
|
|
151
|
-
from docs where nodes collect { ^open: value where kind == "md:task" && !checked }
|
|
162
|
+
select $path, open from docs where nodes collect { ^open: value where kind == "md:task" && !checked }
|
|
152
163
|
joins $repo.<t> exists { where slug == ^ref } semi-join; !… exists anti-join;
|
|
153
164
|
$repo.nodes single { where kind == "person" && attrs.id == ^owner_id } lookup;
|
|
154
165
|
x in ^keys membership over a lifted set.
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -20,7 +20,7 @@ import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair,
|
|
|
20
20
|
import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
|
|
21
21
|
import { planUpdate, docsUpdate } from "../mutate/plan-update.js";
|
|
22
22
|
import { renderOpsetPlan } from "../mutate/opset.js";
|
|
23
|
-
import { historyNode, diffBlocks, diffUnified, changesSince, docHistory } from "../graph/history.js";
|
|
23
|
+
import { historyNode, diffBlocks, diffUnified, changesSince, docHistory, RevisionNotFound } from "../graph/history.js";
|
|
24
24
|
import { linksStale, linksStaleSummary } from "../graph/link-health.js";
|
|
25
25
|
import { resolve as resolveThing } from "../search/resolve.js";
|
|
26
26
|
import { reposStatus, syncStatus } from "../sync/admin.js";
|
|
@@ -70,6 +70,8 @@ function fail(err) {
|
|
|
70
70
|
body = { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
|
|
71
71
|
else if (err instanceof MutationError)
|
|
72
72
|
body = { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
|
|
73
|
+
else if (err instanceof RevisionNotFound)
|
|
74
|
+
body = { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
|
|
73
75
|
else
|
|
74
76
|
body = { error: "repo_not_found", message: String(err), retriable: false };
|
|
75
77
|
return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
|
|
@@ -156,7 +158,10 @@ export function buildServer(ctx) {
|
|
|
156
158
|
if (asBlock)
|
|
157
159
|
return asBlock.block_id;
|
|
158
160
|
const wantDoc = scope.doc || scope.path ? resolveDocId(repoId, scope) : undefined;
|
|
159
|
-
|
|
161
|
+
// An ATX-looking value ("## Title") loses its hashes; anything else is taken
|
|
162
|
+
// as the heading's text verbatim (the setext branch of the heading rule
|
|
163
|
+
// would drop the last line, which for a one-line lookup is everything).
|
|
164
|
+
const needle = /^[ \t]*#/.test(heading) ? normalizeVisibleText(heading, "heading") : normalizeText(heading);
|
|
160
165
|
const rows = (wantDoc
|
|
161
166
|
? store.db.prepare("SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ? AND type = 'heading' AND doc_id = ? AND deleted_commit IS NULL")
|
|
162
167
|
.all(repoId, wantDoc)
|
|
@@ -343,7 +348,7 @@ export function buildServer(ctx) {
|
|
|
343
348
|
}
|
|
344
349
|
});
|
|
345
350
|
server.registerTool("read_ref", {
|
|
346
|
-
description: "Read ANY ref — a document (id or path) OR a block (id or
|
|
351
|
+
description: "Read ANY ref — a document (`d_…` id or repo-relative path) OR a block (`b_…` id, or an `n_…` node id which dereferences to its block) — and get its content, classified. Resolves the ref server-side (the polymorphic `omg cat`): a document ref returns `{ kind:\"document\", content, path, docId, … }` (complete file bytes; `resolution` does not apply); a block ref returns `{ kind:\"block\", … }` — the block subtree at `resolution` (raw|text|outline|skeleton|full, default raw). Use this when you hold a ref and want its bytes WITHOUT first knowing whether it names a document or a block. Contrast: docs_read needs a doc; nodes_get needs a block id.",
|
|
347
352
|
inputSchema: {
|
|
348
353
|
ref: z.string(),
|
|
349
354
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
@@ -423,7 +428,7 @@ export function buildServer(ctx) {
|
|
|
423
428
|
inputSchema: {},
|
|
424
429
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
425
430
|
server.registerTool("query", {
|
|
426
|
-
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 { where kind == \"md:section\" select h: name, items: section.blocks collect { where type == \"list_item\" } }`). Inside a query block (and after a top-level `from`) the `where`/`select` keyword may be omitted: a leading PREDICATE-shaped expression is an implicit `where` (`nodes exists { kind == \"md:task\" }` ≡ `{ where kind == \"md:task\" }`), and a leading reference / `name: value` list is an implicit `select` (`nodes collect { attrs.text }` projects `attrs.text`). The choice is by SYNTAX, not type — a bare boolean-valued property still PROJECTS (`nodes collect { active }`), so to filter by it write `where active` or the predicate-shaped `active == true`. 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 `from docs where layer == \"canon\" select $path values` 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 — `from docs where nodes collect { ^open: value where kind == \"md:task\" && !attrs.checked } select $path, open` 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. `from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }`. 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: `from docs select outline: nodes collect { where kind == \"md:section\" && name == \"Overview\" select n: name, d: $depth follow section.children }` (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`).",
|
|
431
|
+
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`).",
|
|
427
432
|
inputSchema: {
|
|
428
433
|
query: z.string(),
|
|
429
434
|
limit: z.number().int().optional(),
|
|
@@ -615,7 +620,7 @@ export function buildServer(ctx) {
|
|
|
615
620
|
}
|
|
616
621
|
});
|
|
617
622
|
server.registerTool("blocks_split", {
|
|
618
|
-
description: "Split a block at
|
|
623
|
+
description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `block` is a ref; `at` is a list of integer byte offsets into the block's raw bytes (spec/mutate §2.5; an offset inside a multi-byte character rounds down to its start). CAS is pinned to the block's current bytes server-side. One split op through the kernel writer.",
|
|
619
624
|
inputSchema: { block: z.string(), at: z.array(z.number().int()), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
620
625
|
}, async (args) => {
|
|
621
626
|
try {
|
|
@@ -870,7 +875,7 @@ export function buildServer(ctx) {
|
|
|
870
875
|
}
|
|
871
876
|
});
|
|
872
877
|
server.registerTool("observe", {
|
|
873
|
-
description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use docs_delete. Returns doc/path/rev plus a disposition summary (how identity threaded).",
|
|
878
|
+
description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns doc/path/rev plus a disposition summary (how identity threaded).",
|
|
874
879
|
inputSchema: { path: z.string(), content: z.string(), ...REPO_ARG },
|
|
875
880
|
}, async (args) => {
|
|
876
881
|
try {
|
|
@@ -882,7 +887,7 @@ export function buildServer(ctx) {
|
|
|
882
887
|
}
|
|
883
888
|
});
|
|
884
889
|
server.registerTool("observe_many", {
|
|
885
|
-
description: "BATCH sync ingest (ADR-014): observe several files at once — the batch form of `observe`. Give `files` as an array of `{path, content}`; each is echo-gated and reconciled independently, all under one timestamp and a single resurrection-pool sweep (cheaper than N `observe` calls for an initial walk or a large checkpoint). Returns one result per input file (same shape as `observe`). Mirror deletions with `
|
|
890
|
+
description: "BATCH sync ingest (ADR-014): observe several files at once — the batch form of `observe`. Give `files` as an array of `{path, content}`; each is echo-gated and reconciled independently, all under one timestamp and a single resurrection-pool sweep (cheaper than N `observe` calls for an initial walk or a large checkpoint). Returns one result per input file (same shape as `observe`). Mirror deletions with `observe_delete`, one path at a time.",
|
|
886
891
|
inputSchema: { files: z.array(z.object({ path: z.string(), content: z.string() })), ...REPO_ARG },
|
|
887
892
|
}, async (args) => {
|
|
888
893
|
try {
|
|
@@ -64,6 +64,13 @@ export interface ApplyRequest {
|
|
|
64
64
|
doc: string;
|
|
65
65
|
raw: string | null;
|
|
66
66
|
}[];
|
|
67
|
+
/**
|
|
68
|
+
* Commit timestamp (RFC 3339 UTC, spec/store §2.4) stamped on every commit this
|
|
69
|
+
* request records — the `api` commits and any `observed` commit a file-CAS
|
|
70
|
+
* conflict ingests. Defaults to now; the spec/mutate fixture runner pins it so
|
|
71
|
+
* the projected `commits.ts` / pool `expires_ts` compare across engines.
|
|
72
|
+
*/
|
|
73
|
+
ts?: string;
|
|
67
74
|
}
|
|
68
75
|
export interface OpResult {
|
|
69
76
|
ids: string[];
|
package/dist/src/mutate/apply.js
CHANGED
|
@@ -5,8 +5,8 @@ import { ingestFile } from "../core/ingest.js";
|
|
|
5
5
|
import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
|
|
6
6
|
import { makeKnownIdResolver } from "./known-ids.js";
|
|
7
7
|
import { loadMutDoc } from "./load.js";
|
|
8
|
-
import { renderDoc, MutationError } from "./tree.js";
|
|
9
|
-
import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge } from "./ops.js";
|
|
8
|
+
import { renderDoc, markContainerDirty, MutationError } from "./tree.js";
|
|
9
|
+
import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams } from "./ops.js";
|
|
10
10
|
import { withWriterLock } from "../sync/writer-lock.js";
|
|
11
11
|
import { resolveDocStore } from "./doc-store.js";
|
|
12
12
|
// Resolve "$n.ids[i]" placeholders in a string against prior op results.
|
|
@@ -164,7 +164,7 @@ export function apply(store, req) {
|
|
|
164
164
|
// supplied (11 §3.2); otherwise the Store's in-process serialization suffices.
|
|
165
165
|
const docStore = resolveDocStore(req);
|
|
166
166
|
const commitPhase = () => {
|
|
167
|
-
const ts = new Date().toISOString();
|
|
167
|
+
const ts = req.ts ?? new Date().toISOString();
|
|
168
168
|
for (const [docId, d] of loaded) {
|
|
169
169
|
const rendered = renderDoc(d);
|
|
170
170
|
// File-CAS: on-disk bytes must equal the revision we computed against.
|
|
@@ -191,7 +191,9 @@ export function apply(store, req) {
|
|
|
191
191
|
// and thus node — identity on the probabilistic matcher (node-editability).
|
|
192
192
|
ingestFile(store, req.repoId, d.path, rendered, {
|
|
193
193
|
ts,
|
|
194
|
-
origin: "
|
|
194
|
+
origin: "api",
|
|
195
|
+
actor: req.origin.actor,
|
|
196
|
+
reason: req.origin.reason ?? null,
|
|
195
197
|
resolveIds: makeKnownIdResolver(store, req.repoId, d, priorIds.get(docId) ?? new Set(), { path: d.path }),
|
|
196
198
|
});
|
|
197
199
|
// Keep the freshness cache warm so this engine write isn't re-hashed by a
|
|
@@ -246,15 +248,23 @@ function crossDocMove(src, dst, blockIds, to, opIndex) {
|
|
|
246
248
|
throw new MutationError("block_missing", `block ${id} not found in source doc`, { op_index: opIndex });
|
|
247
249
|
moving.push(found.block);
|
|
248
250
|
}
|
|
249
|
-
// remove from their source lists
|
|
251
|
+
// remove from their source lists (marking each emptied owner dirty)
|
|
250
252
|
for (const id of blockIds) {
|
|
251
253
|
const f = locateMut(src, id);
|
|
252
|
-
if (f)
|
|
254
|
+
if (f) {
|
|
253
255
|
f.siblings.splice(f.index, 1);
|
|
256
|
+
markContainerDirty(src, f.siblings);
|
|
257
|
+
}
|
|
254
258
|
}
|
|
255
|
-
// Insert into dst at the resolved target.
|
|
259
|
+
// Insert into dst at the resolved target; the destination owner re-renders.
|
|
256
260
|
const target = resolveDstTarget(dst, to);
|
|
257
261
|
target.siblings.splice(target.index, 0, ...moving);
|
|
262
|
+
markContainerDirty(dst, target.siblings);
|
|
263
|
+
// Heal both documents' top-level seams (spec/mutate §2.3): a block that was
|
|
264
|
+
// last in its source carries a lone "\n" that would soft-join it to its new
|
|
265
|
+
// neighbour, and the source's new last block may now be followed by nothing.
|
|
266
|
+
healTopLevelSeams(src.children, src.format);
|
|
267
|
+
healTopLevelSeams(dst.children, dst.format);
|
|
258
268
|
return { ids: blockIds };
|
|
259
269
|
}
|
|
260
270
|
function locateMut(doc, blockId) {
|
|
@@ -11,6 +11,8 @@ export interface DocOpContext {
|
|
|
11
11
|
/** workspace .omgbase/ dir; when set, the write runs under the writer flock. */
|
|
12
12
|
omgbaseDir?: string;
|
|
13
13
|
actor?: string;
|
|
14
|
+
/** Commit timestamp (RFC 3339 UTC) for every commit the operation records; defaults to now (see `ApplyRequest.ts`). */
|
|
15
|
+
ts?: string;
|
|
14
16
|
}
|
|
15
17
|
export interface DocOpResult {
|
|
16
18
|
docId: string;
|
package/dist/src/mutate/docs.js
CHANGED
|
@@ -10,6 +10,9 @@ import { inboundLinksTo, retargetLinksInRaw } from "../graph/inbound-links.js";
|
|
|
10
10
|
import { apply } from "./apply.js";
|
|
11
11
|
import { resolveDocStore } from "./doc-store.js";
|
|
12
12
|
import { MutationError } from "./tree.js";
|
|
13
|
+
function nowOr(ctx) {
|
|
14
|
+
return ctx.ts ?? new Date().toISOString();
|
|
15
|
+
}
|
|
13
16
|
function dirOf(path) {
|
|
14
17
|
return path.replace(/[^/]*$/, "");
|
|
15
18
|
}
|
|
@@ -39,8 +42,8 @@ export function docsCreate(store, ctx, path, markdown, frontmatter) {
|
|
|
39
42
|
if (docStore.exists(rel))
|
|
40
43
|
throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
|
|
41
44
|
docStore.write(rel, content);
|
|
42
|
-
const ts =
|
|
43
|
-
const res = ingestFile(store, ctx.repoId, rel, content, { ts, origin: "
|
|
45
|
+
const ts = nowOr(ctx);
|
|
46
|
+
const res = ingestFile(store, ctx.repoId, rel, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `create ${rel}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: rel }) });
|
|
44
47
|
if (ctx.omgbaseDir)
|
|
45
48
|
docStore.recordStat(store, ctx.repoId, rel, content);
|
|
46
49
|
return { docId: res.docId, path: rel, committed: true };
|
|
@@ -72,7 +75,7 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
|
|
|
72
75
|
docStore.rename(info.path, toRel);
|
|
73
76
|
else
|
|
74
77
|
docStore.write(toRel, content);
|
|
75
|
-
const ts =
|
|
78
|
+
const ts = nowOr(ctx);
|
|
76
79
|
store.write((db) => {
|
|
77
80
|
// Update the document row's path + its open revisions' path pointer, and
|
|
78
81
|
// record an api commit noting the move.
|
|
@@ -161,6 +164,7 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
|
|
|
161
164
|
...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
|
|
162
165
|
...(ctx.docStore ? { docStore: ctx.docStore } : {}),
|
|
163
166
|
...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
|
|
167
|
+
...(ctx.ts ? { ts: ctx.ts } : {}),
|
|
164
168
|
ops,
|
|
165
169
|
origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
|
|
166
170
|
});
|
|
@@ -177,7 +181,7 @@ export function docsDelete(store, ctx, docRef) {
|
|
|
177
181
|
throw new MutationError("doc_missing", `no document ${docRef}`);
|
|
178
182
|
const docStore = resolveDocStore(ctx);
|
|
179
183
|
return underLock(ctx, () => {
|
|
180
|
-
const ts =
|
|
184
|
+
const ts = nowOr(ctx);
|
|
181
185
|
store.write((db) => {
|
|
182
186
|
const commit = newCommit(db, { repoId: ctx.repoId, ts, origin: "api", actor: ctx.actor ?? null, reason: `delete ${info.path}` });
|
|
183
187
|
// Tombstone the document and its live blocks; FTS rows drop with the blocks.
|
|
@@ -205,8 +209,8 @@ export function docsSetMeta(store, ctx, docRef, patch) {
|
|
|
205
209
|
delete merged[k];
|
|
206
210
|
const content = composeFile(body, merged);
|
|
207
211
|
docStore.write(info.path, content);
|
|
208
|
-
const ts =
|
|
209
|
-
const res = ingestFile(store, ctx.repoId, info.path, content, { ts, origin: "
|
|
212
|
+
const ts = nowOr(ctx);
|
|
213
|
+
const res = ingestFile(store, ctx.repoId, info.path, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `set_meta ${info.path}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: info.path }) });
|
|
210
214
|
if (ctx.omgbaseDir)
|
|
211
215
|
docStore.recordStat(store, ctx.repoId, info.path, content);
|
|
212
216
|
return { docId: res.docId, path: info.path, committed: true };
|
|
@@ -91,6 +91,14 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
|
|
|
91
91
|
detail: {},
|
|
92
92
|
}));
|
|
93
93
|
const deleted = [...priorIds].filter((id) => !nowSet.has(id));
|
|
94
|
+
// Ids new to THIS document may already exist elsewhere: a cross-document
|
|
95
|
+
// move carries a block whose source document still holds its row (it
|
|
96
|
+
// commits later in the same changeset) or already pooled it (it committed
|
|
97
|
+
// first). Report them so ingest evicts the foreign row and drops the pool
|
|
98
|
+
// row (spec/store §5.4) — otherwise a live block would sit in the pool, or
|
|
99
|
+
// the destination's INSERT would collide on the block_id key. A freshly
|
|
100
|
+
// minted id matches nothing and the eviction is a no-op.
|
|
101
|
+
const crossDocIds = nowIds.filter((id) => !priorIds.has(id));
|
|
94
102
|
const extractEdges = (thisDocId, metadata) => {
|
|
95
103
|
const out = [];
|
|
96
104
|
if (adapter?.extractEdges) {
|
|
@@ -114,7 +122,7 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
|
|
|
114
122
|
return out;
|
|
115
123
|
};
|
|
116
124
|
void docId;
|
|
117
|
-
return { assigned, dispositions, deleted, extractEdges };
|
|
125
|
+
return { assigned, dispositions, deleted, crossDocIds, extractEdges };
|
|
118
126
|
};
|
|
119
127
|
}
|
|
120
128
|
// Edge resolution — mirrors reconciling-ingest's resolvers (block-anchor targets
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { adapterForFormat } from "../format/registry.js";
|
|
2
2
|
import { MutationError } from "./tree.js";
|
|
3
|
+
import { byteSpanToCodeUnits } from "../core/utf8.js";
|
|
3
4
|
import { globClause } from "../graph/link-health.js";
|
|
4
5
|
import { rewriteLinkDestinations, splitDestination } from "../graph/link-destinations.js";
|
|
5
6
|
// Server-side macros (04 §3). Each expands deterministically to kernel ops —
|
|
@@ -102,7 +103,9 @@ export function nodeSet(store, nodeId, prop, value) {
|
|
|
102
103
|
const hash = rawHashOfBlock(store, node.block_id);
|
|
103
104
|
const result = editor({
|
|
104
105
|
blockRaw,
|
|
105
|
-
|
|
106
|
+
// Stored spans are byte offsets into the block's UTF-8 raw (spec/graph
|
|
107
|
+
// §2.3); editors slice a JavaScript string, so convert back to code units.
|
|
108
|
+
span: node.span_start !== null && node.span_end !== null ? byteSpanToCodeUnits(blockRaw, node.span_start, node.span_end) : null,
|
|
106
109
|
node: {
|
|
107
110
|
kind: node.kind,
|
|
108
111
|
...(node.name !== null ? { name: node.name } : {}),
|
|
@@ -179,7 +182,11 @@ export function linksRepair(store, repoId, repairs, opts = {}) {
|
|
|
179
182
|
JOIN blobs b ON b.hash = bl.raw_hash
|
|
180
183
|
JOIN docs d ON d.doc_id = bl.doc_id
|
|
181
184
|
WHERE bl.repo_id = ? AND bl.deleted_commit IS NULL AND d.deleted_commit IS NULL
|
|
182
|
-
AND bl.type != 'code_fence' AND instr(b.bytes, ?) > 0 ${globSql}
|
|
185
|
+
AND bl.type != 'code_fence' AND instr(b.bytes, ?) > 0 ${globSql}
|
|
186
|
+
ORDER BY d.path, bl.depth, bl.ordinal, bl.block_id`);
|
|
187
|
+
// The ORDER BY fixes the order of `ops`/`hits` (document path, then shallow
|
|
188
|
+
// before deep, then sibling order) so two engines emit the same changeset;
|
|
189
|
+
// an unordered join would leave it to the query planner (spec/mutate §5).
|
|
183
190
|
const candidates = new Map();
|
|
184
191
|
for (const key of new Set(effective.map((r) => stripSlash(r.from)))) {
|
|
185
192
|
const rows = stmt.all(repoId, key, ...params);
|
package/dist/src/mutate/ops.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MutDoc } from "./tree.js";
|
|
1
|
+
import { type MutBlock, type MutDoc } from "./tree.js";
|
|
2
2
|
export type At = "start" | "end" | {
|
|
3
3
|
before: string;
|
|
4
4
|
} | {
|
|
@@ -26,6 +26,7 @@ export declare function opUpdate(doc: MutDoc, blockId: string, opIndex: number,
|
|
|
26
26
|
export declare function opMove(doc: MutDoc, blockIds: string[], to: To, opIndex: number): {
|
|
27
27
|
ids: string[];
|
|
28
28
|
};
|
|
29
|
+
export declare function healTopLevelSeams(children: MutBlock[], format: string): void;
|
|
29
30
|
export declare function opRemove(doc: MutDoc, blockIds: string[], opIndex: number, expectPer?: Record<string, Expect>): {
|
|
30
31
|
ids: string[];
|
|
31
32
|
removed: string[];
|
package/dist/src/mutate/ops.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mintId } from "../core/ids.js";
|
|
2
2
|
import { parseTree } from "../core/parse/tree.js";
|
|
3
3
|
import { adapterForFormat } from "../format/index.js";
|
|
4
|
+
import { codeUnitIndexOf } from "../core/utf8.js";
|
|
4
5
|
import { MutationError, locate, rawHashHex, parentChildrenHash, markContainerDirty, ownerOf } from "./tree.js";
|
|
5
6
|
function defaultTrivia(format) {
|
|
6
7
|
if (format === "json")
|
|
@@ -363,7 +364,7 @@ export function opMove(doc, blockIds, to, opIndex) {
|
|
|
363
364
|
// document is already well-formed) and never touches the last block's trailing
|
|
364
365
|
// trivia (that is the document's trailing bytes). Exact target trivia is the
|
|
365
366
|
// planner's job via `update { trivia }`; this only guarantees well-formedness.
|
|
366
|
-
function healTopLevelSeams(children, format) {
|
|
367
|
+
export function healTopLevelSeams(children, format) {
|
|
367
368
|
const sep = defaultTrivia(format);
|
|
368
369
|
for (let i = 0; i < children.length - 1; i++) {
|
|
369
370
|
const b = children[i];
|
|
@@ -454,7 +455,12 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
|
|
|
454
455
|
throw new MutationError("block_missing", `block ${blockId} not found`, { op_index: opIndex, block: blockId });
|
|
455
456
|
checkContentHash(found.block, expect, opIndex);
|
|
456
457
|
const raw = found.block.raw;
|
|
457
|
-
|
|
458
|
+
// `at` are UTF-8 byte offsets into the raw (spec/mutate §2.5) — the same
|
|
459
|
+
// language-neutral unit every persisted span uses. A JavaScript string slices
|
|
460
|
+
// at UTF-16 code units, so convert each cut first; an offset inside a
|
|
461
|
+
// multi-byte sequence rounds down to that character's start, one past the end
|
|
462
|
+
// clamps (core/utf8.ts).
|
|
463
|
+
const cuts = [0, ...at.map((b) => codeUnitIndexOf(raw, b)), raw.length].sort((a, b) => a - b);
|
|
458
464
|
const pieces = [];
|
|
459
465
|
for (let i = 1; i < cuts.length; i++)
|
|
460
466
|
pieces.push(raw.slice(cuts[i - 1], cuts[i]));
|
|
@@ -467,7 +473,23 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
|
|
|
467
473
|
const newBlocks = nonEmpty.slice(1).map((p) => ({
|
|
468
474
|
id: mintId("b"), type: found.block.type, raw: p, trivia: "\n\n", attrs: { ...found.block.attrs }, children: [], dirty: true,
|
|
469
475
|
}));
|
|
476
|
+
// Seams exactly as opUpdate's extra-sibling path (spec/mutate §2.5): the
|
|
477
|
+
// target's trailing trivia is the document's tiling AFTER the whole run, so it
|
|
478
|
+
// moves to the last piece; at the top level every earlier piece (the first
|
|
479
|
+
// included) must separate blocks — a last block's lone "\n" would otherwise
|
|
480
|
+
// soft-join the pieces into one paragraph and lose the minted ids.
|
|
481
|
+
const tail = found.block.trivia;
|
|
482
|
+
if (found.siblings === doc.children) {
|
|
483
|
+
const sep = defaultTrivia(doc.format);
|
|
484
|
+
if (!separatesBlocks(found.block.trivia, doc.format))
|
|
485
|
+
found.block.trivia = sep;
|
|
486
|
+
for (const b of newBlocks.slice(0, -1))
|
|
487
|
+
if (!separatesBlocks(b.trivia, doc.format))
|
|
488
|
+
b.trivia = sep;
|
|
489
|
+
}
|
|
490
|
+
newBlocks[newBlocks.length - 1].trivia = tail;
|
|
470
491
|
found.siblings.splice(found.index + 1, 0, ...newBlocks);
|
|
492
|
+
markContainerDirty(doc, found.siblings);
|
|
471
493
|
return { ids: [blockId, ...newBlocks.map((b) => b.id)] };
|
|
472
494
|
}
|
|
473
495
|
export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {
|