@omgbase/core 0.2.0 → 0.4.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.
Files changed (43) hide show
  1. package/dist/src/core/clock.d.ts +9 -0
  2. package/dist/src/core/clock.js +46 -0
  3. package/dist/src/core/ingest.js +15 -12
  4. package/dist/src/core/read/document.js +6 -1
  5. package/dist/src/core/read/nodes.js +5 -1
  6. package/dist/src/core/store/fts.d.ts +38 -7
  7. package/dist/src/core/store/fts.js +60 -9
  8. package/dist/src/core/store/rebuild.js +5 -4
  9. package/dist/src/core/store/writers.d.ts +6 -0
  10. package/dist/src/core/store/writers.js +1 -0
  11. package/dist/src/format/markdown.js +27 -23
  12. package/dist/src/graph/extract.d.ts +41 -1
  13. package/dist/src/graph/extract.js +59 -1
  14. package/dist/src/graph/history.d.ts +33 -1
  15. package/dist/src/graph/history.js +141 -18
  16. package/dist/src/index.d.ts +4 -2
  17. package/dist/src/index.js +4 -1
  18. package/dist/src/mcp/server.js +23 -12
  19. package/dist/src/mutate/apply.d.ts +2 -0
  20. package/dist/src/mutate/apply.js +8 -5
  21. package/dist/src/mutate/known-ids.js +2 -2
  22. package/dist/src/mutate/ops.d.ts +3 -3
  23. package/dist/src/mutate/ops.js +38 -13
  24. package/dist/src/oqx-js/context.d.ts +11 -0
  25. package/dist/src/oqx-js/context.js +30 -1
  26. package/dist/src/oqx-js/planner.d.ts +20 -1
  27. package/dist/src/oqx-js/planner.js +95 -2
  28. package/dist/src/oqx-js/run.js +27 -2
  29. package/dist/src/oqx-js/sql/translate.d.ts +71 -1
  30. package/dist/src/oqx-js/sql/translate.js +165 -70
  31. package/dist/src/search/rrf.js +2 -1
  32. package/dist/src/search/tasks.d.ts +34 -1
  33. package/dist/src/search/tasks.js +42 -23
  34. package/dist/src/search/vector.js +19 -18
  35. package/dist/src/sync/external-source.js +20 -7
  36. package/dist/src/sync/freshness.d.ts +8 -1
  37. package/dist/src/sync/freshness.js +23 -3
  38. package/dist/src/sync/plugin.d.ts +30 -5
  39. package/dist/src/sync/plugin.js +13 -1
  40. package/dist/src/sync/reconciling-ingest.js +5 -3
  41. package/dist/src/sync/watcher.d.ts +2 -0
  42. package/dist/src/sync/watcher.js +10 -2
  43. package/package.json +2 -2
@@ -72,26 +72,149 @@ function blocksAtRevision(store, docId, revId) {
72
72
  walk(rev.root_tree.toString("hex"));
73
73
  return out;
74
74
  }
