@omgbase/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/src/core/clock.d.ts +9 -0
  2. package/dist/src/core/clock.js +46 -0
  3. package/dist/src/core/ingest.js +15 -12
  4. package/dist/src/core/read/document.js +6 -1
  5. package/dist/src/core/read/nodes.js +5 -1
  6. package/dist/src/core/store/fts.d.ts +38 -7
  7. package/dist/src/core/store/fts.js +60 -9
  8. package/dist/src/core/store/rebuild.js +5 -4
  9. package/dist/src/core/store/writers.d.ts +6 -0
  10. package/dist/src/core/store/writers.js +1 -0
  11. package/dist/src/format/markdown.js +27 -23
  12. package/dist/src/graph/extract.d.ts +41 -1
  13. package/dist/src/graph/extract.js +59 -1
  14. package/dist/src/graph/history.d.ts +33 -1
  15. package/dist/src/graph/history.js +141 -18
  16. package/dist/src/index.d.ts +4 -2
  17. package/dist/src/index.js +4 -1
  18. package/dist/src/mcp/server.js +23 -12
  19. package/dist/src/mutate/apply.d.ts +2 -0
  20. package/dist/src/mutate/apply.js +8 -5
  21. package/dist/src/mutate/known-ids.js +2 -2
  22. package/dist/src/mutate/ops.d.ts +3 -3
  23. package/dist/src/mutate/ops.js +38 -13
  24. package/dist/src/oqx-js/context.d.ts +11 -0
  25. package/dist/src/oqx-js/context.js +30 -1
  26. package/dist/src/oqx-js/planner.d.ts +20 -1
  27. package/dist/src/oqx-js/planner.js +95 -2
  28. package/dist/src/oqx-js/run.js +27 -2
  29. package/dist/src/oqx-js/sql/translate.d.ts +71 -1
  30. package/dist/src/oqx-js/sql/translate.js +165 -70
  31. package/dist/src/search/rrf.js +2 -1
  32. package/dist/src/search/tasks.d.ts +34 -1
  33. package/dist/src/search/tasks.js +42 -23
  34. package/dist/src/search/vector.js +19 -18
  35. package/dist/src/sync/external-source.js +20 -7
  36. package/dist/src/sync/freshness.d.ts +8 -1
  37. package/dist/src/sync/freshness.js +23 -3
  38. package/dist/src/sync/plugin.d.ts +30 -5
  39. package/dist/src/sync/plugin.js +13 -1
  40. package/dist/src/sync/reconciling-ingest.js +5 -3
  41. package/dist/src/sync/watcher.d.ts +2 -0
  42. package/dist/src/sync/watcher.js +10 -2
  43. package/package.json +2 -2
@@ -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
@@ -6,7 +6,7 @@ import { assignIds, writeBlockTree, putBlob, newCommit, writeRevision } from "./
6
6
  import { sha256, childQuoteDepth, visibleText } from "./hash.js";
7
7
  import { mintId } from "./ids.js";
8
8
  import { keyBetween } from "./order-key.js";
9
- import { ftsDeleteDoc, ftsIndexDoc } from "./store/fts.js";
9
+ import { ftsBeforeEvictRows, ftsDeleteDoc, ftsIndexDoc } from "./store/fts.js";
10
10
  import { rebuildSections } from "./store/sections.js";
11
11
  import { writeDocNodes, projectSectionNodes, toByteSpans } from "./store/nodes.js";
12
12
  import { flattenFrontmatter, flattenComputed, writeDocProperties } from "./store/properties.js";
@@ -288,25 +288,28 @@ export function ingestFile(store, repoId, path, content, opts = {}) {
288
288
  });
289
289
  }
290
290
  // Remove another document's `blocks` row for each id in `ids` (a cross-doc move
291
- // or a resurrection re-homes the id into `docId`). A live foreign row still has
292
- // an FTS entry (external-content index: delete with the original text); a
293
- // tombstoned one does not. The source document, if it commits later in the same
291
+ // or a resurrection re-homes the id into `docId`). A live foreign LEAF row still
292
+ // has an FTS entry (external-content index: delete with the original text); a
293
+ // tombstoned row or a container does not (spec/search §1.1) — ftsBeforeEvictRows
294
+ // decides leaf-ness over the table before any row goes and re-indexes a parent
295
+ // left childless. The source document, if it commits later in the same
294
296
  // checkpoint, rewrites its rows wholesale and never lists a carried id as
295
297
  // deleted, so nothing there depends on the evicted row.
