@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
@@ -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,6 +1,39 @@
1
1
  import type { Store } from "../core/store/store.js";
2
2
  import { type EmbedTask, type DocEmbedTask } from "./embeddings.js";
3
- /** Build EmbedTask[] for every embeddable live block in the repo. */
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;
31
+ /**
32
+ * Build EmbedTask[] for every embeddable live block in the repo: the live
33
+ * LEAF blocks (spec/search §2.1, 1.2 — a container's text is its children's
34
+ * text, so embedding it would pool the same words twice) whose text clears the
35
+ * `shouldEmbed` floor.
36
+ */
4
37
  export declare function buildEmbedTasks(store: Store, repoId: string): EmbedTask[];
5
38
  /**
6
39
  * Build DocEmbedTask[] for every live document in the repo (doc-grain semantic
@@ -1,5 +1,6 @@
1
1
  import { reconstructContent } from "../core/read/document.js";
2
2
  import { docPropertiesMerged } from "../core/store/properties.js";
3
+ import { LIVE_LEAF_SQL } from "../core/store/fts.js";
3
4
  import { contextPrefix, shouldEmbed, estimateTokens } from "./embeddings.js";
4
5
  function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
5
6
  const t = titleByDoc.get(docId);
@@ -7,31 +8,31 @@ function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
7
8
  return t;
8
9
  return firstHeadingByDoc.get(docId) ?? path;
9
10
  }
10
- /** Build EmbedTask[] for every embeddable live block in the repo. */
11
- export function buildEmbedTasks(store, repoId) {
12
- const blocks = store.db
13
- .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
14
- b.ordinal, b.type, b.text, b.raw_hash
15
- FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
16
- WHERE b.repo_id = ? AND b.deleted_commit IS NULL
17
- ORDER BY d.path, b.ordinal`)
18
- .all(repoId);
19
- // Frontmatter title per doc (scalar), for the embed context prefix. One scan
20
- // of the properties table instead of a per-block metadata parse.
11
+ /**
12
+ * Load the title/section facts the context prefix depends on. `scope` narrows
13
+ * the load to one repo (every doc — the drain and vector search) or one doc
14
+ * (a single block's lookup).
15
+ */
16
+ export function makeBlockContextResolver(store, scope) {
17
+ const byRepo = "repoId" in scope;
18
+ // Frontmatter title per doc (scalar string), for the embed context prefix.
19
+ // One scan of the properties table instead of a per-block metadata parse.
21
20
  const titleByDoc = new Map();
22
21
  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)) {
22
+ WHERE ${byRepo ? "p.repo_id = ?" : "p.doc_id = ?"} AND p.source = 'frontmatter' AND p.key = 'title'
23
+ AND p.card = 'scalar' AND p.type = 'string' AND p.deleted_commit IS NULL`).all(byRepo ? scope.repoId : scope.docId)) {
25
24
  if (r.title)
26
25
  titleByDoc.set(r.doc_id, r.title);
27
26
  }
28
- // All sections in the repo, with their heading text, for chain lookup.
27
+ // The sections in scope with their heading text, for chain lookup. Ordered by
28
+ // first_ordinal so the first per doc is the title fallback (§2.2).
29
29
  const sections = store.db
