@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.
- package/README.md +172 -0
- package/dist/src/cli/eval-matcher.d.ts +6 -1
- package/dist/src/cli/eval-matcher.js +14 -2
- package/dist/src/core/hash.d.ts +25 -1
- package/dist/src/core/hash.js +110 -10
- package/dist/src/core/ids.d.ts +12 -0
- package/dist/src/core/ids.js +43 -1
- package/dist/src/core/ingest.d.ts +22 -2
- package/dist/src/core/ingest.js +92 -18
- package/dist/src/core/parse/parse.d.ts +1 -1
- package/dist/src/core/parse/parse.js +31 -9
- package/dist/src/core/store/nodes.d.ts +13 -0
- package/dist/src/core/store/nodes.js +24 -0
- package/dist/src/core/utf8.d.ts +29 -0
- package/dist/src/core/utf8.js +96 -0
- package/dist/src/format/adapter.d.ts +7 -1
- package/dist/src/format/markdown.js +9 -18
- package/dist/src/graph/history.d.ts +6 -0
- package/dist/src/graph/history.js +13 -2
- package/dist/src/index.d.ts +8 -5
- package/dist/src/index.js +8 -5
- package/dist/src/mcp/graph.js +8 -4
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +17 -6
- package/dist/src/mcp/server.js +12 -7
- package/dist/src/mutate/apply.d.ts +7 -0
- package/dist/src/mutate/apply.js +17 -7
- package/dist/src/mutate/docs.d.ts +2 -0
- package/dist/src/mutate/docs.js +10 -6
- package/dist/src/mutate/known-ids.js +9 -1
- package/dist/src/mutate/macros.js +9 -2
- package/dist/src/mutate/ops.d.ts +2 -1
- package/dist/src/mutate/ops.js +24 -2
- package/dist/src/mutate/plan-update.d.ts +7 -1
- package/dist/src/mutate/plan-update.js +3 -1
- package/dist/src/oqx-js/context.js +43 -6
- package/dist/src/reconcile/eval/generator.d.ts +24 -1
- package/dist/src/reconcile/eval/generator.js +248 -0
- package/dist/src/reconcile/eval/metrics.d.ts +33 -9
- package/dist/src/reconcile/eval/metrics.js +55 -32
- package/dist/src/reconcile/flatten.js +7 -5
- package/dist/src/reconcile/phase5.d.ts +2 -1
- package/dist/src/reconcile/phase5.js +27 -10
- package/dist/src/reconcile/phase6a.js +17 -5
- package/dist/src/reconcile/phases.d.ts +44 -6
- package/dist/src/reconcile/phases.js +0 -0
- package/dist/src/reconcile/reconcile.js +16 -9
- package/dist/src/reconcile/types.d.ts +4 -1
- package/dist/src/reconcile/types.js +11 -2
- package/dist/src/search/external.d.ts +10 -0
- package/dist/src/search/external.js +18 -1
- package/dist/src/search/provider.d.ts +7 -3
- package/dist/src/search/provider.js +8 -7
- package/dist/src/search/rrf.js +3 -0
- package/dist/src/search/vector.js +21 -6
- package/dist/src/sync/attach.d.ts +7 -2
- package/dist/src/sync/attach.js +10 -8
- package/dist/src/sync/checkpoint.d.ts +18 -1
- package/dist/src/sync/checkpoint.js +31 -29
- package/dist/src/sync/driver.d.ts +15 -4
- package/dist/src/sync/driver.js +18 -43
- package/dist/src/sync/external-source.js +7 -1
- package/dist/src/sync/freshness.d.ts +65 -14
- package/dist/src/sync/freshness.js +107 -96
- package/dist/src/sync/fs-util.d.ts +19 -0
- package/dist/src/sync/fs-util.js +24 -1
- package/dist/src/sync/observe.d.ts +53 -5
- package/dist/src/sync/observe.js +162 -21
- package/dist/src/sync/reconciling-ingest.d.ts +42 -2
- package/dist/src/sync/reconciling-ingest.js +82 -10
- package/dist/src/sync/recovery.d.ts +5 -1
- package/dist/src/sync/recovery.js +8 -8
- package/dist/src/sync/workspace.d.ts +11 -3
- package/dist/src/sync/workspace.js +39 -30
- package/dist/src/sync/writer-lock.js +16 -26
- 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
|
-
|
|
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
|
|
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 };
|
package/dist/src/core/hash.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/core/hash.js
CHANGED
|
@@ -29,18 +29,118 @@ export function normalizeText(raw) {
|
|
|
29
29
|
.join(" ")
|
|
30
30
|
.normalize("NFC");
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/src/core/ids.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/core/ids.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|