75
- /** Unified textual diff (line-based) between two revisions' rendered files. */
75
+ /** `diff_unified` (spec/surface §3): a unified diff of two revisions' rendered
76
+ * texts — each revision's live raws joined by `\n` — via {@link unifiedDiff}. */
76
77
  export function diffUnified(store, docId, fromRev, toRev) {
77
- const rendered = (rev) => {
78
- const map = blocksAtRevision(store, docId, rev);
79
- return [...map.values()].join("\n");
80
- };
81
- const a = rendered(fromRev).split("\n");
82
- const b = rendered(toRev).split("\n");
83
- // Minimal line diff (not a full Myers; sufficient for digests/preview).
84
- const out = [];
85
- const max = Math.max(a.length, b.length);
86
- for (let i = 0; i < max; i++) {
87
- if (a[i] === b[i])
88
- continue;
89
- if (a[i] !== undefined)
90
- out.push(`- ${a[i]}`);
91
- if (b[i] !== undefined)
92
- out.push(`+ ${b[i]}`);
78
+ const rendered = (rev) => [...blocksAtRevision(store, docId, rev).values()].join("\n");
79
+ return unifiedDiff(rendered(fromRev), rendered(toRev));
80
+ }
81
+ // ---- unified diff (spec/surface §3) ------------------------------------------
82
+ // A line-grain unified diff with a deterministic Myers script, so both engines
83
+ // (this reference and crates/omgbase-surface/src/history.rs) produce the same
84
+ // bytes for the same two texts. Kept pure so it can be pinned by unit tests.
85
+ /** How each text becomes lines: `split("\n")` exactly — no trimming, no
86
+ * dropping of a trailing empty element (a raw ending in `\n` yields one) — with
87
+ * a single special case: the empty text has *no* lines (an empty file is zero
88
+ * lines, not one empty line), so a diff from/to nothing is `@@ -0,0 +1,n @@`. */
89
+ export function diffLines(text) {
90
+ return text === "" ? [] : text.split("\n");
91
+ }
92
+ const DIFF_CONTEXT = 3;
93
+ /**
94
+ * Myers' O(ND) shortest edit script (forward, with a per-`d` trace for the
95
+ * backtrack). `V[k]` is the furthest x on diagonal `k = x - y` reachable with
96
+ * `d` edits. The canonical tie rule, identical in both engines: at each step
97
+ * take the diagonal from `k+1` (moving down — an insertion of `b[y]`) when
98
+ * `k == -d || (k != d && V[k-1] < V[k+1])`, else from `k-1` (moving right —
99
+ * a deletion of `a[x]`). On a tie (`V[k-1] == V[k+1]`) that is the deletion.
100
+ */
101
+ export function myersScript(a, b) {
102
+ const n = a.length;
103
+ const m = b.length;
104
+ const max = n + m;
105
+ // V is indexed by k ∈ [-max-1, max+1]; `off` maps it onto a plain array.
106
+ const off = max + 1;
107
+ const v = new Array(2 * max + 3).fill(0);
108
+ const trace = [];
109
+ let found = false;
110
+ for (let d = 0; d <= max && !found; d++) {
111
+ trace.push(v.slice());
112
+ for (let k = -d; k <= d; k += 2) {
113
+ let x;
114
+ if (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]))
115
+ x = v[off + k + 1];
116
+ else
117
+ x = v[off + k - 1] + 1;
118
+ let y = x - k;
119
+ while (x < n && y < m && a[x] === b[y]) {
120
+ x++;
121
+ y++;
122
+ }
123
+ v[off + k] = x;
124
+ if (x >= n && y >= m) {
125
+ found = true;
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ // Backtrack from (n, m) through the trace, emitting ops newest-first.
131
+ const ops = [];
132
+ let x = n;
133
+ let y = m;
134
+ for (let d = trace.length - 1; d >= 0; d--) {
135
+ const vd = trace[d];
136
+ const k = x - y;
137
+ const prevK = k === -d || (k !== d && vd[off + k - 1] < vd[off + k + 1]) ? k + 1 : k - 1;
138
+ const prevX = vd[off + prevK];
139
+ const prevY = prevX - prevK;
140
+ while (x > prevX && y > prevY) {
141
+ x--;
142
+ y--;
143
+ ops.push({ kind: "keep", line: a[x] });
144
+ }
145
+ if (d > 0) {
146
+ if (x === prevX)
147
+ ops.push({ kind: "insert", line: b[prevY] });
148
+ else
149
+ ops.push({ kind: "delete", line: a[prevX] });
150
+ }
151
+ x = prevX;
152
+ y = prevY;
153
+ }
154
+ ops.reverse();
155
+ return ops;
156
+ }
157
+ /**
158
+ * The unified diff of two texts (spec/surface §3): hunks of `DIFF_CONTEXT` (3)
159
+ * lines of context; a change group extends to include the next change when
160
+ * fewer than `2 * DIFF_CONTEXT + 1` unchanged lines separate them (the two
161
+ * contexts touch or overlap). Each hunk is `@@ -a,b +c,d @@` (1-based start
162
+ * and length; a length of 1 is written as the start alone; a length of 0 as
163
+ * `a,0` with `a` the line before the insertion point, `0` at the very top)
164
+ * followed by its lines prefixed `-`, `+` or a space with nothing after the
165
+ * sign; hunks joined by `\n`; no file header; identical texts → `""`.
166
+ */
167
+ export function unifiedDiff(oldText, newText) {
168
+ const ops = myersScript(diffLines(oldText), diffLines(newText));
169
+ // Old/new line counts consumed before each op (0-based positions).
170
+ const oldPos = new Array(ops.length + 1);
171
+ const newPos = new Array(ops.length + 1);
172
+ let o = 0;
173
+ let nn = 0;
174
+ for (let i = 0; i < ops.length; i++) {
175
+ oldPos[i] = o;
176
+ newPos[i] = nn;
177
+ if (ops[i].kind !== "insert")
178
+ o++;
179
+ if (ops[i].kind !== "delete")
180
+ nn++;
181
+ }
182
+ oldPos[ops.length] = o;
183
+ newPos[ops.length] = nn;
184
+ const changes = [];
185
+ for (let i = 0; i < ops.length; i++)
186
+ if (ops[i].kind !== "keep")
187
+ changes.push(i);
188
+ if (changes.length === 0)
189
+ return "";
190
+ const hunks = [];
191
+ let g = 0;
192
+ while (g < changes.length) {
193
+ const first = changes[g];
194
+ let last = first;
195
+ // Merge rule: the next change joins this hunk iff the unchanged lines
196
+ // between them number at most 2 * DIFF_CONTEXT.
197
+ while (g + 1 < changes.length && changes[g + 1] - last - 1 <= 2 * DIFF_CONTEXT) {
198
+ g++;
199
+ last = changes[g];
200
+ }
201
+ g++;
202
+ const start = Math.max(0, first - DIFF_CONTEXT);
203
+ const end = Math.min(ops.length - 1, last + DIFF_CONTEXT);
204
+ const oldLen = oldPos[end + 1] - oldPos[start];
205
+ const newLen = newPos[end + 1] - newPos[start];
206
+ const range = (pos, len) => {
207
+ const startLine = len === 0 ? pos : pos + 1;
208
+ return len === 1 ? `${startLine}` : `${startLine},${len}`;
209
+ };
210
+ const lines = [`@@ -${range(oldPos[start], oldLen)} +${range(newPos[start], newLen)} @@`];
211
+ for (let i = start; i <= end; i++) {
212
+ const op = ops[i];
213
+ lines.push((op.kind === "keep" ? " " : op.kind === "delete" ? "-" : "+") + op.line);
214
+ }
215
+ hunks.push(lines.join("\n"));
93
216
  }
94
- return out.join("\n");
217
+ return hunks.join("\n");
95
218
  }
96
219
  /** changes_since: commit digests after a cursor (repo commit seq) — the change
97
220
  * feed (06 §3). `seq` is a dense per-REPO total order, so a cursor is only
@@ -19,7 +19,8 @@ export { ingestDirectory, type IngestDirResult } from "./sync/attach.js";
19
19
  export { ensureRepo } from "./core/attach.js";
20
20
  export { walkMarkdown, walkMarkdownAsync, nodeFs, type SyncFs, type FileStat } from "./sync/fs-util.js";
21
21
  export { Watcher, type WatcherOptions } from "./sync/watcher.js";
22
- export type { SyncSource, SourceCapabilities, SourceIdentity, SourceEntry, SourceItem, SourceWatch, WatchListener, } from "./sync/plugin.js";
22
+ export type { SyncSource, SourceCapabilities, SourceIdentity, SourceEntry, SourceItem, SourceWatch, WatchEvent, WatchListener, } from "./sync/plugin.js";
23
+ export { awaitReady, WATCH_READY_PATIENCE_MS } from "./sync/plugin.js";
23
24
  export { createExternalSource, type ExternalSourceSpec } from "./sync/external-source.js";
24
25
  export { ensureAdapter, listAdapters, createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, renderConfigFlags, type AdapterRow, type SourceRow, } from "./sync/sources.js";
25
26
  export { reconcileChanges, attachSource } from "./sync/driver.js";
@@ -43,12 +44,13 @@ export { embeddingSettings, type EmbeddingSettings } from "./search/provider.js"
43
44
  export { createExternalProvider, type ExternalProvider } from "./search/external.js";
44
45
  export { hybridSearch, type HybridHit, type HybridInput } from "./search/rrf.js";
45
46
  export { vectorSearch, docVectorSearch, type VectorHit, type DocVectorHit } from "./search/vector.js";
46
- export { historyNode, diffUnified, changesSince, type NodeChange, type CommitDigest } from "./graph/history.js";
47
+ export { historyNode, diffUnified, unifiedDiff, changesSince, type NodeChange, type CommitDigest } from "./graph/history.js";
47
48
  export { docLinks, type LinksResult, type LinkEdge, type LinksOptions } from "./graph/links.js";
48
49
  export { FilterInvalid } from "./search/cel/parser.js";
49
50
  export { EngineError, type ErrorCode } from "./mcp/errors.js";
50
51
  export { MutationError } from "./mutate/tree.js";
51
52
  export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter, type IdMinter, type IdPrefix } from "./core/ids.js";
53
+ export { pinClock, withClock } from "./core/clock.js";
52
54
  export { sha256 } from "./core/hash.js";
53
55
  export { ingestFile, type IngestResult } from "./core/ingest.js";
54
56
  export { apply, type ApplyRequest, type ApplyResult, type Op } from "./mutate/apply.js";
package/dist/src/index.js CHANGED
@@ -20,6 +20,7 @@ export { ingestDirectory } from "./sync/attach.js";
20
20
  export { ensureRepo } from "./core/attach.js";
21
21
  export { walkMarkdown, walkMarkdownAsync, nodeFs } from "./sync/fs-util.js";
22
22
  export { Watcher } from "./sync/watcher.js";
23
+ export { awaitReady, WATCH_READY_PATIENCE_MS } from "./sync/plugin.js";
23
24
  export { createExternalSource } from "./sync/external-source.js";
24
25
  export { ensureAdapter, listAdapters, createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, renderConfigFlags, } from "./sync/sources.js";
25
26
  export { reconcileChanges, attachSource } from "./sync/driver.js";
@@ -48,12 +49,14 @@ export { embeddingSettings } from "./search/provider.js";
48
49
  export { createExternalProvider } from "./search/external.js";
49
50
  export { hybridSearch } from "./search/rrf.js";
50
51
  export { vectorSearch, docVectorSearch } from "./search/vector.js";
51
- export { historyNode, diffUnified, changesSince } from "./graph/history.js";
52
+ export { historyNode, diffUnified, unifiedDiff, changesSince } from "./graph/history.js";
52
53
  export { docLinks } from "./graph/links.js";
53
54
  export { FilterInvalid } from "./search/cel/parser.js";
54
55
  export { EngineError } from "./mcp/errors.js";
55
56
  export { MutationError } from "./mutate/tree.js";
56
57
  export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter } from "./core/ids.js";
58
+ // The clock seam (spec/surface §7.1): `omg mcp` pins the process clock under OMGBASE_SPEC_CLOCK.
59
+ export { pinClock, withClock } from "./core/clock.js";
57
60
  export { sha256 } from "./core/hash.js";
58
61
  export { ingestFile } from "./core/ingest.js";
59
62
  // Mutation surface consumed by CLI write commands (11 §5.6).
@@ -45,14 +45,23 @@ const toSchema = z.object({
45
45
  ]),
46
46
  at: atSchema,
47
47
  });
48
+ // blocks_insert / blocks_move carry the op-level expectation of spec/mutate §1.2:
49
+ // only `parent_children_hash` (the destination parent's live direct child ids
50
+ // joined by "," and sha256-hexed) has a meaning there — there is no target block
51
+ // whose content_hash could be checked — so the schema names that one key alone
52
+ // (an accidental `content_hash` is dropped, not an error; the kernel would
53
+ // ignore it anyway).
54
+ const parentExpectSchema = z.object({
55
+ parent_children_hash: z.string().optional(),
56
+ });
48
57
  const expectSchema = z.object({
49
58
  content_hash: z.string().optional(),
50
59
  parent_children_hash: z.string().optional(),
51
60
  });
52
61
  const opSchema = z.discriminatedUnion("op", [
53
- z.object({ op: z.literal("insert"), doc: z.string().optional(), to: toSchema, markdown: z.string() }),
62
+ z.object({ op: z.literal("insert"), doc: z.string().optional(), to: toSchema, markdown: z.string(), expect: expectSchema.optional() }),
54
63
  z.object({ op: z.literal("update"), block: z.string(), markdown: z.string().optional(), attrs: z.record(z.string(), z.unknown()).optional(), expect: expectSchema.optional() }),
55
- z.object({ op: z.literal("move"), blocks: z.array(z.string()), to: toSchema }),
64
+ z.object({ op: z.literal("move"), blocks: z.array(z.string()), to: toSchema, expect: expectSchema.optional() }),
56
65
  z.object({ op: z.literal("remove"), blocks: z.array(z.string()), expect: z.record(z.string(), expectSchema).optional() }),
57
66
  z.object({ op: z.literal("split"), block: z.string(), at: z.array(z.number().int()), expect: expectSchema.optional() }),
58
67
  z.object({ op: z.literal("merge"), blocks: z.array(z.string()), separator: z.string().optional(), expect: z.record(z.string(), expectSchema).optional() }),
@@ -279,7 +288,7 @@ export function buildServer(ctx) {
279
288
  }
280
289
  });
281
290
  server.registerTool("docs_get_many", {
282
- description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a doc id OR a path, same id-or-path symmetry docs_read accepts in its `doc` field), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`content` = complete file bytes verbatim, `properties` grouped by source, plus `path`/`docId`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block id → content hash} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it. Use this after query/text_search/resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline.",
291
+ description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a doc id OR a path, same id-or-path symmetry docs_read accepts in its `doc` field), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`content` = complete file bytes verbatim, `properties` grouped by source, plus `path`/`docId`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block id → content hash} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/text_search/resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline.",
283
292
  inputSchema: {
284
293
  docs: z.array(z.string()),
285
294
  include_ids: z.boolean().optional(),
@@ -322,7 +331,7 @@ export function buildServer(ctx) {
322
331
  }
323
332
  });
324
333
  server.registerTool("nodes_get_many", {
325
- description: "Fetch up to 100 blocks by id (in request order) with budget truncation. Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side; `doc`/`path` is an optional SCOPE (ids from other docs then count as unresolved), not a requirement. Result carries `nodes`, `truncated` (cap or `budget_tokens` hit), and `unresolved` — the requested ids that name no live block (never silently dropped).",
334
+ description: "Fetch up to 100 blocks by id (in request order) with budget truncation. Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side; `doc`/`path` is an optional SCOPE (ids from other docs then count as unresolved), not a requirement. Result carries `nodes`, `truncated` (cap or `budget_tokens` hit — the first resolved node always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
326
335
  inputSchema: {
327
336
  doc: z.string().optional(),
328
337
  path: z.string().optional(),
@@ -540,16 +549,18 @@ export function buildServer(ctx) {
540
549
  // local: the resolution that used to live in the CLI now lives next to the
541
550
  // store, so a remote client needs no local database.
542
551
  server.registerTool("blocks_insert", {
543
- description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block, or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Expands to one insert op through the kernel writer.",
544
- inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
552
+ description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block, or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
553
+ inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
545
554
  }, async (args) => {
546
555
  try {
547
556
  const { repoId, rootPath } = repoScope(args.repo);
548
557
  const root = requireRoot(rootPath);
549
558
  const { parent, docId } = resolveParentRef(repoId, args.to);
550
559
  const to = { parent, at: resolveAt(repoId, args.at) };
551
- // A document parent carries no block to infer the doc from; pin it.
552
- const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown }];
560
+ // A document parent carries no block to infer the doc from; pin it. The
561
+ // caller's expect rides through as the op-level expectation (spec/mutate
562
+ // §1.2: checked against the destination parent before the op mutates).
563
+ const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown, ...(args.expect ? { expect: args.expect } : {}) }];
553
564
  return applyOps(repoId, root, ops, "blocks_insert", args.dry_run);
554
565
  }
555
566
  catch (e) {
@@ -583,8 +594,8 @@ export function buildServer(ctx) {
583
594
  }
584
595
  });
585
596
  server.registerTool("blocks_move", {
586
- description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref, or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. One move op through the kernel writer.",
587
- inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
597
+ description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref, or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. One move op through the kernel writer.",
598
+ inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
588
599
  }, async (args) => {
589
600
  try {
590
601
  const { repoId, rootPath } = repoScope(args.repo);
@@ -597,7 +608,7 @@ export function buildServer(ctx) {
597
608
  throw new EngineError("target_missing", `blocks_move cannot target another document's root (${args.to}); anchor on a block in that document with at.before/at.after`, { data: { to: args.to } });
598
609
  }
599
610
  const to = { parent, at: resolveAt(repoId, args.at) };
600
- const ops = [{ op: "move", blocks, to }];
611
+ const ops = [{ op: "move", blocks, to, ...(args.expect ? { expect: args.expect } : {}) }];
601
612
  return applyOps(repoId, root, ops, "blocks_move", args.dry_run);
602
613
  }
603
614
  catch (e) {
@@ -939,7 +950,7 @@ export function buildServer(ctx) {
939
950
  }
940
951
  });
941
952
  server.registerTool("diff_unified", {
942
- description: "Line-based unified diff between two revisions of a document (the `omg diff` rendering): returns `{ doc, path, from, to, diff }` where `diff` is the +/- unified text. `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `diff`, which returns block-grain added/removed/changed entries.",
953
+ description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{ doc, path, from, to, diff }` where `diff` is a unified diff of the two rendered texts (Myers shortest edit script; `@@ -a,b +c,d @@` hunks with 3 lines of context; lines prefixed `-`, `+` or a space; no file header; identical texts → \"\"). `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `diff`, which returns block-grain added/removed/changed entries.",
943
954
  inputSchema: { doc: z.string(), from_rev: z.string().optional(), to_rev: z.string().optional(), ...REPO_ARG },
944
955
  }, async (args) => {
945
956
  try {
@@ -6,6 +6,7 @@ export type Op = {
6
6
  doc?: string;
7
7
  to: To;
8
8
  markdown: string;
9
+ expect?: Expect;
9
10
  } | {
10
11
  op: "update";
11
12
  block: string;
@@ -18,6 +19,7 @@ export type Op = {
18
19
  op: "move";
19
20
  blocks: string[];
20
21
  to: To;
22
+ expect?: Expect;
21
23
  } | {
22
24
  op: "remove";
23
25
  blocks: string[];
@@ -6,7 +6,7 @@ import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
6
6
  import { makeKnownIdResolver } from "./known-ids.js";
7
7
  import { loadMutDoc } from "./load.js";
8
8
  import { renderDoc, markContainerDirty, MutationError } from "./tree.js";
9
- import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams } from "./ops.js";
9
+ import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams, checkParentChildrenHash } 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.
@@ -92,7 +92,7 @@ export function apply(store, req) {
92
92
  ? resolveDocRef(store, req.repoId, rawOp.doc)
93
93
  : parentDoc(store, req.repoId, to, loaded);
94
94
  const d = ensureDoc(docId);
95
- results.push(opInsert(d, to, rawOp.markdown));
95
+ results.push(opInsert(d, to, rawOp.markdown, rawOp.expect, i));
96
96
  break;
97
97
  }
98
98
  case "update": {
@@ -114,11 +114,11 @@ export function apply(store, req) {
114
114
  const topLevelSameDoc = typeof to.parent === "object" && "doc" in to.parent && typeof to.at === "string";
115
115
  const dstDocId = topLevelSameDoc ? srcDocId : parentDoc(store, req.repoId, to, loaded);
116
116
  if (dstDocId === srcDocId) {
117
- results.push(opMove(ensureDoc(srcDocId), blocks, to, i));
117
+ results.push(opMove(ensureDoc(srcDocId), blocks, to, i, rawOp.expect));
118
118
  }
119
119
  else {
120
120
  // Cross-document move (04 §1): extract from source, insert into target.
121
- results.push(crossDocMove(ensureDoc(srcDocId), ensureDoc(dstDocId), blocks, to, i));
121
+ results.push(crossDocMove(ensureDoc(srcDocId), ensureDoc(dstDocId), blocks, to, i, rawOp.expect));
122
122
  }
123
123
  break;
124
124
  }
@@ -239,7 +239,7 @@ function throwMissing(id) {
239
239
  throw new MutationError("parent_missing", `parent ${id} not found`);
240
240
  }
241
241
  // Cross-document move: extract the contiguous run from src, insert into dst.
242
- function crossDocMove(src, dst, blockIds, to, opIndex) {
242
+ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
243
243
  // Extract from src (must be a contiguous top-level or same-parent run).
244
244
  const moving = [];
245
245
  for (const id of blockIds) {
@@ -248,6 +248,9 @@ function crossDocMove(src, dst, blockIds, to, opIndex) {
248
248
  throw new MutationError("block_missing", `block ${id} not found in source doc`, { op_index: opIndex });
249
249
  moving.push(found.block);
250
250
  }
251
+ // §1.2: the destination parent's order CAS (in `dst`), before either tree moves.
252
+ if (expect?.parent_children_hash !== undefined)
253
+ checkParentChildrenHash(resolveDstTarget(dst, to).siblings, expect, opIndex);
251
254
  // remove from their source lists (marking each emptied owner dirty)
252
255
  for (const id of blockIds) {
253
256
  const f = locateMut(src, id);
@@ -32,7 +32,7 @@ function assignFromMut(blocks, byKey) {
32
32
  const walk = (list, parentKey) => list.map((b, index) => {
33
33
  const key = `${parentKey ?? ""}/${index}`;
34
34
  const blockId = byKey.get(key) ?? mintId("b");
35
- return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key) };
35
+ return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key), span: b.span };
36
36
  });
37
37
  return walk(blocks, null);
38
38
  }
@@ -41,7 +41,7 @@ function assignFromMut(blocks, byKey) {
41
41
  function collectRawBlocks(blocks) {
42
42
  return blocks.map((b) => ({
43
43
  type: b.type,
44
- span: { start: 0, end: 0 },
44
+ span: b.span ?? { start: 0, end: 0 },
45
45
  raw: b.raw,
46
46
  text: "",
47
47
  attrs: b.attrs,
@@ -17,13 +17,14 @@ export interface Expect {
17
17
  content_hash?: string;
18
18
  parent_children_hash?: string;
19
19
  }
20
- export declare function opInsert(doc: MutDoc, to: To, markdown: string): {
20
+ export declare function checkParentChildrenHash(siblings: MutBlock[], expect: Expect | undefined, opIndex: number): void;
21
+ export declare function opInsert(doc: MutDoc, to: To, markdown: string, expect?: Expect, opIndex?: number): {
21
22
  ids: string[];
22
23
  };
23
24
  export declare function opUpdate(doc: MutDoc, blockId: string, opIndex: number, markdown?: string, attrs?: Record<string, unknown>, expect?: Expect, trivia?: string, childIds?: Record<string, string>): {
24
25
  ids: string[];
25
26
  };
26
- export declare function opMove(doc: MutDoc, blockIds: string[], to: To, opIndex: number): {
27
+ export declare function opMove(doc: MutDoc, blockIds: string[], to: To, opIndex: number, expect?: Expect): {
27
28
  ids: string[];
28
29
  };
29
30
  export declare function healTopLevelSeams(children: MutBlock[], format: string): void;
@@ -38,4 +39,3 @@ export declare function opMerge(doc: MutDoc, blockIds: string[], opIndex: number
38
39
  ids: string[];
39
40
  mergedInto: string[];
40
41
  };
41
- export declare function checkParentChildrenHash(doc: MutDoc, parentId: string | null, expected: string, opIndex: number): void;
@@ -113,6 +113,20 @@ function checkContentHash(block, expect, opIndex) {
113
113
  });
114
114
  }
115
115
  }
116
+ // §1.2 `check_parent_children_hash`: when the expectation carries a
117
+ // `parent_children_hash`, the LIVE direct children of the sibling list it names
118
+ // (the document top level for `doc.children`) must hash to it. Which list is the
119
+ // op's business: update/split/remove/merge pass the block's current sibling list,
120
+ // insert/move the destination's (resolved as the op resolves `to`). Always after
121
+ // the content CAS and before the tree is touched, so a stale order never lands.
122
+ export function checkParentChildrenHash(siblings, expect, opIndex) {
123
+ if (expect?.parent_children_hash === undefined)
124
+ return;
125
+ const current = parentChildrenHash(siblings);
126
+ if (current !== expect.parent_children_hash) {
127
+ throw new MutationError("stale_expectation", "parent_children_hash mismatch", { op_index: opIndex, current: { parent_children_hash: current }, retriable: true });
128
+ }
129
+ }
116
130
  // Assign known ids onto a freshly-parsed subtree by positional key (parentKey +
117
131
  // '/' + index), the same convention flatten()/known-ids use. Keys absent from
118
132
  // the map leave the block's minted id in place (a genuinely new child).
@@ -140,8 +154,11 @@ function markSubtreeClean(b) {
140
154
  markSubtreeClean(c);
141
155
  }
142
156
  // ---- the six ops ------------------------------------------------------------
143
- export function opInsert(doc, to, markdown) {
157
+ export function opInsert(doc, to, markdown, expect, opIndex = 0) {
144
158
  const { siblings, index } = resolveTarget(doc, to);
159
+ // §1.2: the destination parent's order CAS — `siblings` IS the resolved parent
160
+ // list (a section scope or a `{ doc: true }` anchor both name the top level).
161
+ checkParentChildrenHash(siblings, expect, opIndex);
145
162
  let blocks = parseContentToBlocks(markdown, doc.format);
146
163
  // Inserting into a list: the content must land as list ITEMS, not a nested
147
164
  // list. Markdown `- x` parses to a `list` wrapping a `list_item`; unwrap it to
@@ -204,6 +221,8 @@ export function opUpdate(doc, blockId, opIndex, markdown, attrs, expect, trivia,
204
221
  // juggling post-op content hashes across the changeset.
205
222
  if (markdown !== undefined || expect?.content_hash !== undefined)
206
223
  checkContentHash(found.block, expect, opIndex);
224
+ // §1.2: the block's CURRENT parent, after the content CAS.
225
+ checkParentChildrenHash(found.siblings, expect, opIndex);
207
226
  // Blocks minted from MULTI-block content: the target takes the first parsed
208
227
  // block (keeping its id); the rest become fresh siblings right after it.
209
228
  const extra = [];
@@ -309,7 +328,7 @@ export function opUpdate(doc, blockId, opIndex, markdown, attrs, expect, trivia,
309
328
  }
310
329
  return { ids: [blockId, ...extra.map((b) => b.id)] };
311
330
  }
312
- export function opMove(doc, blockIds, to, opIndex) {
331
+ export function opMove(doc, blockIds, to, opIndex, expect) {
313
332
  if (blockIds.length === 0)
314
333
  throw new MutationError("not_contiguous", "move requires ≥1 block");
315
334
  // Locate all; assert contiguous siblings.
@@ -333,6 +352,12 @@ export function opMove(doc, blockIds, to, opIndex) {
333
352
  if (typeof to.parent === "string" && movedIds.has(to.parent)) {
334
353
  throw new MutationError("cycle_move", "target is inside the moved subtree", { op_index: opIndex });
335
354
  }
355
+ // §1.2: the DESTINATION parent's order CAS, resolved on the pre-removal tree
356
+ // (before the op mutates anything) and checked once for the whole run. When
357
+ // source and destination are one list this is the "same siblings reordered
358
+ // under me" guard: the hash covers the moved blocks' current positions too.
359
+ if (expect?.parent_children_hash !== undefined)
360
+ checkParentChildrenHash(resolveTarget(doc, to).siblings, expect, opIndex);
336
361
  // Extract (preserve order) then re-insert at the target.
337
362
  const moving = located.map((l) => l.block);
338
363
  for (const m of moving) {
@@ -395,8 +420,11 @@ export function opRemove(doc, blockIds, opIndex, expectPer) {
395
420
  hint: "the id is not a live block of the targeted document — it may have been removed by an earlier op in this changeset (removing a block removes its whole subtree), or never existed",
396
421
  });
397
422
  }
398
- if (expectPer?.[id])
399
- checkContentHash(found.block, expectPer[id], opIndex);
423
+ const e = expectPer?.[id];
424
+ if (e) {
425
+ checkContentHash(found.block, e, opIndex);
426
+ checkParentChildrenHash(found.siblings, e, opIndex); // §1.2: its current parent
427
+ }
400
428
  if (!hasAncestorIn(doc, found.siblings, set))
401
429
  tops.push(id);
402
430
  }
@@ -454,6 +482,7 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
454
482
  if (!found)
455
483
  throw new MutationError("block_missing", `block ${blockId} not found`, { op_index: opIndex, block: blockId });
456
484
  checkContentHash(found.block, expect, opIndex);
485
+ checkParentChildrenHash(found.siblings, expect, opIndex); // §1.2: its current parent
457
486
  const raw = found.block.raw;
458
487
  // `at` are UTF-8 byte offsets into the raw (spec/mutate §2.5) — the same
459
488
  // language-neutral unit every persisted span uses. A JavaScript string slices
@@ -509,8 +538,11 @@ export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {
509
538
  throw new MutationError("not_contiguous", "merge blocks must share a parent", { op_index: opIndex });
510
539
  if (located[i].block.type !== type)
511
540
  throw new MutationError("type_mismatch", "merge blocks must share a type", { op_index: opIndex });
512
- if (expectPer?.[blockIds[i]])
513
- checkContentHash(located[i].block, expectPer[blockIds[i]], opIndex);
541
+ const e = expectPer?.[blockIds[i]];
542
+ if (e) {
543
+ checkContentHash(located[i].block, e, opIndex);
544
+ checkParentChildrenHash(located[i].siblings, e, opIndex); // §1.2: its current parent
545
+ }
514
546
  }
515
547
  for (let i = 1; i < indices.length; i++)
516
548
  if (indices[i] !== indices[i - 1] + 1)
@@ -525,11 +557,4 @@ export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {
525
557
  siblings.splice(indices[i], 1);
526
558
  return { ids: [first.id], mergedInto };
527
559
  }
528
- export function checkParentChildrenHash(doc, parentId, expected, opIndex) {
529
- const list = parentId ? locate(doc, parentId)?.block.children ?? doc.children : doc.children;
530
- const current = parentChildrenHash(list);
531
- if (current !== expected) {
532
- throw new MutationError("stale_expectation", "parent_children_hash mismatch", { op_index: opIndex, current: { parent_children_hash: current }, retriable: true });
533
- }
534
- }
535
560
  //# sourceMappingURL=ops.js.map
@@ -20,4 +20,15 @@ export declare function makeStoreContext(store: Store, repoId: string, opts?: St
20
20
  /** Tag SQL rows with their target so the store context resolves them (used by the
21
21
  * tier-3 planner to hand produced rows back for residual evaluation). */
22
22
  export declare function tagRows(rows: Record<string, unknown>[], target: Target): unknown[];
23
+ /**
24
+ * A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
25
+ * values, 1.2) is rendered as `{ id, path }` — its id column and its document's
26
+ * path, the same two keys every hit carries — never the store row itself.
27
+ * Returns undefined for anything that is not a tagged row (run.ts leaves those
28
+ * alone).
29
+ */
30
+ export declare function rowRef(value: unknown): {
31
+ id: string;
32
+ path: string;
33
+ } | undefined;
23
34
  export { TARGET };
@@ -17,6 +17,8 @@ import { docsRead } from "../core/read/document.js";
17
17
  import { FilterInvalid } from "../search/cel/parser.js";
18
18
  import { sanitizeFtsQuery } from "../search/fts-query.js";
19
19
  import { cosineBytes } from "../core/vec.js";
20
+ import { ctxHashHex } from "../search/embeddings.js";
21
+ import { makeBlockContextResolver } from "../search/tasks.js";
20
22
  const TARGET = Symbol("oqx.target");
21
23
  const REPO_ROOT = Symbol("oqx.repoRoot");
22
24
  const PROP_SOURCE = Symbol("oqx.propSource");
@@ -470,6 +472,17 @@ export function makeStoreContext(store, repoId, opts = {}) {
470
472
  // blocks
471
473
  return !!one(`SELECT 1 FROM blocks_fts WHERE rowid = (SELECT rowid FROM blocks WHERE block_id = ?) AND blocks_fts MATCH ?`, r.block_id, match);
472
474
  };
475
+ // A block's vector is the cache row for its CURRENT (raw_hash, ctx_hash)
476
+ // (spec/surface §1.3, spec/search §3 — since search 1.1; before, the first row
477
+ // for the raw_hash whatever its context). The context resolver is built once
478
+ // per query (one scan of titles and sections), then a per-block chain walk.
479
+ let blockContexts;
480
+ const blockVector = (r, model) => {
481
+ blockContexts ??= makeBlockContextResolver(store, { repoId });
482
+ const path = typeof r.__path === "string" ? r.__path : String(scalar(`SELECT path FROM docs WHERE doc_id = ?`, r.doc_id) ?? "");
483
+ const ctx = blockContexts.ctx({ doc_id: String(r.doc_id), path, ordinal: Number(r.ordinal), type: String(r.type) });
484
+ return one(`SELECT vec FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?`, r.raw_hash, Buffer.from(ctxHashHex(ctx), "hex"), model);
485
+ };
473
486
  const semanticScore = (t, r, phrase) => {
474
487
  if (t === "nodes" || t === "edges")
475
488
  throw new FilterInvalid("semantic(...) is available on the docs and blocks targets", "OQX semantic");
@@ -478,7 +491,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
478
491
  throw new FilterInvalid(`semantic(${JSON.stringify(phrase)}) needs an embedding provider; none is configured for this query`, "OQX semantic");
479
492
  const row = t === "docs"
480
493
  ? one(`SELECT vec FROM doc_embeddings WHERE doc_id = ? AND model = ?`, r.doc_id, resolved.model)
481
- : one(`SELECT vec FROM embeddings WHERE content_hash = ? AND model = ? LIMIT 1`, r.raw_hash, resolved.model);
494
+ : blockVector(r, resolved.model);
482
495
  if (!row)
483
496
  return null;
484
497
  return cosineBytes(row.vec, resolved.vec);
@@ -597,5 +610,21 @@ function hex(b) {
597
610
  export function tagRows(rows, target) {
598
611
  return tagAll(rows, target);
599
612
  }
613
+ /**
614
+ * A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
615
+ * values, 1.2) is rendered as `{ id, path }` — its id column and its document's
616
+ * path, the same two keys every hit carries — never the store row itself.
617
+ * Returns undefined for anything that is not a tagged row (run.ts leaves those
618
+ * alone).
619
+ */
620
+ export function rowRef(value) {
621
+ const t = value?.[TARGET];
622
+ if (!t)
623
+ return undefined;
624
+ const r = value;
625
+ const id = t === "docs" ? r.doc_id : t === "blocks" ? r.block_id : t === "nodes" ? r.node_id : r.edge_id;
626
+ const path = t === "docs" ? r.path : r.__path;
627
+ return { id: String(id), path: String(path ?? "") };
628
+ }
600
629
  export { TARGET };
601
630
  //# sourceMappingURL=context.js.map