30
30
  .prepare(`SELECT s.doc_id AS doc_id, hb.text AS heading_text, s.level AS level,
31
31
  s.first_ordinal AS first_ordinal, s.last_ordinal AS last_ordinal
32
32
  FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
33
- WHERE hb.repo_id = ?`)
34
- .all(repoId);
33
+ WHERE ${byRepo ? "hb.repo_id = ?" : "s.doc_id = ?"}
34
+ ORDER BY s.doc_id, s.first_ordinal, s.rowid`)
35
+ .all(byRepo ? scope.repoId : scope.docId);
35
36
  const sectionsByDoc = new Map();
36
37
  for (const s of sections) {
37
38
  (sectionsByDoc.get(s.doc_id) ?? sectionsByDoc.set(s.doc_id, []).get(s.doc_id)).push(s);
@@ -52,17 +53,35 @@ export function buildEmbedTasks(store, repoId) {
52
53
  .sort((a, b) => a.level - b.level)
53
54
  .map((s) => s.heading_text);
54
55
  };
55
- const tasks = [];
56
- for (const b of blocks) {
57
- if (!shouldEmbed(b.text))
58
- continue;
59
- const ctx = contextPrefix({
56
+ return {
57
+ ctx: (b) => contextPrefix({
60
58
  docTitle: docTitle(titleByDoc, firstHeadingByDoc, b.doc_id, b.path),
61
59
  path: b.path,
62
60
  headingChain: headingChain(b.doc_id, b.ordinal),
63
61
  blockType: b.type,
64
- });
65
- tasks.push({ blockId: b.block_id, contentHashHex: b.raw_hash.toString("hex"), ctx, text: b.text });
62
+ }),
63
+ };
64
+ }
65
+ /**
66
+ * Build EmbedTask[] for every embeddable live block in the repo: the live
67
+ * LEAF blocks (spec/search §2.1, 1.2 — a container's text is its children's
68
+ * text, so embedding it would pool the same words twice) whose text clears the
69
+ * `shouldEmbed` floor.
70
+ */
71
+ export function buildEmbedTasks(store, repoId) {
72
+ const blocks = store.db
73
+ .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
74
+ b.ordinal, b.type, b.text, b.raw_hash
75
+ FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
76
+ WHERE b.repo_id = ? AND ${LIVE_LEAF_SQL}
77
+ ORDER BY d.path, b.ordinal`)
78
+ .all(repoId);
79
+ const contexts = makeBlockContextResolver(store, { repoId });
80
+ const tasks = [];
81
+ for (const b of blocks) {
82
+ if (!shouldEmbed(b.text))
83
+ continue;
84
+ tasks.push({ blockId: b.block_id, contentHashHex: b.raw_hash.toString("hex"), ctx: contexts.ctx(b), text: b.text });
66
85
  }
67
86
  return tasks;
68
87
  }