296
298
  function evictForeignBlockRows(db, docId, ids) {
297
- const sel = db.prepare("SELECT rowid, text, deleted_commit FROM blocks WHERE block_id = ? AND doc_id != ?");
298
- const ftsDel = db.prepare("INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?, ?)");
299
+ const sel = db.prepare("SELECT rowid, block_id, doc_id, parent_block, text, deleted_commit FROM blocks WHERE block_id = ? AND doc_id != ?");
299
300
  const del = db.prepare("DELETE FROM blocks WHERE block_id = ? AND doc_id != ?");
300
301
  const unpool = db.prepare("DELETE FROM resurrection_pool WHERE block_id = ?");
302
+ const rows = [];
301
303
  for (const id of ids) {
302
304
  const row = sel.get(id, docId);
303
- if (row) {
304
- if (row.deleted_commit === null)
305
- ftsDel.run(row.rowid, row.text);
306
- del.run(id, docId);
307
- }
308
- unpool.run(id);
305
+ if (row)
306
+ rows.push(row);
309
307
  }
308
+ ftsBeforeEvictRows(db, rows);
309
+ for (const r of rows)
310
+ del.run(r.block_id, docId);
311
+ for (const id of ids)
312
+ unpool.run(id);
310
313
  }
311
314
  // Zip assigned block ids onto the parsed RawBlocks for node projection. The
312
315
  // parsed `rest` and `assigned` trees are the same blocks in the same order and
@@ -116,8 +116,13 @@ export function docsReadMany(store, repoId, refs, opts = {}) {
116
116
  errors.push({ ref, error: "doc_not_found" });
117
117
  continue;
118
118
  }
119
+ // The budget is checked before the SECOND item onward (spec/surface §2, 1.2):
120
+ // the first item is always emitted even when it alone exceeds the budget —
121
+ // the same "at least one row" contract docs_list/docs_tree keep, so a
122
+ // budgeted client always makes progress. `truncated` stays honest: it is
123
+ // set only when the budget stopped an item that would otherwise have shipped.
119
124
  const cost = Math.ceil(JSON.stringify(read).length / 4);
