@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.
- package/dist/src/core/clock.d.ts +9 -0
- package/dist/src/core/clock.js +46 -0
- package/dist/src/core/store/writers.d.ts +6 -0
- package/dist/src/core/store/writers.js +1 -0
- package/dist/src/format/markdown.js +27 -23
- package/dist/src/graph/extract.d.ts +41 -1
- package/dist/src/graph/extract.js +59 -1
- package/dist/src/graph/history.d.ts +33 -1
- package/dist/src/graph/history.js +141 -18
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +3 -1
- package/dist/src/mcp/server.js +3 -3
- package/dist/src/mutate/apply.d.ts +2 -0
- package/dist/src/mutate/apply.js +8 -5
- package/dist/src/mutate/known-ids.js +2 -2
- package/dist/src/mutate/ops.d.ts +3 -3
- package/dist/src/mutate/ops.js +38 -13
- package/dist/src/oqx-js/context.js +14 -1
- package/dist/src/search/rrf.js +2 -1
- package/dist/src/search/tasks.d.ts +28 -0
- package/dist/src/search/tasks.js +36 -23
- package/dist/src/search/vector.js +19 -18
- package/dist/src/sync/freshness.d.ts +8 -1
- package/dist/src/sync/freshness.js +23 -3
- package/dist/src/sync/reconciling-ingest.js +5 -3
- package/package.json +2 -2
|
@@ -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[];
|
|
@@ -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,
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
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
|
|
104
|
-
//
|
|
105
|
-
//
|
|
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;
|
|
108
|
-
//
|
|
109
|
-
|
|
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(
|
|
114
|
-
const start = (m.index ?? 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[
|
|
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) / 
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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).
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -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: "
|
|
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[];
|
package/dist/src/mutate/apply.js
CHANGED
|
@@ -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,
|
package/dist/src/mutate/ops.d.ts
CHANGED
|
@@ -17,13 +17,14 @@ export interface Expect {
|
|
|
17
17
|
content_hash?: string;
|
|
18
18
|
parent_children_hash?: string;
|
|
19
19
|
}
|
|
20
|
-
export declare function
|
|
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;
|
package/dist/src/mutate/ops.js
CHANGED
|
@@ -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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
:
|
|
494
|
+
: blockVector(r, resolved.model);
|
|
482
495
|
if (!row)
|
|
483
496
|
return null;
|
|
484
497
|
return cosineBytes(row.vec, resolved.vec);
|
package/dist/src/search/rrf.js
CHANGED
|
@@ -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
|
/**
|
package/dist/src/search/tasks.js
CHANGED
|
@@ -7,31 +7,31 @@ function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
|
|
|
7
7
|
return t;
|
|
8
8
|
return firstHeadingByDoc.get(docId) ?? path;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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",
|