@omgbase/core 0.2.0 → 0.3.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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Pin the wall clock to `ts` (RFC 3339): `new Date()` and `Date.now()` return
3
+ * that instant until the returned restore function runs. Explicit-argument
4
+ * constructions (`new Date(ms)`, `new Date(iso)`) and the static parsers are
5
+ * untouched. Throws `RangeError` when `ts` does not parse.
6
+ */
7
+ export declare function pinClock(ts: string): () => void;
8
+ /** Run `body` under `pinClock(ts)` (a no-op when `ts` is undefined), restoring the clock afterwards. */
9
+ export declare function withClock<T>(ts: string | undefined, body: () => Promise<T>): Promise<T>;
@@ -0,0 +1,46 @@
1
+ // The process clock seam (spec/surface §7.1). Every commit the engine stamps
2
+ // reads "now" through `new Date()` / `Date.now()` (`ingest`, `apply`, the doc
3
+ // ops, `observe*`, checkpoints — none caches a `Date`), so pinning the global
4
+ // `Date` pins every timestamp a tool call produces. Two users: the surface
5
+ // fixtures (`corpus/surface/fixture.ts`, per `read` step) and `omg mcp` under
6
+ // `OMGBASE_SPEC_CLOCK` (for the whole process). Never used in production paths.
7
+ /**
8
+ * Pin the wall clock to `ts` (RFC 3339): `new Date()` and `Date.now()` return
9
+ * that instant until the returned restore function runs. Explicit-argument
10
+ * constructions (`new Date(ms)`, `new Date(iso)`) and the static parsers are
11
+ * untouched. Throws `RangeError` when `ts` does not parse.
12
+ */
13
+ export function pinClock(ts) {
14
+ const RealDate = Date;
15
+ const fixed = RealDate.parse(ts);
16
+ if (Number.isNaN(fixed))
17
+ throw new RangeError(`pinClock: not an RFC 3339 instant: ${JSON.stringify(ts)}`);
18
+ class PinnedDate extends RealDate {
19
+ constructor(...args) {
20
+ if (args.length === 0)
21
+ super(fixed);
22
+ else
23
+ super(...args);
24
+ }
25
+ static now() {
26
+ return fixed;
27
+ }
28
+ }
29
+ globalThis.Date = PinnedDate;
30
+ return () => {
31
+ globalThis.Date = RealDate;
32
+ };
33
+ }
34
+ /** Run `body` under `pinClock(ts)` (a no-op when `ts` is undefined), restoring the clock afterwards. */
35
+ export async function withClock(ts, body) {
36
+ if (ts === undefined)
37
+ return body();
38
+ const restore = pinClock(ts);
39
+ try {
40
+ return await body();
41
+ }
42
+ finally {
43
+ restore();
44
+ }
45
+ }
46
+ //# sourceMappingURL=clock.js.map
@@ -9,6 +9,12 @@ export interface TreeInputBlock {
9
9
  trivia: string;
10
10
  attrs: Record<string, unknown>;
11
11
  children: TreeInputBlock[];
12
+ /** The parsed block's source span, when the tree came from a parse — what
13
+ * `ownText` (spec/properties §3.2) uses to blank a container's children. */
14
+ span?: {
15
+ start: number;
16
+ end: number;
17
+ };
12
18
  }
13
19
  /** Assign fresh minted ids to a parsed block tree (Stage-1 re-mint path). */
14
20
  export declare function assignIds(blocks: RawBlock[]): TreeInputBlock[];
@@ -9,6 +9,7 @@ export function assignIds(blocks) {
9
9
  trivia: b.trivia,
10
10
  attrs: b.attrs,
11
11
  children: assignIds(b.children),
12
+ span: b.span,
12
13
  }));
13
14
  }
14
15
  /** Content-address raw text into blobs; returns hex hash. Dedups. */
@@ -2,7 +2,7 @@
2
2
  import { AdapterCapability } from "./adapter.js";
3
3
  import { parseTree, assertFullCoverage } from "../core/parse/tree.js";
4
4
  import { render } from "../core/parse/render.js";
5
- import { extractFromBlock, extractFromFrontmatter, maskCode } from "../graph/extract.js";
5
+ import { extractFromBlock, extractFromFrontmatter, ownText, scanText, INLINE_FIELD_BRACKETED, INLINE_FIELD_LINE } from "../graph/extract.js";
6
6
  export const MARKDOWN_FORMAT = "markdown";