@@ -1,3 +1,5 @@
1
+ import { ctxHashHex } from "./embeddings.js";
2
+ import { buildEmbedTasks } from "./tasks.js";
1
3
  function cosine(a, b) {
2
4
  let dot = 0, na = 0, nb = 0;
3
5
  const n = Math.min(a.length, b.length);
@@ -12,25 +14,24 @@ function cosine(a, b) {
12
14
  }
13
15
  export function vectorSearch(store, repoId, model, queryVec, opts = {}) {
14
16
  const limit = opts.limit ?? 50;
15
- const rows = store.db
16
- .prepare(`SELECT b.block_id AS blockId, b.doc_id AS docId, d.path AS path, e.vec AS vec
17
- FROM embeddings e
18
- JOIN blocks b ON b.raw_hash = e.content_hash AND b.deleted_commit IS NULL
19
- JOIN docs d ON d.doc_id = b.doc_id
20
- WHERE b.repo_id = ? AND e.model = ?`)
21
- .all(repoId, model);
22
- // One hit per live block (spec/search §3). Several cache rows can join to the
23
- // same block — a stale ctx row survives a heading rename next to the fresh one
24
- // — and the block keeps its best cosine, not one hit per row.
25
- const best = new Map();
26
- for (const r of rows) {
27
- const v = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
28
- const c = cosine(queryVec, v);
29
- const prev = best.get(r.blockId);
30
- if (!prev || c > prev.cosine)
31
- best.set(r.blockId, { blockId: r.blockId, docId: r.docId, path: r.path, cosine: c });
17
+ // The candidates are exactly the drain's tasks: every live embeddable block
18
+ // with its current (content_hash, ctx). Reusing buildEmbedTasks keeps the
19
+ // context rule in one place; its cost is one walk of the repo's blocks and
20
+ // sections per query (fine at v1 scale, see the ceiling above).
21
+ const tasks = buildEmbedTasks(store, repoId);
22
+ const docPath = store.db.prepare("SELECT doc_id AS docId, path FROM blocks JOIN docs USING (doc_id) WHERE block_id = ?");
23
+ const vecFor = store.db.prepare("SELECT vec FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?");
24
+ const scored = [];
25
+ for (const t of tasks) {
26
+ const row = vecFor.get(Buffer.from(t.contentHashHex, "hex"), Buffer.from(ctxHashHex(t.ctx), "hex"), model);
27
+ if (!row)
28
+ continue; // stale (§2.3): no vector until the next drain
29
+ const meta = docPath.get(t.blockId);
30
+ if (!meta)
31
+ continue;
32
+ const v = new Float32Array(row.vec.buffer, row.vec.byteOffset, row.vec.byteLength / 4);
33
+ scored.push({ blockId: t.blockId, docId: meta.docId, path: meta.path, cosine: cosine(queryVec, v) });
32
34
  }
33
- const scored = [...best.values()];
34
35
  scored.sort((a, b) => b.cosine - a.cosine || (a.blockId < b.blockId ? -1 : 1));
35
36
  return scored.slice(0, limit);
36
37
  }
@@ -17,7 +17,7 @@ export async function createExternalSource(spec) {
17
17
  }
18
18
  const rl = createInterface({ input: child.stdout });
19
19
  let watchListener = null;
20
- const router = messageRouter(rl, (paths) => watchListener?.(paths));
20
+ const router = messageRouter(rl, (ev) => watchListener?.(ev));
21
21
  const died = new Promise((_, reject) => {
22
22
  child.once("error", (err) => reject(new Error(`sync adapter '${spec.command}' failed to spawn: ${err.message}`)));
23
23
  child.once("exit", (code) => reject(new Error(`sync adapter '${spec.command}' exited early (code ${code ?? "?"})`)));
@@ -69,10 +69,19 @@ export async function createExternalSource(spec) {
69
69
  },
70
70
  ...(caps.watch
71
71
  ? {
72
- async watch(onBatch) {
73
- watchListener = onBatch;
72
+ async watch(listener) {
73
+ // `ready` resolves on the adapter's {"event":"ready"}; the listener sees
74
+ // it as an event too, in wire order with the batches (spec/sync §5, §8).
75
+ let markReady = () => { };
76
+ const ready = new Promise((resolve) => { markReady = resolve; });
77
+ watchListener = (ev) => {
78
+ if (ev.event === "ready")
79
+ markReady();
80
+ listener(ev);
81
+ };
74
82
  await call("watch");
75
83
  return {
84
+ ready,
76
85
  async stop() {
77
86
  watchListener = null;
78
87
  try {
@@ -105,9 +114,9 @@ export async function createExternalSource(spec) {
105
114
  };
106
115
  return source;
107
116
  }
108
- // Demultiplex adapter stdout: {"event":"batch"} lines feed the watch listener;
109
- // every other line is an id-addressed response buffered for nextResponse().
110
- function messageRouter(rl, onBatch) {
117
+ // Demultiplex adapter stdout: {"event":…} lines feed the watch listener (ready,
118
+ // batch); every other line is an id-addressed response buffered for nextResponse().
119
+ function messageRouter(rl, onEvent) {
111
120
  const buffered = [];
112
121
  const waiters = [];
113
122
  rl.on("line", (line) => {
@@ -117,7 +126,11 @@ function messageRouter(rl, onBatch) {
117
126
  try {
118
127
  const peek = JSON.parse(trimmed);
119
128
  if (peek.event === "batch") {
120
- onBatch(peek.paths ?? []);
129
+ onEvent({ event: "batch", paths: peek.paths ?? [] });
130
+ return;
131
+ }
132
+ if (peek.event === "ready") {
133
+ onEvent({ event: "ready" });
121
134
  return;
122
135
  }
123
136
  }
@@ -87,7 +87,14 @@ export interface DiskDrift {
87
87
  export declare function detectDiskDrift(store: Store, repoId: string, rootPath: string, opts?: {
88
88
  fs?: SyncFs;
89
89
  }): DiskDrift;
90
- /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
90
+ /**
91
+ * Rebuild `file_stats` from scratch (spec/sync §4.3, since 1.1): delete the repo's rows, then
92
+ * walk the tree and record a row only for a file whose bytes' hash equals its live doc's
93
+ * `file_hash` — the cache is allowed to say "known" only about bytes the store already holds.
94
+ * A file with no live doc, or whose bytes differ from what was ingested, gets no row, so the
95
+ * next sweep still treats it as a candidate and drift still reports it (a cache rebuild must
96
+ * never change what the next sweep does). Returns the number of files walked, recorded or not.
97
+ */
91
98
  export declare function rebuildFileStats(store: Store, repoId: string, rootPath: string, opts?: {
92
99
  fs?: SyncFs;
93
100
  }): number;
@@ -147,16 +147,36 @@ export function detectDiskDrift(store, repoId, rootPath, opts = {}) {
147
147
  deleted += 1;
148
148
  return { changed, deleted, untracked };
149
149
  }
150
- /** Rebuild file_stats from scratch by re-statting + re-hashing every file. */
150
+ /**
151
+ * Rebuild `file_stats` from scratch (spec/sync §4.3, since 1.1): delete the repo's rows, then
152
+ * walk the tree and record a row only for a file whose bytes' hash equals its live doc's
153
+ * `file_hash` — the cache is allowed to say "known" only about bytes the store already holds.
154
+ * A file with no live doc, or whose bytes differ from what was ingested, gets no row, so the
155
+ * next sweep still treats it as a candidate and drift still reports it (a cache rebuild must
156
+ * never change what the next sweep does). Returns the number of files walked, recorded or not.
157
+ */
151
158
  export function rebuildFileStats(store, repoId, rootPath, opts = {}) {
152
159
  const fs = opts.fs ?? nodeFs;
153
160
  store.db.prepare("DELETE FROM file_stats WHERE repo_id = ?").run(repoId);
161
+ // The live docs' durable convergence signal, keyed by path (one query).
162
+ const live = new Map();
163
+ for (const row of store.db
164
+ .prepare("SELECT path, file_hash FROM docs WHERE repo_id = ? AND deleted_commit IS NULL AND file_hash IS NOT NULL")
165
+ .all(repoId)) {
166
+ live.set(row.path, row.file_hash);
167
+ }
154
168
  const paths = fs.walk(rootPath);
155
169
  for (const path of paths) {
170
+ const want = live.get(path);
171
+ if (!want)
172
+ continue; // no live doc: nothing the cache may vouch for
156
173
  const bytes = fs.read(rootPath, path);
157
174
  if (bytes === null)
158
- continue;
159
- recordStat(store, repoId, path, fs.stat(rootPath, path), sha256(bytes));
175
+ continue; // vanished between the walk and the read
176
+ const hash = sha256(bytes);
177
+ if (!hash.equals(want))
178
+ continue; // a pending edit: leave it visible
179
+ recordStat(store, repoId, path, fs.stat(rootPath, path), hash);
160
180
  }
161
181
  return paths.length;
162
182
  }
@@ -25,13 +25,38 @@ export interface SourceItem extends SourceEntry {
25
25
  /** The engine hashes this itself for the authoritative echo/convergence gate. */
26
26
  content: string;
27
27
  }
28
- /** A batch of changed storage-keys delivered by a live watch (13 §4.3). */
29
- export type WatchListener = (paths: string[]) => void;
28
+ /**
29
+ * An unsolicited event of a live watch (spec/sync §5): `ready` once — the feed
30
+ * is primed and every change from now on will be reported — then `batch` lines
31
+ * of changed storage keys. An adapter built before sync 1.2 never sends `ready`.
32
+ */
33
+ export type WatchEvent = {
34
+ event: "ready";
35
+ } | {
36
+ event: "batch";
37
+ paths: string[];
38
+ };
39
+ /** Receives every event of a live watch, in wire order. */
40
+ export type WatchListener = (event: WatchEvent) => void;
30
41
  /** A live subscription created by SyncSource.watch. */
31
42
  export interface SourceWatch {
32
- /** Stop delivering batches and release resources. */
43
+ /**
44
+ * Resolves when the source reported `ready` (spec/sync §5). An in-process
45
+ * source resolves it at once; an adapter that never reports leaves it pending,
46
+ * so a host bounds the wait (`awaitReady`).
47
+ */
48
+ ready: Promise<void>;
49
+ /** Stop delivering events and release resources. */
33
50
  stop(): Promise<void>;
34
51
  }
52
+ /** How long a host waits for `ready` before proceeding as if ready (spec/sync §5: unpinned; 30 s). */
53
+ export declare const WATCH_READY_PATIENCE_MS = 30000;
54
+ /**
55
+ * Wait for a watch's `ready` with bounded patience: `true` when it resolved in
56
+ * time, `false` on timeout (the host proceeds as if ready, with a warning — an
57
+ * adapter without `ready` still works, with the old window).
58
+ */
59
+ export declare function awaitReady(ready: Promise<void>, patienceMs?: number): Promise<boolean>;
35
60
  /**
36
61
  * The in-engine handle to an external source process. Only capabilities/
37
62
  * enumerate/fetch are always meaningful; watch exists when capabilities().watch,
@@ -43,8 +68,8 @@ export interface SyncSource {
43
68
  enumerate(): Promise<SourceEntry[]>;
44
69
  /** Current state of one member, or null if it left the scope (a delete). */
45
70
  fetch(path: string): Promise<SourceItem | null>;
46
- /** Subscribe to debounced change batches (push sources). */
47
- watch?(onBatch: WatchListener): Promise<SourceWatch>;
71
+ /** Subscribe to the change feed (push sources): `ready` once, then debounced `batch` events. */
72
+ watch?(listener: WatchListener): Promise<SourceWatch>;
48
73
  /** Persist engine-authored bytes back to the source (writeThrough only). */
49
74
  write?(path: string, content: string): Promise<void>;
50
75
  /** Remove a member from the source (writeThrough only). */
@@ -8,5 +8,17 @@
8
8
  // reconciliation, commits, convergence, and durable revision/cursor state — so a
9
9
  // source only reports membership and transports bytes; it can never write a
10
10
  // commit, mint identity, or bypass the convergence check.
11
- export {};
11
+ /** How long a host waits for `ready` before proceeding as if ready (spec/sync §5: unpinned; 30 s). */
12
+ export const WATCH_READY_PATIENCE_MS = 30_000;
13
+ /**
14
+ * Wait for a watch's `ready` with bounded patience: `true` when it resolved in
15
+ * time, `false` on timeout (the host proceeds as if ready, with a warning — an
16
+ * adapter without `ready` still works, with the old window).
17
+ */
18
+ export function awaitReady(ready, patienceMs = WATCH_READY_PATIENCE_MS) {
19
+ return new Promise((resolve) => {
20
+ const timer = setTimeout(() => resolve(false), patienceMs);
21
+ ready.then(() => { clearTimeout(timer); resolve(true); }, () => { clearTimeout(timer); resolve(false); });
22
+ });
23
+ }
12
24
  //# sourceMappingURL=plugin.js.map
@@ -105,7 +105,7 @@ function assignFromMap(blocks, assignment) {
105
105
  const walk = (list, parentKey) => list.map((b, index) => {
106
106
  const key = `${parentKey ?? ""}/${index}`;
107
107
  const blockId = assignment.get(key) ?? mintId("b");
108
- return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key) };
108
+ return { blockId, type: b.type, raw: b.raw, trivia: b.trivia, attrs: b.attrs, children: walk(b.children, key), span: b.span };
109
109
  });
110
110
  return walk(blocks, null);
111
111
  }
@@ -260,11 +260,13 @@ function resolveAdapterEdge(db, repoId, srcDoc, e, docDir) {
260
260
  provenance: e.provenance,
261
261
  };
262
262
  }
263
- // Collect RawBlock-shaped objects from the id-assigned tree for adapter edge extraction.
263
+ // Collect RawBlock-shaped objects from the id-assigned tree for adapter edge
264
+ // extraction. The parsed span rides along so the adapter can blank a
265
+ // container's children (spec/graph §1 own text).
264
266
  function collectRawBlocks(blocks) {
265
267
  return blocks.map((b) => ({
266
268
  type: b.type,
267
- span: { start: 0, end: 0 },
269
+ span: b.span ?? { start: 0, end: 0 },
268
270
  raw: b.raw,
269
271
  text: "",
270
272
  attrs: b.attrs,
@@ -14,6 +14,8 @@ export declare class Watcher {
14
14
  private sub;
15
15
  constructor(store: Store, repoId: string, source: SyncSource, opts?: WatcherOptions);
16
16
  start(): Promise<void>;
17
+ /** The live subscription's `ready` (spec/sync §5); throws before `start()`. */
18
+ ready(): Promise<void>;
17
19
  private ingest;
18
20
  stop(): Promise<void>;
19
21
  }
@@ -14,11 +14,19 @@ export class Watcher {
14
14
  async start() {
15
15
  if (!this.source.watch)
16
16
  throw new Error("source does not support watch()");
17
- this.sub = await this.source.watch((paths) => {
17
+ this.sub = await this.source.watch((ev) => {
18
+ if (ev.event !== "batch")
19
+ return; // `ready` is surfaced through ready()
18
20
  // A batch arrives from the adapter's stream; reconcile it off the callback.
19
- void this.ingest(paths).catch((err) => this.opts.onError?.(err));
21
+ void this.ingest(ev.paths).catch((err) => this.opts.onError?.(err));
20
22
  });
21
23
  }
24
+ /** The live subscription's `ready` (spec/sync §5); throws before `start()`. */
25
+ ready() {
26
+ if (!this.sub)
27
+ throw new Error("watcher not started");
28
+ return this.sub.ready;
29
+ }
22
30
  async ingest(paths) {
23
31
  if (paths.length === 0)
24
32
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Open Markdown Graph Base — embedded engine",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@modelcontextprotocol/sdk": "^1.30.0",
58
- "@omgbase/oqx": "^0.12.0",
58
+ "@omgbase/oqx": "^0.13.0",
59
59
  "better-sqlite3": "^13.0.3",
60
60
  "mdast-util-from-markdown": "^2.0.2",
61
61
  "mdast-util-frontmatter": "^2.0.1",