@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.
- package/dist/src/core/clock.d.ts +9 -0
- package/dist/src/core/clock.js +46 -0
- package/dist/src/core/ingest.js +15 -12
- package/dist/src/core/read/document.js +6 -1
- package/dist/src/core/read/nodes.js +5 -1
- package/dist/src/core/store/fts.d.ts +38 -7
- package/dist/src/core/store/fts.js +60 -9
- package/dist/src/core/store/rebuild.js +5 -4
- 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 +4 -2
- package/dist/src/index.js +4 -1
- package/dist/src/mcp/server.js +23 -12
- 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.d.ts +11 -0
- package/dist/src/oqx-js/context.js +30 -1
- package/dist/src/oqx-js/planner.d.ts +20 -1
- package/dist/src/oqx-js/planner.js +95 -2
- package/dist/src/oqx-js/run.js +27 -2
- package/dist/src/oqx-js/sql/translate.d.ts +71 -1
- package/dist/src/oqx-js/sql/translate.js +165 -70
- package/dist/src/search/rrf.js +2 -1
- package/dist/src/search/tasks.d.ts +34 -1
- package/dist/src/search/tasks.js +42 -23
- package/dist/src/search/vector.js +19 -18
- package/dist/src/sync/external-source.js +20 -7
- package/dist/src/sync/freshness.d.ts +8 -1
- package/dist/src/sync/freshness.js +23 -3
- package/dist/src/sync/plugin.d.ts +30 -5
- package/dist/src/sync/plugin.js +13 -1
- package/dist/src/sync/reconciling-ingest.js +5 -3
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +10 -2
- 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
|
package/dist/src/core/ingest.js
CHANGED
|
@@ -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
|
|
292
|
-
// an FTS entry (external-content index: delete with the original text); a
|
|
293
|
-
// tombstoned
|
|
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
|
-
|
|
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
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
18
|
-
|
|
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[];
|
|
@@ -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;
|