7
7
  function toAdapterEdge(e) {
8
8
  return {
@@ -35,14 +35,15 @@ export const markdownAdapter = {
35
35
  extractEdges(blocks, metadata) {
36
36
  const edges = [];
37
37
  // Walk every block (recursing into list/blockquote/table nesting) and scan
38
- // its raw for links. srcBlock is the block's OWN assigned id — the caller
39
- // passes id-assigned blocks (blockId zipped on), mirroring projectNodes; a
40
- // block-grain edge without it is useless, so fall back to "" only for the
41
- // pre-identity callers. extractFromBlock returns [] for link-free blocks, so
42
- // no per-block gate is needed (matches the non-adapter fallback path).
38
+ // its OWN text for links (spec/graph §1: the raw with the children blanked,
39
+ // so a link in an item is the item's edge only). srcBlock is the block's
40
+ // OWN assigned id — the caller passes id-assigned blocks (blockId zipped
41
+ // on), mirroring projectNodes; a block-grain edge without it is useless, so
42
+ // fall back to "" only for the pre-identity callers. extractFromBlock
43
+ // returns [] for link-free blocks, so no per-block gate is needed.
43
44
  const walk = (list) => {
44
45
  for (const block of list) {
45
- for (const e of extractFromBlock(block.blockId ?? "", block.type, block.raw)) {
46
+ for (const e of extractFromBlock(block.blockId ?? "", block.type, ownText(block))) {
46
47
  edges.push(toAdapterEdge(e));
47
48
  }
48
49
  if (block.children.length > 0)
@@ -76,12 +77,13 @@ export const markdownAdapter = {
76
77
  // Anchor to the block's OWN assigned id (zipped on by ingest); fall back
77
78
  // to the parent's id, then "root", for pre-identity/parse-time callers.
78
79
  const id = b.blockId || parentId || "root";
79
- // Code is not prose (extraction_version x2): a code_fence block projects
80
- // no link/anchor/field nodes, and inline code spans are masked before the
81
- // regex scans below. Same rule as graph/extract.ts so the md:link /
82
- // md:wikilink nodes and the `references` edges agree. maskCode is
83
- // length-preserving, so spans still index the ORIGINAL raw.
84
- const scan = b.type === "code_fence" ? "" : maskCode(b.raw);
80
+ // The block's own text (spec/graph §1): children blanked, so a feature
81
+ // belongs to the innermost block only; then code masked — a code_fence
82
+ // block projects no link/anchor/field nodes, and inline code spans are
83
+ // blanked (extraction_version x2). Same rule as graph/extract.ts so the
84
+ // md:link / md:wikilink nodes and the `references` edges agree. Both
85
+ // masks are length-preserving, so spans still index the ORIGINAL raw.
86
+ const scan = scanText(b);
85
87
  // Links: [text](target)
86
88
  for (const m of scan.matchAll(/\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
87
89
  nodes.push({ kind: "md:link", name: m[1], value: m[2], blockId: id, ...span(m) });
@@ -100,20 +102,22 @@ export const markdownAdapter = {
100
102
  for (const m of scan.matchAll(anchorRe)) {
101
103
  nodes.push({ kind: "md:anchor", name: m[1], blockId: id, ...span(m) });
102
104
  }
103
- // Inline fields (dataview forms). Two shapes, so a multi-word value is
104
- // captured whole instead of truncated at the first space:
105
- // line form `key:: value` — key at line start, value to end of line
105
+ // Inline fields (dataview forms; the patterns are spec/properties §3.2's,
106
+ // shared with the properties scanner). Two shapes, so a multi-word value
107
+ // is captured whole instead of truncated at the first space:
108
+ // line form `key:: value` — key at line start (after blanks and, since
109
+ // 1.1, an optional list marker + task checkbox), value to
110
+ // end of line; span from the key to the trimmed value's end
106
111
  // bracketed `[key:: value]` / `(key:: value)` — value ends at closer
107
- // Bracketed matches first; the line form is anchored to a line start
108
- // (leading whitespace only), so a `[key:: …]`/`(key:: …)` sitting on its
109
- // own line is not double-counted (a bracket is not `[a-z]`).
110
- for (const m of scan.matchAll(/[[(]([a-z][a-z0-9_]*)::[ \t]*([^\]\n)]*?)[ \t]*[\])]/gi)) {
112
+ // Bracketed matches first; a `[key:: …]`/`(key:: …)` sitting on its own
113
+ // line is not double-counted (a bracket is not `[a-z]`).
114
+ for (const m of scan.matchAll(INLINE_FIELD_BRACKETED)) {
111
115
  nodes.push({ kind: "md:inline_field", name: m[1], value: m[2], blockId: id, ...span(m) });
112
116
  }
113
- for (const m of scan.matchAll(/^[ \t]*([a-z][a-z0-9_]*)::[ \t]*([^\n]*?)[ \t]*$/gim)) {
114
- const start = (m.index ?? 0) + (m[0].match(/^[ \t]*/)?.[0].length ?? 0);
117
+ for (const m of scan.matchAll(INLINE_FIELD_LINE)) {
118
+ const start = (m.index ?? 0) + m[1].length;
115
119
  const end = (m.index ?? 0) + m[0].replace(/[ \t]+$/, "").length;
116
- nodes.push({ kind: "md:inline_field", name: m[1], value: m[2], blockId: id, spanStart: start, spanEnd: end });
120
+ nodes.push({ kind: "md:inline_field", name: m[2], value: m[3], blockId: id, spanStart: start, spanEnd: end });
117
121
  }
118
122
  if (b.children.length > 0)
119
123
  walk(b.children, id);
@@ -11,6 +11,42 @@ export interface ExtractedEdge {
11
11
  anchor: string | null;
12
12
  provenance: Provenance;
13
13
  }
14
+ /** Bracketed form: `[key:: value]` / `(key:: value)` anywhere in prose; the value
15
+ * runs to the first closer (either bracket), trimmed of spaces and tabs. */
16
+ export declare const INLINE_FIELD_BRACKETED: RegExp;
17
+ /** Line form (1.1): a key at the start of a line — after blanks and optionally
18
+ * a list marker (`-`/`*`/`+`/`1.`/`1)`) and a task checkbox — with the value
19
+ * to the end of the line, trimmed. Group 1 is the prefix (so a span can start
20
+ * at the key), 2 the key as written, 3 the trimmed value. A bracketed field on
21
+ * its own line is not also a line-form match: `[`/`(` is not `[a-z]`. */
22
+ export declare const INLINE_FIELD_LINE: RegExp;
23
+ interface SpannedBlock {
24
+ raw: string;
25
+ span: {
26
+ start: number;
27
+ end: number;
28
+ };
29
+ children: SpannedBlock[];
30
+ }
31
+ /**
32
+ * A block's own text: its `raw` with the span of every DIRECT child blanked to
33
+ * spaces (newlines kept, like `maskCode`, so line structure survives). A child
34
+ * sits at `child.span.start - block.span.start` in the parent's raw (spans are
35
+ * source offsets; spec/format §1 inv. 3 puts a child inside its parent). That
36
+ * offset is trusted only when the parent's raw actually reads `child.raw`
37
+ * there; otherwise — callers that hand in blocks without real spans (a tree
38
+ * rebuilt from store rows carries `{0,0}`), which the invariant makes impossible
39
+ * for a parsed tree — the child is located as the first occurrence of its raw
40
+ * at or after the previous child's end, and skipped when not found. Length-
41
+ * preserving: offsets into the result are offsets into `raw`.
42
+ */
43
+ export declare function ownText(block: SpannedBlock): string;
44
+ /** What the scanners see for a block: nothing for a `code_fence`, else
45
+ * `maskCode(ownText(block))` — own text first (a child's fence is blanked
46
+ * either way), code second. Same length as `raw`. */
47
+ export declare function scanText(block: SpannedBlock & {
48
+ type: string;
49
+ }): string;
14
50
  /**
15
51
  * Resolve a `./foo` / `../bar` destination against the source document's
16
52
  * directory (`docDir` = path with the filename stripped, e.g. "a/b/" or "").
@@ -33,6 +69,10 @@ export declare function normalizeUri(uri: string): string;
33
69
  */
34
70
  export declare function maskCode(raw: string): string;
35
71
  /** Extract edges from a single block's raw markdown. Code is skipped: a
36
- * `code_fence` block yields nothing, and inline code spans are masked. */
72
+ * `code_fence` block yields nothing, and inline code spans are masked. Pass the
73
+ * block's own text (`ownText`) for a container so its children's features are
74
+ * not counted again here — the adapter does; a caller with only a stored raw
75
+ * (no children) passes it as is. */
37
76
  export declare function extractFromBlock(blockId: string, blockType: string, rawInput: string): ExtractedEdge[];
38
77
  export declare function extractFromFrontmatter(fm: Record<string, unknown>): ExtractedEdge[];
78
+ export {};
@@ -10,6 +10,12 @@
10
10
  // (and links_stale no longer reports them as dangling). There is no persisted
11
11
  // extraction version — an existing repo re-extracts a document the next time
12
12
  // that document is ingested (checkpoint/observe/apply), not via rebuild-index.
13
+ //
14
+ // spec/properties 1.1 + spec/graph 1.1: every scanner runs over the block's
15
+ // OWN text — `maskCode(ownText(block))`, the raw with each direct child's span
16
+ // blanked — so a feature inside a list item belongs to the item (the innermost
17
+ // block that contains it), never also to the list; and the inline-field line
18
+ // form admits a list marker and a task checkbox (`- key:: v`, `- [ ] due:: x`).
13
19
  export const EXTRACTION_VERSION = "x2";
14
20
  // --- link/inline scanners (operate on a block's raw markdown) ----------------
15
21
  const MD_LINK = /(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; // [t](dest) / ![alt](dest)
@@ -17,6 +23,55 @@ const WIKILINK = /(!?)\[\[([^\]]+)\]\]/g; // [[note]] / [[note#H]] / [[note^ref]
17
23
  const BARE_URL = /(?<![("[])\bhttps?:\/\/[^\s)>\]]+/g;
18
24
  const AUTOLINK = /<(https?:\/\/[^>]+)>/g;
19
25
  const INLINE_FIELD = /(?:^|\s)([a-z][a-z0-9_]*)::\s*(\[\[[^\]]+\]\]|\/[^\s]+|https?:\/\/[^\s]+)/gi;
26
+ // --- inline-field forms (spec/properties §3.2; shared with md:inline_field) ---
27
+ /** Bracketed form: `[key:: value]` / `(key:: value)` anywhere in prose; the value
28
+ * runs to the first closer (either bracket), trimmed of spaces and tabs. */
29
+ export const INLINE_FIELD_BRACKETED = /[[(]([a-z][a-z0-9_]*)::[ \t]*([^\]\n)]*?)[ \t]*[\])]/gi;
30
+ /** Line form (1.1): a key at the start of a line — after blanks and optionally
31
+ * a list marker (`-`/`*`/`+`/`1.`/`1)`) and a task checkbox — with the value
32
+ * to the end of the line, trimmed. Group 1 is the prefix (so a span can start
33
+ * at the key), 2 the key as written, 3 the trimmed value. A bracketed field on
34
+ * its own line is not also a line-form match: `[`/`(` is not `[a-z]`. */
35
+ export const INLINE_FIELD_LINE = /^([ \t]*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+(?:\[[ xX]\][ \t]+)?)?)([a-z][a-z0-9_]*)::[ \t]*([^\n]*?)[ \t]*$/gim;
36
+ /**
37
+ * A block's own text: its `raw` with the span of every DIRECT child blanked to
38
+ * spaces (newlines kept, like `maskCode`, so line structure survives). A child
39
+ * sits at `child.span.start - block.span.start` in the parent's raw (spans are
40
+ * source offsets; spec/format §1 inv. 3 puts a child inside its parent). That
41
+ * offset is trusted only when the parent's raw actually reads `child.raw`
42
+ * there; otherwise — callers that hand in blocks without real spans (a tree
43
+ * rebuilt from store rows carries `{0,0}`), which the invariant makes impossible
44
+ * for a parsed tree — the child is located as the first occurrence of its raw
45
+ * at or after the previous child's end, and skipped when not found. Length-
46
+ * preserving: offsets into the result are offsets into `raw`.
47
+ */
48
+ export function ownText(block) {
49
+ if (block.children.length === 0)
50
+ return block.raw;
51
+ const raw = block.raw;
52
+ let out = raw;
53
+ let cursor = 0;
54
+ for (const child of block.children) {
55
+ const len = child.raw.length;
56
+ if (len === 0)
57
+ continue;
58
+ let at = child.span.start - block.span.start;
59
+ if (!(at >= cursor && at + len <= raw.length && raw.startsWith(child.raw, at))) {
60
+ at = raw.indexOf(child.raw, cursor);
61
+ if (at < 0)
62
+ continue;
63
+ }
64
+ out = out.slice(0, at) + blank(child.raw) + out.slice(at + len);
65
+ cursor = at + len;
66
+ }
67
+ return out;
68
+ }
69
+ /** What the scanners see for a block: nothing for a `code_fence`, else
70
+ * `maskCode(ownText(block))` — own text first (a child's fence is blanked
71
+ * either way), code second. Same length as `raw`. */
72
+ export function scanText(block) {
73
+ return block.type === "code_fence" ? "" : maskCode(ownText(block));
74
+ }
20
75
  function splitAnchor(target) {
21
76
  const hashIdx = target.indexOf("#");
22
77
  const caretIdx = target.indexOf("^");
@@ -161,7 +216,10 @@ export function maskCode(raw) {
161
216
  return out;
162
217
  }
163
218
  /** Extract edges from a single block's raw markdown. Code is skipped: a
164
- * `code_fence` block yields nothing, and inline code spans are masked. */
219
+ * `code_fence` block yields nothing, and inline code spans are masked. Pass the
220
+ * block's own text (`ownText`) for a container so its children's features are
221
+ * not counted again here — the adapter does; a caller with only a stored raw
222
+ * (no children) passes it as is. */
165
223
  export function extractFromBlock(blockId, blockType, rawInput) {
166
224
  const edges = [];
167
225
  if (blockType === "code_fence")
@@ -25,8 +25,40 @@ export interface DiffEntry {
25
25
  after?: string;
26
26
  }
27
27
  export declare function diffBlocks(store: Store, docId: string, fromRev: string, toRev: string): DiffEntry[];
28
- /** Unified textual diff (line-based) between two revisions' rendered files. */
28
+ /** `diff_unified` (spec/surface §3): a unified diff of two revisions' rendered
29
+ * texts — each revision's live raws joined by `\n` — via {@link unifiedDiff}. */
29
30
  export declare function diffUnified(store: Store, docId: string, fromRev: string, toRev: string): string;
31
+ /** How each text becomes lines: `split("\n")` exactly — no trimming, no
32
+ * dropping of a trailing empty element (a raw ending in `\n` yields one) — with
33
+ * a single special case: the empty text has *no* lines (an empty file is zero
34
+ * lines, not one empty line), so a diff from/to nothing is `@@ -0,0 +1,n @@`. */
35
+ export declare function diffLines(text: string): string[];
36
+ /** One step of the edit script: `keep` consumes a line from both sides,
37
+ * `delete` one from the old text, `insert` one from the new text. */
38
+ export interface EditOp {
39
+ kind: "keep" | "delete" | "insert";
40
+ line: string;
41
+ }
42
+ /**
43
+ * Myers' O(ND) shortest edit script (forward, with a per-`d` trace for the
44
+ * backtrack). `V[k]` is the furthest x on diagonal `k = x - y` reachable with
45
+ * `d` edits. The canonical tie rule, identical in both engines: at each step
46
+ * take the diagonal from `k+1` (moving down — an insertion of `b[y]`) when
47
+ * `k == -d || (k != d && V[k-1] < V[k+1])`, else from `k-1` (moving right —
48
+ * a deletion of `a[x]`). On a tie (`V[k-1] == V[k+1]`) that is the deletion.
49
+ */
50
+ export declare function myersScript(a: string[], b: string[]): EditOp[];
51
+ /**
52
+ * The unified diff of two texts (spec/surface §3): hunks of `DIFF_CONTEXT` (3)
53
+ * lines of context; a change group extends to include the next change when
54
+ * fewer than `2 * DIFF_CONTEXT + 1` unchanged lines separate them (the two
55
+ * contexts touch or overlap). Each hunk is `@@ -a,b +c,d @@` (1-based start
56
+ * and length; a length of 1 is written as the start alone; a length of 0 as
57
+ * `a,0` with `a` the line before the insertion point, `0` at the very top)
58
+ * followed by its lines prefixed `-`, `+` or a space with nothing after the
59
+ * sign; hunks joined by `\n`; no file header; identical texts → `""`.
60
+ */
61
+ export declare function unifiedDiff(oldText: string, newText: string): string;
30
62
  export interface CommitDigest {
31
63
  commit: string;
32
64
  seq: number;
@@ -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
@@ -43,12 +43,13 @@ export { embeddingSettings, type EmbeddingSettings } from "./search/provider.js"
43
43
  export { createExternalProvider, type ExternalProvider } from "./search/external.js";
44
44
  export { hybridSearch, type HybridHit, type HybridInput } from "./search/rrf.js";
45
45
  export { vectorSearch, docVectorSearch, type VectorHit, type DocVectorHit } from "./search/vector.js";
46
- export { historyNode, diffUnified, changesSince, type NodeChange, type CommitDigest } from "./graph/history.js";
46
+ export { historyNode, diffUnified, unifiedDiff, changesSince, type NodeChange, type CommitDigest } from "./graph/history.js";
47
47
  export { docLinks, type LinksResult, type LinkEdge, type LinksOptions } from "./graph/links.js";
48
48
  export { FilterInvalid } from "./search/cel/parser.js";
49
49
  export { EngineError, type ErrorCode } from "./mcp/errors.js";
50
50
  export { MutationError } from "./mutate/tree.js";
51
51
  export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter, type IdMinter, type IdPrefix } from "./core/ids.js";
52
+ export { pinClock, withClock } from "./core/clock.js";
52
53
  export { sha256 } from "./core/hash.js";
53
54
  export { ingestFile, type IngestResult } from "./core/ingest.js";
54
55
  export { apply, type ApplyRequest, type ApplyResult, type Op } from "./mutate/apply.js";
package/dist/src/index.js CHANGED
@@ -48,12 +48,14 @@ export { embeddingSettings } from "./search/provider.js";
48
48
  export { createExternalProvider } from "./search/external.js";
49
49
  export { hybridSearch } from "./search/rrf.js";
50
50
  export { vectorSearch, docVectorSearch } from "./search/vector.js";
51
- export { historyNode, diffUnified, changesSince } from "./graph/history.js";
51
+ export { historyNode, diffUnified, unifiedDiff, changesSince } from "./graph/history.js";
52
52
  export { docLinks } from "./graph/links.js";
53
53
  export { FilterInvalid } from "./search/cel/parser.js";
54
54
  export { EngineError } from "./mcp/errors.js";
55
55
  export { MutationError } from "./mutate/tree.js";
56
56
  export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter } from "./core/ids.js";
57
+ // The clock seam (spec/surface §7.1): `omg mcp` pins the process clock under OMGBASE_SPEC_CLOCK.
58
+ export { pinClock, withClock } from "./core/clock.js";
57
59
  export { sha256 } from "./core/hash.js";
58
60
  export { ingestFile } from "./core/ingest.js";
59
61
  // Mutation surface consumed by CLI write commands (11 §5.6).
@@ -50,9 +50,9 @@ const expectSchema = z.object({
50
50
  parent_children_hash: z.string().optional(),
51
51
  });
52
52
  const opSchema = z.discriminatedUnion("op", [
53
- z.object({ op: z.literal("insert"), doc: z.string().optional(), to: toSchema, markdown: z.string() }),
53
+ z.object({ op: z.literal("insert"), doc: z.string().optional(), to: toSchema, markdown: z.string(), expect: expectSchema.optional() }),
54
54
  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 }),
55
+ z.object({ op: z.literal("move"), blocks: z.array(z.string()), to: toSchema, expect: expectSchema.optional() }),
56
56
  z.object({ op: z.literal("remove"), blocks: z.array(z.string()), expect: z.record(z.string(), expectSchema).optional() }),
57
57
  z.object({ op: z.literal("split"), block: z.string(), at: z.array(z.number().int()), expect: expectSchema.optional() }),
58
58
  z.object({ op: z.literal("merge"), blocks: z.array(z.string()), separator: z.string().optional(), expect: z.record(z.string(), expectSchema).optional() }),
@@ -939,7 +939,7 @@ export function buildServer(ctx) {
939
939
  }
940
940
  });
941
941
  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.",
942
+ 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
943
  inputSchema: { doc: z.string(), from_rev: z.string().optional(), to_rev: z.string().optional(), ...REPO_ARG },
944
944
  }, async (args) => {
945
945
  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
@@ -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);
@@ -50,7 +50,8 @@ export function hybridSearch(store, input) {
50
50
  const vecRanks = new Map();
51
51
  const cosineByBlock = new Map();
52
52
  if (input.vector) {
53
- // First occurrence wins, exactly as for FTS (spec/search §4).
53
+ // First occurrence wins, exactly as for FTS (spec/search §4). Since search
54
+ // 1.1 vectorSearch yields one candidate per block, so this is a no-op guard.
54
55
  vectorSearch(store, input.repoId, input.vector.model, input.vector.vec, { limit: 200 }).forEach((h, i) => {
55
56
  if (vecRanks.has(h.blockId))
56
57
  return;
@@ -1,5 +1,33 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type EmbedTask, type DocEmbedTask } from "./embeddings.js";
3
+ /** The columns a block's current context (spec/search §2.2) is a function of. */
4
+ export interface BlockContextInput {
5
+ doc_id: string;
6
+ path: string;
7
+ ordinal: number;
8
+ type: string;
9
+ }
10
+ /**
11
+ * Resolves a block's *current* embedding context (spec/search §2.2) — the one
12
+ * rule every reader of the `embeddings` cache keys with: the drain (tasks), the
13
+ * pooled document vector, `vector_search` (§3) and `semantic()` (spec/surface
14
+ * §1.3). Built once per repo (or per doc) and reused across blocks: one scan of
15
+ * the frontmatter titles and one of the sections, then a per-block walk of the
16
+ * doc's sections for the heading chain.
17
+ */
18
+ export interface BlockContextResolver {
19
+ ctx(block: BlockContextInput): string;
20
+ }
21
+ /**
22
+ * Load the title/section facts the context prefix depends on. `scope` narrows
23
+ * the load to one repo (every doc — the drain and vector search) or one doc
24
+ * (a single block's lookup).
25
+ */
26
+ export declare function makeBlockContextResolver(store: Store, scope: {
27
+ repoId: string;
28
+ } | {
29
+ docId: string;
30
+ }): BlockContextResolver;
3
31
  /** Build EmbedTask[] for every embeddable live block in the repo. */
4
32
  export declare function buildEmbedTasks(store: Store, repoId: string): EmbedTask[];
5
33
  /**
@@ -7,31 +7,31 @@ function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
7
7
  return t;
8
8
  return firstHeadingByDoc.get(docId) ?? path;
9
9
  }
10
- /** Build EmbedTask[] for every embeddable live block in the repo. */
11
- export function buildEmbedTasks(store, repoId) {
12
- const blocks = store.db
13
- .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
14
- b.ordinal, b.type, b.text, b.raw_hash
15
- FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
16
- WHERE b.repo_id = ? AND b.deleted_commit IS NULL
17
- ORDER BY d.path, b.ordinal`)
18
- .all(repoId);
19
- // Frontmatter title per doc (scalar), for the embed context prefix. One scan
20
- // of the properties table instead of a per-block metadata parse.
10
+ /**
11
+ * Load the title/section facts the context prefix depends on. `scope` narrows
12
+ * the load to one repo (every doc — the drain and vector search) or one doc
13
+ * (a single block's lookup).
14
+ */
15
+ export function makeBlockContextResolver(store, scope) {
16
+ const byRepo = "repoId" in scope;
17
+ // Frontmatter title per doc (scalar string), for the embed context prefix.
18
+ // One scan of the properties table instead of a per-block metadata parse.
21
19
  const titleByDoc = new Map();
22
20
  for (const r of store.db.prepare(`SELECT p.doc_id AS doc_id, p.val_text AS title FROM properties p
23
- WHERE p.repo_id = ? AND p.source = 'frontmatter' AND p.key = 'title'
24
- AND p.card = 'scalar' AND p.type = 'string' AND p.deleted_commit IS NULL`).all(repoId)) {
21
+ WHERE ${byRepo ? "p.repo_id = ?" : "p.doc_id = ?"} AND p.source = 'frontmatter' AND p.key = 'title'
22
+ AND p.card = 'scalar' AND p.type = 'string' AND p.deleted_commit IS NULL`).all(byRepo ? scope.repoId : scope.docId)) {
25
23
  if (r.title)
26
24
  titleByDoc.set(r.doc_id, r.title);
27
25
  }
28
- // All sections in the repo, with their heading text, for chain lookup.
26
+ // The sections in scope with their heading text, for chain lookup. Ordered by
27
+ // first_ordinal so the first per doc is the title fallback (§2.2).
29
28
  const sections = store.db
30
29
  .prepare(`SELECT s.doc_id AS doc_id, hb.text AS heading_text, s.level AS level,
31
30
  s.first_ordinal AS first_ordinal, s.last_ordinal AS last_ordinal
32
31
  FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
33
- WHERE hb.repo_id = ?`)
34
- .all(repoId);
32
+ WHERE ${byRepo ? "hb.repo_id = ?" : "s.doc_id = ?"}
33
+ ORDER BY s.doc_id, s.first_ordinal, s.rowid`)
34
+ .all(byRepo ? scope.repoId : scope.docId);
35
35
  const sectionsByDoc = new Map();
36
36
  for (const s of sections) {
37
37
  (sectionsByDoc.get(s.doc_id) ?? sectionsByDoc.set(s.doc_id, []).get(s.doc_id)).push(s);
@@ -52,17 +52,30 @@ export function buildEmbedTasks(store, repoId) {
52
52
  .sort((a, b) => a.level - b.level)
53
53
  .map((s) => s.heading_text);
54
54
  };
55
- const tasks = [];
56
- for (const b of blocks) {
57
- if (!shouldEmbed(b.text))
58
- continue;
59
- const ctx = contextPrefix({
55
+ return {
56
+ ctx: (b) => contextPrefix({
60
57
  docTitle: docTitle(titleByDoc, firstHeadingByDoc, b.doc_id, b.path),
61
58
  path: b.path,
62
59
  headingChain: headingChain(b.doc_id, b.ordinal),
63
60
  blockType: b.type,
64
- });
65
- tasks.push({ blockId: b.block_id, contentHashHex: b.raw_hash.toString("hex"), ctx, text: b.text });
61
+ }),
62
+ };
63
+ }
64
+ /** Build EmbedTask[] for every embeddable live block in the repo. */
65
+ export function buildEmbedTasks(store, repoId) {
66
+ const blocks = store.db
67
+ .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
68
+ b.ordinal, b.type, b.text, b.raw_hash
69
+ FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
70
+ WHERE b.repo_id = ? AND b.deleted_commit IS NULL
71
+ ORDER BY d.path, b.ordinal`)
72
+ .all(repoId);
73
+ const contexts = makeBlockContextResolver(store, { repoId });
74
+ const tasks = [];
75
+ for (const b of blocks) {
76
+ if (!shouldEmbed(b.text))
77
+ continue;
78
+ tasks.push({ blockId: b.block_id, contentHashHex: b.raw_hash.toString("hex"), ctx: contexts.ctx(b), text: b.text });
66
79
  }
67
80
  return tasks;
68
81
  }
@@ -1,3 +1,5 @@
1
+ import { ctxHashHex } from "./embeddings.js";
2
+ import { buildEmbedTasks } from "./tasks.js";
1
3
  function cosine(a, b) {
2
4
  let dot = 0, na = 0, nb = 0;
3
5
  const n = Math.min(a.length, b.length);
@@ -12,25 +14,24 @@ function cosine(a, b) {
12
14
  }
13
15
  export function vectorSearch(store, repoId, model, queryVec, opts = {}) {
14
16
  const limit = opts.limit ?? 50;
15
- const rows = store.db
16
- .prepare(`SELECT b.block_id AS blockId, b.doc_id AS docId, d.path AS path, e.vec AS vec
17
- FROM embeddings e
18
- JOIN blocks b ON b.raw_hash = e.content_hash AND b.deleted_commit IS NULL
19
- JOIN docs d ON d.doc_id = b.doc_id
20
- WHERE b.repo_id = ? AND e.model = ?`)
21
- .all(repoId, model);
22
- // One hit per live block (spec/search §3). Several cache rows can join to the
23
- // same block — a stale ctx row survives a heading rename next to the fresh one
24
- // — and the block keeps its best cosine, not one hit per row.
25
- const best = new Map();
26
- for (const r of rows) {
27
- const v = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
28
- const c = cosine(queryVec, v);
29
- const prev = best.get(r.blockId);
30
- if (!prev || c > prev.cosine)
31
- best.set(r.blockId, { blockId: r.blockId, docId: r.docId, path: r.path, cosine: c });
17
+ // The candidates are exactly the drain's tasks: every live embeddable block
18
+ // with its current (content_hash, ctx). Reusing buildEmbedTasks keeps the
19
+ // context rule in one place; its cost is one walk of the repo's blocks and
20
+ // sections per query (fine at v1 scale, see the ceiling above).
21
+ const tasks = buildEmbedTasks(store, repoId);
22
+ const docPath = store.db.prepare("SELECT doc_id AS docId, path FROM blocks JOIN docs USING (doc_id) WHERE block_id = ?");
23
+ const vecFor = store.db.prepare("SELECT vec FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?");
24
+ const scored = [];
25
+ for (const t of tasks) {
26
+ const row = vecFor.get(Buffer.from(t.contentHashHex, "hex"), Buffer.from(ctxHashHex(t.ctx), "hex"), model);
27
+ if (!row)
28
+ continue; // stale (§2.3): no vector until the next drain
29
+ const meta = docPath.get(t.blockId);
30
+ if (!meta)
31
+ continue;
32
+ const v = new Float32Array(row.vec.buffer, row.vec.byteOffset, row.vec.byteLength / 4);
33
+ scored.push({ blockId: t.blockId, docId: meta.docId, path: meta.path, cosine: cosine(queryVec, v) });
32
34
  }
33
- const scored = [...best.values()];
34
35
  scored.sort((a, b) => b.cosine - a.cosine || (a.blockId < b.blockId ? -1 : 1));
35
36
  return scored.slice(0, limit);
36
37
  }
@@ -87,7 +87,14 @@ export interface DiskDrift {
87
87
  export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string, opts?: {
88
88
  fs?: SyncFs;
89
89
  }): DiskDrift;
90
- /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
90
+ /**
91
+ * Rebuild `file_stats` from scratch (spec/sync §4.3, since 1.1): delete the repo's rows, then
92
+ * walk the tree and record a row only for a file whose bytes' hash equals its live doc's
93
+ * `file_hash` — the cache is allowed to say "known" only about bytes the store already holds.
94
+ * A file with no live doc, or whose bytes differ from what was ingested, gets no row, so the
95
+ * next sweep still treats it as a candidate and drift still reports it (a cache rebuild must
96
+ * never change what the next sweep does). Returns the number of files walked, recorded or not.
97
+ */
91
98
  export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string, opts?: {
92
99
  fs?: SyncFs;
93
100
  }): number;
@@ -147,16 +147,36 @@ export function detectDiskDrift(store, repoId, rootPath, opts = {}) {
147
147
  deleted += 1;
148
148
  return { changed, deleted, untracked };
149
149
  }
150
- /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
150
+ /**
151
+ * Rebuild `file_stats` from scratch (spec/sync §4.3, since 1.1): delete the repo's rows, then
152
+ * walk the tree and record a row only for a file whose bytes' hash equals its live doc's
153
+ * `file_hash` — the cache is allowed to say "known" only about bytes the store already holds.
154
+ * A file with no live doc, or whose bytes differ from what was ingested, gets no row, so the
155
+ * next sweep still treats it as a candidate and drift still reports it (a cache rebuild must
156
+ * never change what the next sweep does). Returns the number of files walked, recorded or not.
157
+ */
151
158
  export function rebuildFileStats(store, repoId, rootPath, opts = {}) {
152
159
  const fs = opts.fs ?? nodeFs;
153
160
  store.db.prepare("DELETE FROM file_stats WHERE repo_id = ?").run(repoId);
161
+ // The live docs' durable convergence signal, keyed by path (one query).
162
+ const live = new Map();
163
+ for (const row of store.db
164
+ .prepare("SELECT path, file_hash FROM docs WHERE repo_id = ? AND deleted_commit IS NULL AND file_hash IS NOT NULL")
165
+ .all(repoId)) {
166
+ live.set(row.path, row.file_hash);
167
+ }
154
168
  const paths = fs.walk(rootPath);
155
169
  for (const path of paths) {
170
+ const want = live.get(path);
171
+ if (!want)
172
+ continue; // no live doc: nothing the cache may vouch for
156
173
  const bytes = fs.read(rootPath, path);
157
174
  if (bytes === null)
158
- continue;
159
- recordStat(store, repoId, path, fs.stat(rootPath, path), sha256(bytes));
175
+ continue; // vanished between the walk and the read
176
+ const hash = sha256(bytes);
177
+ if (!hash.equals(want))
178
+ continue; // a pending edit: leave it visible
179
+ recordStat(store, repoId, path, fs.stat(rootPath, path), hash);
160
180
  }
161
181
  return paths.length;
162
182
  }
@@ -105,7 +105,7 @@ function assignFromMap(blocks, assignment) {
105
105
  const walk = (list, parentKey) => list.map((b, index) => {
106
106
  const key = `${parentKey ?? ""}/${index}`;
107
107
  const blockId = assignment.get(key) ?? mintId("b");
108
- return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key) };
108
+ return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key), span: b.span };
109
109
  });
110
110
  return walk(blocks, null);
111
111
  }
@@ -260,11 +260,13 @@ function resolveAdapterEdge(db, repoId, srcDoc, e, docDir) {
260
260
  provenance: e.provenance,
261
261
  };
262
262
  }
263
- // Collect RawBlock-shaped objects from the id-assigned tree for adapter edge extraction.
263
+ // Collect RawBlock-shaped objects from the id-assigned tree for adapter edge
264
+ // extraction. The parsed span rides along so the adapter can blank a
265
+ // container's children (spec/graph §1 own text).
264
266
  function collectRawBlocks(blocks) {
265
267
  return blocks.map((b) => ({
266
268
  type: b.type,
267
- span: { start: 0, end: 0 },
269
+ span: b.span ?? { start: 0, end: 0 },
268
270
  raw: b.raw,
269
271
  text: "",
270
272
  attrs: b.attrs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Open Markdown Graph Base — embedded engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@modelcontextprotocol/sdk": "^1.30.0",
58
- "@omgbase/oqx": "^0.12.0",
58
+ "@omgbase/oqx": "^0.13.0",
59
59
  "better-sqlite3": "^13.0.3",
60
60
  "mdast-util-from-markdown": "^2.0.2",
61
61
  "mdast-util-frontmatter": "^2.0.1",