@omgbase/core 0.1.0 → 0.2.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 (76) hide show
  1. package/README.md +172 -0
  2. package/dist/src/cli/eval-matcher.d.ts +6 -1
  3. package/dist/src/cli/eval-matcher.js +14 -2
  4. package/dist/src/core/hash.d.ts +25 -1
  5. package/dist/src/core/hash.js +110 -10
  6. package/dist/src/core/ids.d.ts +12 -0
  7. package/dist/src/core/ids.js +43 -1
  8. package/dist/src/core/ingest.d.ts +22 -2
  9. package/dist/src/core/ingest.js +92 -18
  10. package/dist/src/core/parse/parse.d.ts +1 -1
  11. package/dist/src/core/parse/parse.js +31 -9
  12. package/dist/src/core/store/nodes.d.ts +13 -0
  13. package/dist/src/core/store/nodes.js +24 -0
  14. package/dist/src/core/utf8.d.ts +29 -0
  15. package/dist/src/core/utf8.js +96 -0
  16. package/dist/src/format/adapter.d.ts +7 -1
  17. package/dist/src/format/markdown.js +9 -18
  18. package/dist/src/graph/history.d.ts +6 -0
  19. package/dist/src/graph/history.js +13 -2
  20. package/dist/src/index.d.ts +8 -5
  21. package/dist/src/index.js +8 -5
  22. package/dist/src/mcp/graph.js +8 -4
  23. package/dist/src/mcp/reference.d.ts +1 -1
  24. package/dist/src/mcp/reference.js +17 -6
  25. package/dist/src/mcp/server.js +12 -7
  26. package/dist/src/mutate/apply.d.ts +7 -0
  27. package/dist/src/mutate/apply.js +17 -7
  28. package/dist/src/mutate/docs.d.ts +2 -0
  29. package/dist/src/mutate/docs.js +10 -6
  30. package/dist/src/mutate/known-ids.js +9 -1
  31. package/dist/src/mutate/macros.js +9 -2
  32. package/dist/src/mutate/ops.d.ts +2 -1
  33. package/dist/src/mutate/ops.js +24 -2
  34. package/dist/src/mutate/plan-update.d.ts +7 -1
  35. package/dist/src/mutate/plan-update.js +3 -1
  36. package/dist/src/oqx-js/context.js +43 -6
  37. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  38. package/dist/src/reconcile/eval/generator.js +248 -0
  39. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  40. package/dist/src/reconcile/eval/metrics.js +55 -32
  41. package/dist/src/reconcile/flatten.js +7 -5
  42. package/dist/src/reconcile/phase5.d.ts +2 -1
  43. package/dist/src/reconcile/phase5.js +27 -10
  44. package/dist/src/reconcile/phase6a.js +17 -5
  45. package/dist/src/reconcile/phases.d.ts +44 -6
  46. package/dist/src/reconcile/phases.js +0 -0
  47. package/dist/src/reconcile/reconcile.js +16 -9
  48. package/dist/src/reconcile/types.d.ts +4 -1
  49. package/dist/src/reconcile/types.js +11 -2
  50. package/dist/src/search/external.d.ts +10 -0
  51. package/dist/src/search/external.js +18 -1
  52. package/dist/src/search/provider.d.ts +7 -3
  53. package/dist/src/search/provider.js +8 -7
  54. package/dist/src/search/rrf.js +3 -0
  55. package/dist/src/search/vector.js +21 -6
  56. package/dist/src/sync/attach.d.ts +7 -2
  57. package/dist/src/sync/attach.js +10 -8
  58. package/dist/src/sync/checkpoint.d.ts +18 -1
  59. package/dist/src/sync/checkpoint.js +31 -29
  60. package/dist/src/sync/driver.d.ts +15 -4
  61. package/dist/src/sync/driver.js +18 -43
  62. package/dist/src/sync/external-source.js +7 -1
  63. package/dist/src/sync/freshness.d.ts +65 -14
  64. package/dist/src/sync/freshness.js +107 -96
  65. package/dist/src/sync/fs-util.d.ts +19 -0
  66. package/dist/src/sync/fs-util.js +24 -1
  67. package/dist/src/sync/observe.d.ts +53 -5
  68. package/dist/src/sync/observe.js +162 -21
  69. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  70. package/dist/src/sync/reconciling-ingest.js +82 -10
  71. package/dist/src/sync/recovery.d.ts +5 -1
  72. package/dist/src/sync/recovery.js +8 -8
  73. package/dist/src/sync/workspace.d.ts +11 -3
  74. package/dist/src/sync/workspace.js +39 -30
  75. package/dist/src/sync/writer-lock.js +16 -26
  76. package/package.json +3 -3
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @omgbase/core
2
+
3
+ **Open Markdown Graph Base — the embedded engine.**
4
+
5
+ `@omgbase/core` is a versioned, addressable graph layer over ordinary Markdown, YAML,
6
+ and JSON files. The files stay the human source of truth for *content*; an embedded
7
+ SQLite database (one per workspace, under `.omgbase/`) owns *identity, history, and
8
+ derived indexes*: stable block ids that survive edits, block-grain commit history,
9
+ a typed link/property graph, full-text + vector retrieval, and a six-op structural
10
+ mutation kernel with content-hash CAS. At quiescence, `sha256(file)` equals the
11
+ current revision's rendered hash for every tracked document. This package is the
12
+ library; the `omg` CLI ([`omgbase`](https://www.npmjs.com/package/omgbase)) and the
13
+ MCP server (`omg mcp`) are thin clients over it. The full as-built design lives in
14
+ the [monorepo README](https://github.com/omgbase/omgbase#readme) and
15
+ [`docs/`](https://github.com/omgbase/omgbase/tree/main/docs).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @omgbase/core
21
+ ```
22
+
23
+ - **Node ≥ 22.** ESM only (`"type": "module"`; no CommonJS build).
24
+ - SQLite is [`better-sqlite3`](https://www.npmjs.com/package/better-sqlite3), which
25
+ ships prebuilt binaries for common platforms; a compiler is only needed where no
26
+ prebuild exists.
27
+ - No ML dependency. Semantic search is optional and talks to an external embedder
28
+ process/URL (e.g. `@omgbase/embedder`) over a small stdio/HTTP protocol.
29
+
30
+ ## Quick start
31
+
32
+ Everything below was run as written against a scratch directory. A **workspace** is
33
+ a directory holding `.omgbase/omgbase.db`; a **repo** is a set of documents whose
34
+ bytes come from an attached source — here, a directory of Markdown.
35
+
36
+ ```js
37
+ import { mkdirSync, writeFileSync } from "node:fs";
38
+ import {
39
+ Workspace, ensureRepo, freshnessSweep,
40
+ oqxRun, findDoc, docsRead,
41
+ apply, tasksComplete, historyNode,
42
+ } from "@omgbase/core";
43
+
44
+ const wsDir = "/tmp/omg-demo";
45
+ const root = `${wsDir}/notes`;
46
+ mkdirSync(root, { recursive: true });
47
+ writeFileSync(`${root}/todo.md`, "# Todo\n\n- [ ] write the README\n- [x] ship 0.1\n");
48
+
49
+ const ws = Workspace.open(wsDir); // creates .omgbase/ + the SQLite db
50
+ const repoId = ensureRepo(ws.store, "notes", root); // repo row + an `fs` source rooted at `root`
51
+ const sweep = freshnessSweep(ws.store, repoId, root); // ingest new/changed *.md as one checkpoint
52
+ sweep.ingested; // ["todo.md"]
53
+
54
+ // Query with OQX. Nodes are projections of blocks; $block_id is the anchor block.
55
+ const open = oqxRun(ws.store, repoId,
56
+ 'select block: $block_id from nodes where kind == "md:task" && !attrs.checked');
57
+ open.hits; // [{ id: "n_…", path: "todo.md", block: "b_…" }]
58
+
59
+ // Read a whole document back byte-exact, with block ids + CAS hashes.
60
+ const doc = findDoc(ws.store, { repoId, path: "todo.md" });
61
+ const read = docsRead(ws.store, doc.docId, { includeIds: true });
62
+ read.content; // "# Todo\n\n- [ ] write the README\n- [x] ship 0.1\n"
63
+ read.ids; // ["b_…", "b_…", "b_…", "b_…"] read.hashes[id] → content_hash
64
+
65
+ // Mutate. The macro expands to kernel `update` ops pinned by content_hash;
66
+ // apply() renders, writes the file, and commits a new revision.
67
+ const result = apply(ws.store, {
68
+ repoId,
69
+ rootPath: root,
70
+ omgbaseDir: ws.omgbaseDir, // take the cross-process writer lock
71
+ ops: tasksComplete(ws.store, open.hits.map((h) => h.block)),
72
+ origin: { actor: "agent:quickstart", reason: "demo" },
73
+ });
74
+ result; // { results: [{ ids: ["b_…"] }], revisions: [{ doc: "d_…", path: "todo.md" }], committed: true }
75
+ // todo.md on disk now reads "- [x] write the README"
76
+
77
+ historyNode(ws.store, open.hits[0].block); // [{ origin: "import", kind: "edited", … }, { origin: "observed", kind: "inserted", … }]
78
+ ws.close();
79
+ ```
80
+
81
+ Raw kernel ops take the same route. An `update` whose `expect.content_hash` no
82
+ longer matches throws a `MutationError` with `code: "stale_expectation"`:
83
+
84
+ ```js
85
+ apply(ws.store, {
86
+ repoId, rootPath: root, omgbaseDir: ws.omgbaseDir,
87
+ ops: [{ op: "update", block: read.ids[1], markdown: "hello, world",
88
+ expect: { content_hash: read.hashes[read.ids[1]] } }],
89
+ origin: { actor: "agent:quickstart" },
90
+ });
91
+ ```
92
+
93
+ No filesystem at all? `new Store({ path: ":memory:" })`, `ensureRepo(store, slug, null)`,
94
+ feed bytes with `observeFile(store, repoId, "a.md", text)`, and pass
95
+ `docStore: new NullDocStore()` to `apply` — the DB is then the canonical artifact.
96
+
97
+ ## Surface map
98
+
99
+ All of these are named exports of `@omgbase/core` (`src/index.ts`). Functions take
100
+ the `Store` first and are synchronous unless noted.
101
+
102
+ | Group | What it does | Key exports |
103
+ | --- | --- | --- |
104
+ | Store / workspace | Open the SQLite store (WAL, migrations to `SCHEMA_VERSION`); find a workspace by walking up for `.omgbase/`; list/select repos; layered settings | `Store`, `Workspace.open/find`, `Workspace#repos/selectRepo/close`, `ensureRepo`, `resolveSettings`, `workspaceSettings`, `writeWorkspaceSettings` |
105
+ | Ingest / sync | Get file bytes into the repo as observed commits: one-shot sweep, batch checkpoint, headless observe, directory walk, external stdio adapters, live watcher, writer lock | `freshnessSweep`, `processCheckpoint`, `observeFile/observeMany/observeDelete`, `ingestDirectory`, `createSource/attachSourceToRepo/listSources`, `createExternalSource`, `reconcileChanges`, `Watcher`, `WatchLease`, `withWriterLock`, `reposStatus`, `syncStatus` |
106
+ | Read | Byte-exact document read, outlines, block/node hydration, listings, ref resolution, cursors | `docsRead`, `docsOutline`, `nodesGet/nodesGetMany`, `findDoc`, `loadDocBlocks`, `blockRaw`, `docsList`, `docsTree`, `resolveRef`, `encodeCursor/decodeCursor` |
107
+ | Mutate — kernel | The six ops (`insert/update/move/remove/split/merge`) applied atomically as one changeset with CAS; write target is pluggable | `apply`, `Op`, `ApplyRequest`, `Expect`, `To`, `DocStore`, `FsDocStore`, `NullDocStore`, `MutationError` |
108
+ | Mutate — macros + doc ops | Op builders for common intents; whole-document create/move/delete/frontmatter; whole-document update planner | `tasksComplete`, `sectionsAppend`, `sectionsRename`, `sectionsMove`, `listsInsertItem`, `nodeSet`, `linksRetarget`, `docsCreate`, `docsMove`, `docsDelete`, `docsSetMeta`, `planUpdate`, `applyOpset`, `docsUpdate` |
109
+ | OQX | Run an OQX query over the store (`docs`/`blocks`/`nodes`/`edges`); async variant embeds `semantic("…")` phrases first | `oqxRun`, `oqxRunAsync`, `parseOqx`, `collectSemanticPhrases`, `OqxResult`, `FilterInvalid` |
110
+ | Graph / history | Block history, unified diffs between revisions, commit digests since a cursor, link edges | `historyNode`, `diffUnified`, `changesSince`, `docLinks`, `inboundLinksTo` |
111
+ | Search | Ranked "what does the caller mean" resolution; RRF fusion of FTS + vectors; raw vector search | `resolve`, `hybridSearch`, `vectorSearch`, `docVectorSearch` |
112
+ | Embeddings | Out-of-process embedder client, batching worker, post-mutation drainer, task builders | `createExternalProvider`, `EmbeddingWorker`, `EmbedDrainer`, `buildEmbedTasks`, `embeddingSettings`, `SemanticUnavailable` |
113
+ | Format adapters | Register/lookup the parsers that turn a file into blocks + nodes + edges | `registerAdapter`, `adapterForPath`, `detectFormat`, `markdownAdapter`, `yamlAdapter`, `jsonAdapter` |
114
+ | MCP server | Build the full tool surface (45 tools: `docs_read`, `query`, `apply`, `observe`, `changes_since`, …) as an `McpServer`; wire it to stdio | `buildServer({ store, repoId, rootPath?, embedQuery?, onMutation? })`, `serveStdio(ctx)` |
115
+ | Admin | Rebuild derived indexes; garbage-collect unreferenced blobs | `rebuildIndex`, `runGc`, `VERSION`, `EngineError` |
116
+
117
+ `omg mcp` is just a host for `serveStdio`: it opens the workspace, primes with
118
+ `freshnessSweep`, optionally starts a `Watcher` and `EmbedDrainer`, then calls
119
+ `serveStdio({ store, repoId, rootPath, embedQuery, onMutation })`. Embed the same
120
+ server in your own process with `buildServer(ctx)` and any MCP SDK transport.
121
+
122
+ ## Data model in 10 lines
123
+
124
+ 1. A **repo** (`rp_`) owns identity and history; its bytes come from attached sources (a directory, or nothing).
125
+ 2. A **doc** (`d_`) is one file at a repo-relative path, in a format (`markdown`, `yaml`, `json`).
126
+ 3. A doc is an ordered tree of **blocks** (`b_`): paragraphs, headings, list items, fences, YAML entries, JSON properties…
127
+ 4. Block ids are minted once and threaded through re-ingests by the reconciler, so they survive edits and moves.
128
+ 5. Blocks are the **mutation anchors**: every kernel op addresses `b_` ids; `expect.content_hash` (sha256 of the block's raw bytes) is the CAS token.
129
+ 6. **Nodes** (`n_`) are semantic projections of blocks — `md:task`, `md:heading`, `md:section`, `md:link`, `md:wikilink`, `md:anchor`, `md:inline_field` — queryable but derived; `$block_id` points back at the anchor.
130
+ 7. **Edges** are typed links between blocks/docs (wikilinks, `$ref`, frontmatter relations) with commit intervals.
131
+ 8. Every ingest or mutation appends a **commit** (`c_`) and a per-doc **revision** (`r_`) whose rendered hash equals the file's bytes.
132
+ 9. Commits carry `origin` (`observed` from disk, `import` from `apply`) and an actor; `historyNode`/`changesSince` read them back.
133
+ 10. Indexes (FTS, properties table, sections, embeddings) are derived and rebuildable; the schema is `SCHEMA_VERSION = 13`.
134
+
135
+ ## Query language
136
+
137
+ OQX is the standalone [`@omgbase/oqx`](https://www.npmjs.com/package/@omgbase/oqx)
138
+ engine bound to the store (`src/oqx-js/`): omgbase supplies a `DataContext` over
139
+ `docs`/`blocks`/`nodes`/`edges` and a SQLite pushdown planner, and the library
140
+ supplies the language (`select … from … where … follow … order by … limit … offset …`, in that fixed order,
141
+ consumers `collect/count/exists/none/first/single`, nested blocks, `^` outer
142
+ references, `entries()`, `values`). Row-scoped predicates like `text("…")` (FTS)
143
+ and `semantic("…")` (embedding score) are omgbase additions.
144
+
145
+ ```js
146
+ oqxRun(ws.store, repoId, `
147
+ select $path, $title, open: nodes collect { select $block_id where kind == "md:task" && !attrs.checked }
148
+ from docs where nodes count { where kind == "md:task" && !attrs.checked } >= 2
149
+ order by $path limit 10
150
+ `);
151
+ // hits: [{ id: "d_…", path: "more.md", $path: "more.md", $title: "More", open: [{ $block_id: "b_…" }, …] }]
152
+ ```
153
+
154
+ See [`docs/query-language.md`](https://github.com/omgbase/omgbase/blob/main/docs/query-language.md)
155
+ for the omgbase surface (targets, fields, scoping, execution model) and the
156
+ [`@omgbase/oqx` README](https://github.com/omgbase/omgbase/blob/main/packages/oqx/README.md)
157
+ for the language itself.
158
+
159
+ ## Where the truth lives
160
+
161
+ Code wins over prose. When in doubt, read these in the monorepo:
162
+
163
+ - `packages/core/src/index.ts` — the exported surface (this README's tables are derived from it).
164
+ - `packages/core/src/mcp/server.ts` — every MCP tool, its schema, and its inline description.
165
+ - `packages/core/src/core/store/schema.ts` — the SQLite DDL, `SCHEMA_VERSION`, and migrations.
166
+ - `packages/core/src/mutate/` — the six kernel ops, CAS, macros, and the whole-document planner.
167
+ - `packages/core/src/oqx-js/` — the OQX `DataContext` + planner; `corpus/oqx/README.md` has runnable query examples.
168
+ - `docs/` — as-built design references (`architecture.md`, `data-model.md`, `mutation-and-concurrency.md`, `graph-and-query.md`, `mcp-api.md`, `surface-map.md`); the repo-root `AGENTS.md` indexes them.
169
+
170
+ ## License
171
+
172
+ MIT
@@ -1,8 +1,13 @@
1
- import { runEval } from "../reconcile/eval/metrics.js";
1
+ import { runEval, type EvalMode } from "../reconcile/eval/metrics.js";
2
2
  export interface EvalCliOptions {
3
3
  n?: number;
4
4
  size?: number;
5
5
  intensity?: number;
6
+ mode?: EvalMode;
7
+ itemWords?: number;
8
+ contextSimFloor?: number;
9
+ thetaAccept?: number;
10
+ thetaSmall?: number;
6
11
  }
7
12
  export declare function formatReport(report: ReturnType<typeof runEval>): string;
8
13
  /** Run the eval and return the report + a pass/fail against release gates. */
@@ -2,7 +2,8 @@ import { runEval } from "../reconcile/eval/metrics.js";
2
2
  import { DEFAULT_CONFIG } from "../reconcile/types.js";
3
3
  export function formatReport(report) {
4
4
  const lines = [];
5
- lines.push(`matcher_v=${DEFAULT_CONFIG.matcherV} cases=${report.cases}`);
5
+ const mode = report.mode === "structured" ? ` mode=structured` : "";
6
+ lines.push(`matcher_v=${report.config.matcherV} cases=${report.cases}${mode}`);
6
7
  lines.push("");
7
8
  const row = (name, m) => `${name.padEnd(10)} precision=${m.precision.toFixed(4)} recall=${m.recall.toFixed(4)} true=${m.truePairs} carried=${m.carriedPairs}`;
8
9
  lines.push(row("OVERALL", report.overall));
@@ -12,13 +13,24 @@ export function formatReport(report) {
12
13
  }
13
14
  /** Run the eval and return the report + a pass/fail against release gates. */
14
15
  export function evalMatcher(opts = {}) {
15
- const runOpts = { config: DEFAULT_CONFIG };
16
+ const overrides = {};
17
+ if (opts.contextSimFloor !== undefined)
18
+ overrides.contextSimFloor = opts.contextSimFloor;
19
+ if (opts.thetaAccept !== undefined)
20
+ overrides.thetaAccept = opts.thetaAccept;
21
+ if (opts.thetaSmall !== undefined)
22
+ overrides.thetaSmall = opts.thetaSmall;
23
+ const runOpts = { config: DEFAULT_CONFIG, configOverrides: overrides };
16
24
  if (opts.n !== undefined)
17
25
  runOpts.n = opts.n;
18
26
  if (opts.size !== undefined)
19
27
  runOpts.size = opts.size;
20
28
  if (opts.intensity !== undefined)
21
29
  runOpts.intensity = opts.intensity;
30
+ if (opts.mode !== undefined)
31
+ runOpts.mode = opts.mode;
32
+ if (opts.itemWords !== undefined)
33
+ runOpts.itemWords = opts.itemWords;
22
34
  const report = runEval(runOpts);
23
35
  const gatesPass = checkGates(report);
24
36
  return { report, text: formatReport(report), gatesPass };
@@ -5,7 +5,31 @@ export declare function shortHash(hash: Buffer | string): string;
5
5
  /** raw_hash — sha256 of the exact raw source bytes (02 §1). */
6
6
  export declare function rawHash(raw: string): Buffer;
7
7
  export declare function normalizeText(raw: string): string;
8
- export declare function normalizeVisibleText(raw: string, type: string): string;
8
+ /** The minimal tree shape visibleText() needs; RawBlock, TreeInputBlock and FlatSource all satisfy it. */
9
+ export interface VisibleTextBlock {
10
+ type: string;
11
+ raw: string;
12
+ children: readonly VisibleTextBlock[];
13
+ }
14
+ /** Container blocks compose their text from children (spec/format §4.1). */
15
+ export declare function isTextContainer(type: string, hasChildren: boolean): boolean;
16
+ /** Blockquote depth of a block's children given the block's own depth. */
17
+ export declare function childQuoteDepth(type: string, quoteDepth: number): number;
18
+ /** Container rule: children's texts in order, joined by one space, empties skipped. */
19
+ export declare function joinVisibleTexts(texts: readonly string[]): string;
20
+ /**
21
+ * `text` for a block in its tree. `quoteDepth` is the number of blockquote
22
+ * ancestors of `block` itself (0 at the top level).
23
+ */
24
+ export declare function visibleText(block: VisibleTextBlock, quoteDepth?: number): string;
25
+ /**
26
+ * Leaf rule (spec/format §4.1 steps 1–7): strip up to `quoteDepth` blockquote
27
+ * markers from every line after the first, then the kind's own syntax, then
28
+ * normalize whitespace (trim each line with the JS trim set, collapse `[ \t]+`,
29
+ * drop empty lines, join with one space, NFC). Inline syntax is content.
30
+ * Call this only for leaves — containers go through visibleText().
31
+ */
32
+ export declare function normalizeVisibleText(raw: string, type: string, quoteDepth?: number): string;
9
33
  /** norm_hash — sha256 of the normalized text (02 §1, §5.2). */
10
34
  export declare function normHash(normalizedText: string): Buffer;
11
35
  export declare function canonicalAttrs(attrs: Record<string, unknown>): string;
@@ -29,18 +29,118 @@ export function normalizeText(raw) {
29
29
  .join(" ")
30
30
  .normalize("NFC");
31
31
  }
32
- // Type-aware visible text (02 §5.2). Strips block-level syntax markers that are
33
- // not content: ATX heading hashes and list/task markers. Inline markers stay.
34
- export function normalizeVisibleText(raw, type) {
35
- let s = raw;
36
- if (type === "heading") {
37
- s = s.replace(/^[ \t]*#{1,6}[ \t]+/, "").replace(/[ \t]+#*[ \t]*$/, "");
32
+ /** Container blocks compose their text from children (spec/format §4.1). */
33
+ export function isTextContainer(type, hasChildren) {
34
+ return type === "list" || type === "blockquote" || type === "table" || ((type === "list_item" || type === "task") && hasChildren);
35
+ }
36
+ /** Blockquote depth of a block's children given the block's own depth. */
37
+ export function childQuoteDepth(type, quoteDepth) {
38
+ return type === "blockquote" ? quoteDepth + 1 : quoteDepth;
39
+ }
40
+ /** Container rule: children's texts in order, joined by one space, empties skipped. */
41
+ export function joinVisibleTexts(texts) {
42
+ return texts.filter((t) => t.length > 0).join(" ");
43
+ }
44
+ /**
45
+ * `text` for a block in its tree. `quoteDepth` is the number of blockquote
46
+ * ancestors of `block` itself (0 at the top level).
47
+ */
48
+ export function visibleText(block, quoteDepth = 0) {
49
+ if (isTextContainer(block.type, block.children.length > 0)) {
50
+ const inner = childQuoteDepth(block.type, quoteDepth);
51
+ return joinVisibleTexts(block.children.map((c) => visibleText(c, inner)));
52
+ }
53
+ return normalizeVisibleText(block.raw, block.type, quoteDepth);
54
+ }
55
+ const LINE_ENDING = /\r\n|\r|\n/;
56
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
57
+ const QUOTE_MARKER = /^[ \t]*> ?/;
58
+ // A `|` is escaped iff preceded by an odd number of backslashes (GFM: `\|` is
59
+ // content, `\\|` is an escaped backslash followed by a delimiter).
60
+ function isEscapedAt(line, i) {
61
+ let n = 0;
62
+ for (let j = i - 1; j >= 0 && line[j] === "\\"; j--)
63
+ n++;
64
+ return n % 2 === 1;
65
+ }
66
+ // table_row: one leading `|`, one trailing unescaped `|` (each with surrounding
67
+ // spaces/tabs), then every remaining unescaped `|` becomes a space.
68
+ function stripTableRow(line) {
69
+ let s = line.replace(/^[ \t]*\|/, "");
70
+ const trimmedEnd = s.replace(/[ \t]+$/, "");
71
+ if (trimmedEnd.endsWith("|") && !isEscapedAt(trimmedEnd, trimmedEnd.length - 1))
72
+ s = trimmedEnd.slice(0, -1);
73
+ let out = "";
74
+ for (let i = 0; i < s.length; i++) {
75
+ out += s[i] === "|" && !isEscapedAt(s, i) ? " " : s[i];
76
+ }
77
+ return out;
78
+ }
79
+ /**
80
+ * Leaf rule (spec/format §4.1 steps 1–7): strip up to `quoteDepth` blockquote
81
+ * markers from every line after the first, then the kind's own syntax, then
82
+ * normalize whitespace (trim each line with the JS trim set, collapse `[ \t]+`,
83
+ * drop empty lines, join with one space, NFC). Inline syntax is content.
84
+ * Call this only for leaves — containers go through visibleText().
85
+ */
86
+ export function normalizeVisibleText(raw, type, quoteDepth = 0) {
87
+ let lines = raw.split(LINE_ENDING);
88
+ // 1. Blockquote markers on continuation lines (the first line starts at content).
89
+ if (quoteDepth > 0) {
90
+ for (let i = 1; i < lines.length; i++) {
91
+ let line = lines[i];
92
+ for (let q = 0; q < quoteDepth; q++) {
93
+ const m = QUOTE_MARKER.exec(line);
94
+ if (!m)
95
+ break;
96
+ line = line.slice(m[0].length);
97
+ }
98
+ lines[i] = line;
99
+ }
38
100
  }
39
- else if (type === "list_item" || type === "task") {
40
- // Strip the list marker and an optional GFM checkbox on the first line.
41
- s = s.replace(/^[ \t]*([-*+]|\d+[.)])[ \t]+/, "").replace(/^\[[ xX]\][ \t]+/, "");
101
+ // 2. Kind syntax.
102
+ switch (type) {
103
+ case "heading": {
104
+ if (lines.length > 1) {
105
+ lines.pop(); // setext: content lines + the underline
106
+ }
107
+ else {
108
+ // ATX: the opening run (which may be the whole line), then a closing
109
+ // run of `#` that is the entire remainder or follows a space/tab.
110
+ lines[0] = (lines[0] ?? "").replace(/^ {0,3}#{1,6}[ \t]*/, "").replace(/(?:^|[ \t]+)#+[ \t]*$/, "");
111
+ }
112
+ break;
113
+ }
114
+ case "frontmatter":
115
+ lines = lines.slice(1, -1);
116
+ break;
117
+ case "code_fence": {
118
+ const open = FENCE_OPEN.exec(lines[0] ?? "");
119
+ if (open) {
120
+ lines = lines.slice(1);
121
+ const fence = open[1];
122
+ const closer = new RegExp(`^ {0,3}\\${fence[0]}{${fence.length},}[ \\t]*$`);
123
+ if (lines.length > 0 && closer.test(lines[lines.length - 1]))
124
+ lines.pop();
125
+ }
126
+ break;
127
+ }
128
+ case "list_item":
129
+ case "task": {
130
+ lines[0] = (lines[0] ?? "").replace(/^[ \t]*([-*+]|[0-9]+[.)])[ \t]+/, "").replace(/^\[[ xX]\][ \t]+/, "");
131
+ break;
132
+ }
133
+ case "table_row":
134
+ lines = lines.map(stripTableRow);
135
+ break;
136
+ case "thematic_break":
137
+ lines = [];
138
+ break;
139
+ default:
140
+ break; // paragraph, html_block, opaque: nothing
42
141
  }
43
- return normalizeText(s);
142
+ // 3–7.
143
+ return normalizeText(lines.join("\n"));
44
144
  }
45
145
  /** norm_hash — sha256 of the normalized text (02 §1, §5.2). */
46
146
  export function normHash(normalizedText) {
@@ -1,5 +1,17 @@
1
1
  export type IdPrefix = "d" | "b" | "c" | "r" | "x" | "col" | "cp" | "e" | "rp" | "v" | "src";
2
2
  export declare function randomSuffix(len?: number): string;
3
+ /** A replacement id generator (spec/store §2.2): given a prefix, return the whole id. */
4
+ export type IdMinter = (prefix: IdPrefix) => string;
5
+ /** Install a replacement minter (null restores the CSPRNG default). */
6
+ export declare function setIdMinter(minter: IdMinter | null): void;
7
+ /** Run `body` with `minter` installed, restoring the previous minter afterwards (also on throw). */
8
+ export declare function withIdMinter<T>(minter: IdMinter, body: () => T): T;
9
+ /**
10
+ * The fixture minter (spec/store §2.2): a sequential per-prefix counter —
11
+ * `d_0, d_1, …`, `b_0, …`, each prefix counting from 0 independently. A fresh
12
+ * instance per case resets every counter.
13
+ */
14
+ export declare function sequentialMinter(): IdMinter;
3
15
  /** Mint an ID with the given prefix. Collision checking is the store's job. */
4
16
  export declare function mintId(prefix: IdPrefix): string;
5
17
  export declare function isValidId(id: string, prefix?: IdPrefix): boolean;
@@ -8,11 +8,53 @@ export function randomSuffix(len = ID_LEN) {
8
8
  s += ALPHABET[randomInt(ALPHABET.length)];
9
9
  return s;
10
10
  }
11
+ // The installed minter, or null for production (CSPRNG). spec/store §2.2: the
12
+ // store's tree hashes contain block ids, so a fixture runner installs a
13
+ // deterministic per-prefix counter here to make ids comparable across
14
+ // implementations. Every mint in the engine — documents, blocks (including
15
+ // the reconcile module's phase 7 mints), commits, revisions, repos, sources —
16
+ // goes through `mintId`, so one seam covers them all.
17
+ let installed = null;
18
+ /** Install a replacement minter (null restores the CSPRNG default). */
19
+ export function setIdMinter(minter) {
20
+ installed = minter;
21
+ }
22
+ /** Run `body` with `minter` installed, restoring the previous minter afterwards (also on throw). */
23
+ export function withIdMinter(minter, body) {
24
+ const previous = installed;
25
+ installed = minter;
26
+ try {
27
+ return body();
28
+ }
29
+ finally {
30
+ installed = previous;
31
+ }
32
+ }
33
+ /**
34
+ * The fixture minter (spec/store §2.2): a sequential per-prefix counter —
35
+ * `d_0, d_1, …`, `b_0, …`, each prefix counting from 0 independently. A fresh
36
+ * instance per case resets every counter.
37
+ */
38
+ export function sequentialMinter() {
39
+ const counters = new Map();
40
+ return (prefix) => {
41
+ const n = counters.get(prefix) ?? 0;
42
+ counters.set(prefix, n + 1);
43
+ return `${prefix}_${n}`;
44
+ };
45
+ }
11
46
  /** Mint an ID with the given prefix. Collision checking is the store's job. */
12
47
  export function mintId(prefix) {
48
+ if (installed)
49
+ return installed(prefix);
13
50
  return `${prefix}_${randomSuffix()}`;
14
51
  }
15
- const ID_RE = new RegExp(`^([a-z]+)_([${ALPHABET}]{${ID_LEN}})$`);
52
+ // Production ids carry exactly ID_LEN suffix characters; the fixture minter
53
+ // (spec/store §2.2) mints `d_0`, `b_12`, … — one to seven alphabet characters.
54
+ // The id-or-path dispatch (`findDocByRef`, `insert.doc`, history) must treat
55
+ // both as ids: nothing else in the system is spelled `<prefix>_<alnum>` (paths
56
+ // carry an extension), so accepting the shorter suffix loses no path.
57
+ const ID_RE = new RegExp(`^([a-z]+)_([${ALPHABET}]{1,${ID_LEN}})$`);
16
58
  export function isValidId(id, prefix) {
17
59
  const m = ID_RE.exec(id);
18
60
  if (!m)
@@ -1,6 +1,6 @@
1
1
  import { Store } from "./store/store.js";
2
2
  import { type TreeInputBlock } from "./store/writers.js";
3
- import type { RawBlock } from "./parse/types.js";
3
+ import type { RawBlock, BlockTree } from "./parse/types.js";
4
4
  export interface DispositionRow {
5
5
  blockId: string;
6
6
  kind: string;
@@ -24,8 +24,22 @@ export type IdResolver = (rest: RawBlock[], docId: string | null) => {
24
24
  dispositions: DispositionRow[];
25
25
  deleted: string[];
26
26
  consumedPool?: string[];
27
+ /** Ids carried INTO this document from another one in the same checkpoint
28
+ * (cross-document moves, reconciliation-spec §8). The other doc may still hold
29
+ * a `blocks` row for them (it commits later in the batch, or was tombstoned);
30
+ * ingest evicts those rows so the id can be re-homed regardless of order. */
31
+ crossDocIds?: string[];
27
32
  extractEdges?: (docId: string, frontmatter: Record<string, unknown>) => ResolvedEdgeRow[];
28
33
  };
34
+ /** The parse step of ingest, exported so a batch caller can parse + reconcile
35
+ * every document of a checkpoint BEFORE committing any (the cross-document
36
+ * phase needs all per-doc results first) and hand the tree back via
37
+ * `opts.parsed`. Frontmatter is split off: `rest` is what the IdResolver sees. */
38
+ export declare function parseForIngest(path: string, content: string): {
39
+ tree: BlockTree;
40
+ fmBlock: RawBlock | null;
41
+ rest: RawBlock[];
42
+ };
29
43
  export interface IngestResult {
30
44
  docId: string;
31
45
  commitId: string;
@@ -37,7 +51,13 @@ export interface IngestResult {
37
51
  /** Ingest a single file's content into the store as one observed commit. */
38
52
  export declare function ingestFile(store: Store, repoId: string, path: string, content: string, opts?: {
39
53
  ts?: string;
40
- origin?: "observed" | "import";
54
+ /** Commit provenance. `observed` = a file edit picked up by sync; `api` = an intent write (apply/docs_*), which should also carry `actor`/`reason`; `import` = bulk ingest with no retro history. */
55
+ origin?: "observed" | "import" | "api";
56
+ actor?: string | null;
57
+ reason?: string | null;
41
58
  resolveIds?: IdResolver;
42
59
  format?: string;
60
+ /** A tree already parsed from `content` (via `parseForIngest`) — skips the
61
+ * re-parse. The caller guarantees it is the parse of these exact bytes. */
62
+ parsed?: BlockTree;
43
63
  }): IngestResult;