120
- if (tokens + cost > budget) {
125
+ if (items.length > 0 && tokens + cost > budget) {
121
126
  truncated = true;
122
127
  break;
123
128
  }
@@ -89,8 +89,12 @@ export function nodesGetMany(store, docId, blockIds, opts = {}) {
89
89
  continue;
90
90
  }
91
91
  const projected = project(store, node, resolution, false);
92
+ // At least one resolved node is always returned (spec/surface §2, 1.2): the
93
+ // budget is checked before the second node onward, as docsReadMany does, so
94
+ // a first node that alone exceeds it still ships and `truncated` only says
95
+ // that more remained.
92
96
  const cost = Math.ceil(JSON.stringify(projected).length / 4);
93
- if (tokens + cost > budget) {
97
+ if (nodes.length > 0 && tokens + cost > budget) {
94
98
  truncated = true;
95
99
  break;
96
100
  }
@@ -1,10 +1,41 @@
1
1
  import type { Database } from "better-sqlite3";
2
- /** Remove a document's live block rows from the FTS index (call before deleting
3
- * or tombstoning blocks). Only live blocks are indexed (see ftsIndexDoc), so the
4
- * delete must target the SAME set: an already-tombstoned block has no FTS row, and
5
- * re-issuing a 'delete' for it corrupts the external-content index. Keeping this
6
- * symmetric with ftsIndexDoc (deleted_commit IS NULL) makes a tombstone followed by
7
- * a later re-ingest of the same doc id (observed delete → recreate) idempotent. */
2
+ /**
3
+ * SQL predicate: the `blocks` row aliased `b` is a live leaf. `c.doc_id =
4
+ * b.doc_id` lets the NOT EXISTS use `idx_blocks_doc` (block ids are unique, so
5
+ * it never changes the answer).
6
+ */
7
+ export declare const LIVE_LEAF_SQL = "b.deleted_commit IS NULL AND NOT EXISTS (SELECT 1 FROM blocks c WHERE c.doc_id = b.doc_id AND c.parent_block = b.block_id AND c.deleted_commit IS NULL)";
8
+ /** Remove a document's live leaf rows from the FTS index (call before deleting
9
+ * or tombstoning blocks). Only live leaves are indexed (see ftsIndexDoc), so the
10
+ * delete must target the SAME set: an already-tombstoned block or a container has
11
+ * no FTS row, and re-issuing a 'delete' for it corrupts the external-content
12
+ * index's statistics. Keeping this symmetric with ftsIndexDoc makes a tombstone
13
+ * followed by a later re-ingest of the same doc id (observed delete → recreate)
14
+ * idempotent. */
8
15
  export declare function ftsDeleteDoc(db: Database, docId: string): void;
9
- /** Index a document's current block rows (call after inserting blocks). */
16
+ /** Index a document's current live leaf rows (call after inserting blocks). */
10
17
  export declare function ftsIndexDoc(db: Database, docId: string): void;
18
+ /**
19
+ * Rebuild the whole index: `'delete-all'`, then every live leaf of every live
20
+ * document. (FTS5's own `'rebuild'` command reads the content table wholesale,
21
+ * containers included, so it is not used.)
22
+ */
23
+ export declare function ftsRebuild(db: Database): void;
24
+ /**
25
+ * Remove single `blocks` rows (by id) that are about to be deleted out of
26
+ * another document's row set — the cross-document eviction of spec/store §5.4
27
+ * step 8 — keeping the index equal to the table's live leaves. Every row's
28
+ * leaf-ness is decided over the table BEFORE any of them goes (so a list moving
29
+ * with its items is order-independent: the items are leaves and lose their
30
+ * entries, the list never had one), and a live parent that keeps its row but
31
+ * loses its last live child becomes a leaf and gains an entry. The caller owns
32
+ * the DELETE of the rows, which must follow this call.
33
+ */
34
+ export declare function ftsBeforeEvictRows(db: Database, rows: {
35
+ rowid: number;
36
+ block_id: string;
37
+ doc_id: string;
38
+ parent_block: string | null;
39
+ text: string;
40
+ deleted_commit: string | null;
41
+ }[]): void;
@@ -1,22 +1,73 @@
1
- /** Remove a document's live block rows from the FTS index (call before deleting
2
- * or tombstoning blocks). Only live blocks are indexed (see ftsIndexDoc), so the
3
- * delete must target the SAME set: an already-tombstoned block has no FTS row, and
4
- * re-issuing a 'delete' for it corrupts the external-content index. Keeping this
5
- * symmetric with ftsIndexDoc (deleted_commit IS NULL) makes a tombstone followed by
6
- * a later re-ingest of the same doc id (observed delete → recreate) idempotent. */
1
+ /**
2
+ * SQL predicate: the `blocks` row aliased `b` is a live leaf. `c.doc_id =
3
+ * b.doc_id` lets the NOT EXISTS use `idx_blocks_doc` (block ids are unique, so
4
+ * it never changes the answer).
5
+ */
6
+ export const LIVE_LEAF_SQL = "b.deleted_commit IS NULL AND NOT EXISTS (SELECT 1 FROM blocks c WHERE c.doc_id = b.doc_id AND c.parent_block = b.block_id AND c.deleted_commit IS NULL)";
7
+ /** Remove a document's live leaf rows from the FTS index (call before deleting
8
+ * or tombstoning blocks). Only live leaves are indexed (see ftsIndexDoc), so the
9
+ * delete must target the SAME set: an already-tombstoned block or a container has
10
+ * no FTS row, and re-issuing a 'delete' for it corrupts the external-content
11
+ * index's statistics. Keeping this symmetric with ftsIndexDoc makes a tombstone
12
+ * followed by a later re-ingest of the same doc id (observed delete → recreate)
13
+ * idempotent. */
7
14
  export function ftsDeleteDoc(db, docId) {
8
- const rows = db.prepare("SELECT rowid, text FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL").all(docId);
15
+ const rows = db.prepare(`SELECT b.rowid AS rowid, b.text AS text FROM blocks b WHERE b.doc_id = ? AND ${LIVE_LEAF_SQL}`).all(docId);
9
16
  const del = db.prepare("INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?, ?)");
10
17
  for (const r of rows)
11
18
  del.run(r.rowid, r.text);
12
19
  }
13
- /** Index a document's current block rows (call after inserting blocks). */
20
+ /** Index a document's current live leaf rows (call after inserting blocks). */
14
21
  export function ftsIndexDoc(db, docId) {
15
22
  const rows = db
16
- .prepare("SELECT rowid, text FROM blocks WHERE doc_id = ? AND deleted_commit IS NULL")
23
+ .prepare(`SELECT b.rowid AS rowid, b.text AS text FROM blocks b WHERE b.doc_id = ? AND ${LIVE_LEAF_SQL}`)
17
24
  .all(docId);
18
25
  const ins = db.prepare("INSERT INTO blocks_fts(rowid, text) VALUES(?, ?)");
19
26
  for (const r of rows)
20
27
  ins.run(r.rowid, r.text);
21
28
  }
29
+ /**
30
+ * Rebuild the whole index: `'delete-all'`, then every live leaf of every live
31
+ * document. (FTS5's own `'rebuild'` command reads the content table wholesale,
32
+ * containers included, so it is not used.)
33
+ */
34
+ export function ftsRebuild(db) {
35
+ db.exec("INSERT INTO blocks_fts(blocks_fts) VALUES('delete-all')");
36
+ const docIds = db.prepare("SELECT doc_id FROM docs WHERE deleted_commit IS NULL").all().map((r) => r.doc_id);
37
+ for (const docId of docIds)
38
+ ftsIndexDoc(db, docId);
39
+ }
40
+ /**
41
+ * Remove single `blocks` rows (by id) that are about to be deleted out of
42
+ * another document's row set — the cross-document eviction of spec/store §5.4
43
+ * step 8 — keeping the index equal to the table's live leaves. Every row's
44
+ * leaf-ness is decided over the table BEFORE any of them goes (so a list moving
45
+ * with its items is order-independent: the items are leaves and lose their
46
+ * entries, the list never had one), and a live parent that keeps its row but
47
+ * loses its last live child becomes a leaf and gains an entry. The caller owns
48
+ * the DELETE of the rows, which must follow this call.
49
+ */
50
+ export function ftsBeforeEvictRows(db, rows) {
51
+ if (rows.length === 0)
52
+ return;
53
+ const evicted = new Set(rows.map((r) => r.block_id));
54
+ const hasLiveChild = db.prepare("SELECT 1 FROM blocks c WHERE c.doc_id = ? AND c.parent_block = ? AND c.deleted_commit IS NULL LIMIT 1");
55
+ const del = db.prepare("INSERT INTO blocks_fts(blocks_fts, rowid, text) VALUES('delete', ?, ?)");
56
+ const leaves = rows.filter((r) => r.deleted_commit === null && !hasLiveChild.get(r.doc_id, r.block_id));
57
+ for (const r of leaves)
58
+ del.run(r.rowid, r.text);
59
+ // Parents that stay live but are left childless are leaves from now on.
60
+ const parentRow = db.prepare(`SELECT b.rowid AS rowid, b.text AS text FROM blocks b WHERE b.block_id = ? AND b.doc_id = ? AND b.deleted_commit IS NULL
61
+ AND NOT EXISTS (SELECT 1 FROM blocks c WHERE c.doc_id = b.doc_id AND c.parent_block = b.block_id AND c.deleted_commit IS NULL AND c.block_id NOT IN (${rows.map(() => "?").join(",")}))`);
62
+ const ins = db.prepare("INSERT INTO blocks_fts(rowid, text) VALUES(?, ?)");
63
+ const seen = new Set();
64
+ for (const r of rows) {
65
+ if (r.deleted_commit !== null || !r.parent_block || evicted.has(r.parent_block) || seen.has(r.parent_block))
66
+ continue;
67
+ seen.add(r.parent_block);
68
+ const p = parentRow.get(r.parent_block, r.doc_id, ...rows.map((x) => x.block_id));
69
+ if (p)
70
+ ins.run(p.rowid, p.text);
71
+ }
72
+ }
22
73
  //# sourceMappingURL=fts.js.map
@@ -1,6 +1,6 @@
1
1
  import { rebuildSections } from "./sections.js";
2
2
  import { rebuildDocEdges } from "./edges.js";
3
- import { ftsIndexDoc } from "./fts.js";
3
+ import { ftsRebuild } from "./fts.js";
4
4
  export function rebuildIndex(store, target = "all") {
5
5
  store.write((db) => {
6
6
  const docIds = db.prepare("SELECT doc_id FROM docs WHERE deleted_commit IS NULL").all().map((r) => r.doc_id);
@@ -14,8 +14,10 @@ export function rebuildIndex(store, target = "all") {
14
14
  rebuildDocEdges(db, docId);
15
15
  }
16
16
  if (target === "fts" || target === "all") {
17
- // Rebuild the external-content FTS index from current block text.
18
- db.exec("INSERT INTO blocks_fts(blocks_fts) VALUES('rebuild')");
17
+ // spec/search §1.1: 'delete-all', then every live leaf of every live doc
18
+ // (FTS5's 'rebuild' would index the content table wholesale, containers
19
+ // included).
20
+ ftsRebuild(db);
19
21
  }
20
22
  if (target === "block_changes" || target === "all") {
21
23
  // block_changes is a projection of dispositions across commits: rebuild
@@ -24,6 +26,5 @@ export function rebuildIndex(store, target = "all") {
24
26
  db.exec("INSERT OR IGNORE INTO block_changes (block_id, commit_id, kind) SELECT block_id, commit_id, kind FROM dispositions");
25
27
  }
26
28
  });
27
- void ftsIndexDoc; // fts rebuild uses the FTS5 'rebuild' command directly
28
29
  }
29
30
  //# sourceMappingURL=rebuild.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;