@omgbase/core 0.23.0 → 0.27.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 (45) hide show
  1. package/dist/src/core/edit-script.d.ts +15 -0
  2. package/dist/src/core/edit-script.js +72 -0
  3. package/dist/src/core/ingest.js +42 -12
  4. package/dist/src/core/read/document.d.ts +7 -0
  5. package/dist/src/core/read/document.js +38 -17
  6. package/dist/src/core/store/archive.d.ts +28 -0
  7. package/dist/src/core/store/archive.js +106 -17
  8. package/dist/src/core/store/delta.d.ts +131 -0
  9. package/dist/src/core/store/delta.js +470 -0
  10. package/dist/src/core/store/gc.d.ts +10 -0
  11. package/dist/src/core/store/gc.js +23 -7
  12. package/dist/src/core/store/retention.d.ts +1 -1
  13. package/dist/src/core/store/retention.js +3 -17
  14. package/dist/src/core/store/schema.d.ts +1 -1
  15. package/dist/src/core/store/schema.js +2 -1
  16. package/dist/src/core/store/store.js +7 -0
  17. package/dist/src/core/store/writers.d.ts +42 -6
  18. package/dist/src/core/store/writers.js +67 -25
  19. package/dist/src/format/json.js +5 -0
  20. package/dist/src/format/yaml.js +15 -3
  21. package/dist/src/graph/history.d.ts +1 -15
  22. package/dist/src/graph/history.js +14 -74
  23. package/dist/src/mcp/arguments.d.ts +11 -0
  24. package/dist/src/mcp/arguments.js +47 -4
  25. package/dist/src/mcp/errors.js +1 -1
  26. package/dist/src/mcp/reference.d.ts +1 -1
  27. package/dist/src/mcp/reference.js +29 -9
  28. package/dist/src/mcp/server.js +4 -2
  29. package/dist/src/mutate/effects.js +87 -13
  30. package/dist/src/mutate/frontmatter-parts.d.ts +71 -0
  31. package/dist/src/mutate/frontmatter-parts.js +237 -0
  32. package/dist/src/mutate/load.js +9 -9
  33. package/dist/src/mutate/select.js +14 -4
  34. package/dist/src/oqx-js/context.js +32 -3
  35. package/dist/src/oqx-js/names.js +1 -1
  36. package/dist/src/oqx-js/planner.js +11 -7
  37. package/dist/src/oqx-js/run.d.ts +8 -0
  38. package/dist/src/oqx-js/run.js +103 -2
  39. package/dist/src/oqx-js/sql/translate.js +33 -6
  40. package/dist/src/oqx-js/store-index.js +1 -1
  41. package/dist/src/search/cel/parser.d.ts +3 -1
  42. package/dist/src/search/cel/parser.js +4 -1
  43. package/dist/src/spec-versions.d.ts +5 -5
  44. package/dist/src/spec-versions.js +4 -4
  45. package/package.json +2 -2
@@ -114,7 +114,7 @@ export declare class ReachMarker {
114
114
  private readonly treeSizes;
115
115
  private readonly blobSizes;
116
116
  constructor(store: Store);
117
- /** Mark every tree node and blob reachable from the given roots and frontmatter blobs into `trees`/`blobs` (hex). */
117
+ /** Mark every tree node and blob the given revisions name EXPLICITLY (spec/store §7, 16.0: a `{`-node's own entries, not its base's) into `trees`/`blobs` (hex). */
118
118
  mark(revs: {
119
119
  root_tree: Buffer | null;
120
120
  frontmatter_blob: Buffer | null;
@@ -1,4 +1,5 @@
1
1
  import { referencePath } from "../paths.js";
2
+ import { explicitWalker } from "./gc.js";
2
3
  export const LADDER_KEEPS = ["one per day", "one per week", "one per month", "one per year"];
3
4
  /** The defaults (spec/store §7): a month hot, then one a day for a year, then one a month; 200 per document; api commits with a reason. */
4
5
  export const DEFAULT_RETENTION = {
@@ -222,24 +223,9 @@ export class ReachMarker {
222
223
  this.treeNode = store.db.prepare("SELECT entries FROM tree_nodes WHERE hash = ?");
223
224
  this.blobSize = store.db.prepare("SELECT size FROM blobs WHERE hash = ?");
224
225
  }
225
- /** Mark every tree node and blob reachable from the given roots and frontmatter blobs into `trees`/`blobs` (hex). */
226
+ /** Mark every tree node and blob the given revisions name EXPLICITLY (spec/store §7, 16.0: a `{`-node's own entries, not its base's) into `trees`/`blobs` (hex). */
226
227
  mark(revs, trees, blobs) {
227
- const walk = (hashHex) => {
228
- if (trees.has(hashHex))
229
- return;
230
- trees.add(hashHex);
231
- const node = this.treeNode.get(Buffer.from(hashHex, "hex"));
232
- if (!node)
233
- return;
234
- this.treeSizes.set(hashHex, Buffer.byteLength(node.entries));
235
- for (const [, rawHashHex, childHashHex, , , triviaHashHex] of JSON.parse(node.entries)) {
236
- blobs.add(rawHashHex);
237
- if (triviaHashHex)
238
- blobs.add(triviaHashHex);
239
- if (childHashHex)
240
- walk(childHashHex);
241
- }
242
- };
228
+ const walk = explicitWalker(this.store.db, trees, blobs, (hashHex, entries) => this.treeSizes.set(hashHex, Buffer.byteLength(entries)));
243
229
  for (const r of revs) {
244
230
  if (r.root_tree)
245
231
  walk(r.root_tree.toString("hex"));
@@ -1,4 +1,4 @@
1
- export declare const SCHEMA_VERSION = 15;
1
+ export declare const SCHEMA_VERSION = 16;
2
2
  export declare const FILE_STATS_DDL = "\nCREATE TABLE IF NOT EXISTS file_stats (\n repo_id TEXT NOT NULL,\n path TEXT NOT NULL,\n mtime_ns INTEGER NOT NULL,\n size INTEGER NOT NULL,\n hash BLOB NOT NULL,\n PRIMARY KEY (repo_id, path)\n);\n";
3
3
  export declare const PROPERTIES_DDL = "\nCREATE TABLE IF NOT EXISTS properties (\n prop_id TEXT PRIMARY KEY,\n repo_id TEXT NOT NULL,\n doc_id TEXT NOT NULL,\n block_id TEXT, -- NULL = document-scoped (frontmatter, computed)\n source TEXT NOT NULL CHECK (source IN ('frontmatter','inline','computed')),\n key TEXT NOT NULL, -- dotted, flattened: \"layer\",\"meta.owner\"; computed carry $: \"$title\"\n card TEXT NOT NULL CHECK (card IN ('scalar','list')),\n ord INTEGER NOT NULL DEFAULT 0,\n val_text TEXT,\n val_num REAL,\n val_bool INTEGER,\n val_json TEXT,\n type TEXT NOT NULL CHECK (type IN ('string','number','bool','null','json')),\n created_commit TEXT NOT NULL,\n deleted_commit TEXT\n);\nCREATE INDEX IF NOT EXISTS idx_props_doc ON properties(doc_id) WHERE deleted_commit IS NULL;\nCREATE INDEX IF NOT EXISTS idx_props_key_text ON properties(repo_id, key, val_text) WHERE deleted_commit IS NULL;\nCREATE INDEX IF NOT EXISTS idx_props_key_num ON properties(repo_id, key, val_num) WHERE deleted_commit IS NULL;\nCREATE INDEX IF NOT EXISTS idx_props_src_key ON properties(repo_id, source, key) WHERE deleted_commit IS NULL;\n";
4
4
  export declare const SYNC_DDL = "\nCREATE TABLE IF NOT EXISTS adapters (\n name TEXT PRIMARY KEY, -- workspace-unique adapter name (e.g. \"fs\")\n command TEXT NOT NULL, -- argv[0] to spawn (e.g. \"omgbase-fs-adapter\")\n args TEXT NOT NULL DEFAULT '[]' -- fixed leading args (JSON string[])\n);\n\nCREATE TABLE IF NOT EXISTS sources (\n source_id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE, -- workspace-unique source name\n adapter TEXT NOT NULL REFERENCES adapters(name),\n config TEXT NOT NULL DEFAULT '{}', -- JSON object \u2192 rendered to flags (13 \u00A73.1)\n env TEXT NOT NULL DEFAULT '{}' -- JSON object \u2192 spawn env (secrets; 13 \u00A73.2)\n);\n\nCREATE TABLE IF NOT EXISTS attachments (\n repo_id TEXT NOT NULL REFERENCES repos(repo_id),\n source_id TEXT NOT NULL REFERENCES sources(source_id),\n PRIMARY KEY (repo_id, source_id)\n);\n\nCREATE TABLE IF NOT EXISTS sync_state (\n repo_id TEXT NOT NULL,\n source_id TEXT NOT NULL,\n path TEXT NOT NULL, -- '' reserved for the attachment-level cursor row\n revision TEXT, -- last-observed source revision for this path\n cursor TEXT, -- last poll/webhook cursor (attachment-level row)\n PRIMARY KEY (repo_id, source_id, path)\n);\n";
@@ -1,7 +1,7 @@
1
1
  // Full DDL — data-model §3 (durable) + §4 (derived). SQLite dialect.
2
2
  // Kept as one string so migrations and rebuild-index can apply it verbatim.
3
3
  // No dialect-specific SQL leaks above the store module (02 §8).
4
- export const SCHEMA_VERSION = 15;
4
+ export const SCHEMA_VERSION = 16;
5
5
  // file_stats (02 §4; derived, rebuildable by a full re-stat) backs the CLI
6
6
  // freshness sweep (11 §3.3): (mtime_ns, size) cheap-change detection so a
7
7
  // one-shot command re-ingests only files that changed on disk since last ingest.
@@ -202,6 +202,7 @@ export const MIGRATIONS = {
202
202
  13: "", // drop repos.root_path (migrate → fs sources), handled programmatically in store.ts
203
203
  14: PENDING_MATERIALIZATIONS_DDL, // the api path's recorded file intent (spec/store §5.7)
204
204
  15: "", // revisions.root_tree/frontmatter_blob nullable + `archive`, create `archives` (spec/store §7 "Retention"), handled programmatically in store.ts
205
+ 16: "", // no DDL: delta tree nodes (spec/store §4.1 "Delta nodes", §3.4 step 16) — every 15.x node is already a valid checkpoint, so the step only moves user_version; handled in store.ts
205
206
  };
206
207
  export const DDL = /* sql */ `
207
208
  -- ---- durable tables (02 §3) --------------------------------------------------
@@ -102,6 +102,13 @@ export class Store {
102
102
  this.migrateV15();
103
103
  continue;
104
104
  }
105
+ // v16 (spec/store §3.4 step 16, §4.1 "Delta nodes"): no DDL and no data
106
+ // pass. A 15.x store holds only full (`[`-form) nodes and raw frontmatter
107
+ // blobs, which ARE the 16.0 checkpoint forms, so every existing revision
108
+ // is a checkpoint; deltas appear with the next commit. The major exists
109
+ // because a 15.x engine cannot read a `{`-form node (§1 step 3 keeps it out).
110
+ if (v === 16)
111
+ continue;
105
112
  const ddl = MIGRATIONS[v];
106
113
  if (!ddl)
107
114
  throw new Error(`no migration to schema v${v}`);
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "better-sqlite3";
2
2
  import { type TreeEntry } from "../hash.js";
3
3
  import type { RawBlock } from "../parse/types.js";
4
+ import { type ResolvedTree } from "./delta.js";
4
5
  /** A block with a minted/assigned id, ready to encode into the tree. */
5
6
  export interface TreeInputBlock {
6
7
  blockId: string;
@@ -20,15 +21,40 @@ export interface TreeInputBlock {
20
21
  export declare function assignIds(blocks: RawBlock[]): TreeInputBlock[];
21
22
  /** Content-address raw text into blobs; returns hex hash. Dedups. */
22
23
  export declare function putBlob(db: Database, text: string): string;
23
- /** Content-address a tree node's entries; returns hex hash. Dedups. */
24
+ /** Content-address a node's text (a `[`-form or a `{"d":…}` delta, spec/store §4.1); returns hex hash. Dedups. */
25
+ export declare function putNodeText(db: Database, text: string): string;
26
+ /** Content-address a tree node's entries (full form); returns hex hash. Dedups. */
24
27
  export declare function putTreeNode(db: Database, entries: TreeEntry[]): string;
25
28
  /**
26
- * Encode a list of sibling blocks into a Merkle tree node bottom-up, writing
27
- * blobs (raw + trivia) and child tree nodes. Returns the node's hex hash.
28
- * Structural sharing: unchanged subtrees produce identical hashes and are not
29
- * re-inserted.
29
+ * The revision context a tree is written in (spec/store §4.4, 16.0): the
30
+ * previous revision's resolved tree (`null` when there is none, or it is
31
+ * archived — then every node is full), and whether this revision is a
32
+ * checkpoint. Without a context `writeBlockTree` writes 15.x full nodes.
30
33
  */
31
- export declare function writeBlockTree(db: Database, blocks: TreeInputBlock[]): string;
34
+ export interface TreeWriteContext {
35
+ base: ResolvedTree | null;
36
+ checkpoint: boolean;
37
+ }
38
+ /** spec/store §4.4: a revision is a checkpoint by cadence, or when it has no live predecessor. */
39
+ export declare function isCheckpointRevision(seq: number, previous: {
40
+ archive: string | null;
41
+ } | undefined): boolean;
42
+ /**
43
+ * Encode a list of sibling blocks into Merkle tree nodes bottom-up, writing
44
+ * blobs (raw + trivia) and child nodes. Returns the root node's hex hash.
45
+ * Structural sharing: an unchanged subtree whose base node is self-contained
46
+ * is not re-written (the base's hash is reused); otherwise, in a non-checkpoint
47
+ * revision, a list is written as the smaller of its delta against the same
48
+ * list of the previous revision and its full form (spec/store §4.1, §4.4).
49
+ */
50
+ export declare function writeBlockTree(db: Database, blocks: TreeInputBlock[], ctx?: TreeWriteContext): string;
51
+ /**
52
+ * The frontmatter blob of a revision (spec/store §5.4 step 1, 16.0): the raw
53
+ * itself at a checkpoint or when the previous revision had none; else the
54
+ * smaller of the line delta against the previous raw and the raw. Returns the
55
+ * blob's hex hash.
56
+ */
57
+ export declare function writeFrontmatterBlob(db: Database, raw: string, baseRaw: string | null, checkpoint: boolean): string;
32
58
  export interface NewCommitInput {
33
59
  repoId: string;
34
60
  ts: string;
@@ -50,7 +76,17 @@ export interface NewRevisionInput {
50
76
  renderedHash: Buffer;
51
77
  path: string;
52
78
  commitId: string;
79
+ /** The per-document seq when the caller computed it already (ingest needs it before the tree is written); `1 + max(seq)` otherwise. */
80
+ seq?: number;
53
81
  }
82
+ /** The previous revision of a document (its greatest seq), or undefined — the base a delta is written against. */
83
+ export declare function previousRevision(db: Database, docId: string): {
84
+ rev_id: string;
85
+ seq: number;
86
+ root_tree: Buffer | null;
87
+ frontmatter_blob: Buffer | null;
88
+ archive: string | null;
89
+ } | undefined;
54
90
  /** Append a revision; assigns the per-document monotonic seq. */
55
91
  export declare function writeRevision(db: Database, input: NewRevisionInput): {
56
92
  revId: string;
@@ -1,5 +1,6 @@
1
1
  import { sha256, serializeTreeEntries } from "../hash.js";
2
2
  import { mintId } from "../ids.js";
3
+ import { byteLength, encodeBodyDelta, encodeFrontmatterDelta, entryText, fullNodeText, isCheckpointSeq, } from "./delta.js";
3
4
  /** Assign fresh minted ids to a parsed block tree (Stage-1 re-mint path). */
4
5
  export function assignIds(blocks) {
5
6
  return blocks.map((b) => ({
@@ -19,34 +20,69 @@ export function putBlob(db, text) {
19
20
  db.prepare("INSERT OR IGNORE INTO blobs (hash, bytes, size) VALUES (?, ?, ?)").run(hash, bytes, bytes.length);
20
21
  return hash.toString("hex");
21
22
  }
22
- /** Content-address a tree node's entries; returns hex hash. Dedups. */
23
- export function putTreeNode(db, entries) {
24
- const serialized = serializeTreeEntries(entries);
25
- const hash = sha256(serialized);
26
- db.prepare("INSERT OR IGNORE INTO tree_nodes (hash, entries) VALUES (?, ?)").run(hash, serialized);
23
+ /** Content-address a node's text (a `[`-form or a `{"d":…}` delta, spec/store §4.1); returns hex hash. Dedups. */
24
+ export function putNodeText(db, text) {
25
+ const hash = sha256(text);
26
+ db.prepare("INSERT OR IGNORE INTO tree_nodes (hash, entries) VALUES (?, ?)").run(hash, text);
27
27
  return hash.toString("hex");
28
28
  }
29
+ /** Content-address a tree node's entries (full form); returns hex hash. Dedups. */
30
+ export function putTreeNode(db, entries) {
31
+ return putNodeText(db, serializeTreeEntries(entries));
32
+ }
33
+ /** spec/store §4.4: a revision is a checkpoint by cadence, or when it has no live predecessor. */
34
+ export function isCheckpointRevision(seq, previous) {
35
+ return isCheckpointSeq(seq) || previous === undefined || previous.archive !== null;
36
+ }
29
37
  /**
30
- * Encode a list of sibling blocks into a Merkle tree node bottom-up, writing
31
- * blobs (raw + trivia) and child tree nodes. Returns the node's hex hash.
32
- * Structural sharing: unchanged subtrees produce identical hashes and are not
33
- * re-inserted.
38
+ * Encode a list of sibling blocks into Merkle tree nodes bottom-up, writing
39
+ * blobs (raw + trivia) and child nodes. Returns the root node's hex hash.
40
+ * Structural sharing: an unchanged subtree whose base node is self-contained
41
+ * is not re-written (the base's hash is reused); otherwise, in a non-checkpoint
42
+ * revision, a list is written as the smaller of its delta against the same
43
+ * list of the previous revision and its full form (spec/store §4.1, §4.4).
34
44
  */
35
- export function writeBlockTree(db, blocks) {
36
- const entries = blocks.map((b) => {
45
+ export function writeBlockTree(db, blocks, ctx) {
46
+ const context = ctx ?? { base: null, checkpoint: true };
47
+ return writeList(db, blocks, null, context);
48
+ }
49
+ function writeList(db, blocks, parent, ctx) {
50
+ const texts = blocks.map((b) => {
37
51
  const rawHashHex = putBlob(db, b.raw);
38
52
  const triviaHashHex = b.trivia.length > 0 ? putBlob(db, b.trivia) : null;
39
- const childTreeHashHex = b.children.length > 0 ? writeBlockTree(db, b.children) : null;
40
- return {
41
- blockId: b.blockId,
42
- rawHashHex,
43
- childTreeHashHex,
44
- type: b.type,
45
- attrs: b.attrs,
46
- triviaHashHex,
47
- };
53
+ const childTreeHashHex = b.children.length > 0 ? writeList(db, b.children, b.blockId, ctx) : null;
54
+ const entry = [b.blockId, rawHashHex, childTreeHashHex, b.type, b.attrs, triviaHashHex];
55
+ return entryText(entry);
48
56
  });
49
- return putTreeNode(db, entries);
57
+ const full = fullNodeText(texts);
58
+ const base = ctx.base ? (parent === null ? ctx.base.root : ctx.base.byBlock.get(parent)) : undefined;
59
+ // An unchanged list reuses the base's node only when that node is context-free.
60
+ if (base && base.selfContained && sameTexts(base.texts, texts))
61
+ return base.hash;
62
+ if (ctx.checkpoint || !base)
63
+ return putNodeText(db, full);
64
+ const delta = encodeBodyDelta(base.texts, texts);
65
+ return putNodeText(db, byteLength(delta) < byteLength(full) ? delta : full);
66
+ }
67
+ function sameTexts(a, b) {
68
+ if (a.length !== b.length)
69
+ return false;
70
+ for (let i = 0; i < a.length; i++)
71
+ if (a[i] !== b[i])
72
+ return false;
73
+ return true;
74
+ }
75
+ /**
76
+ * The frontmatter blob of a revision (spec/store §5.4 step 1, 16.0): the raw
77
+ * itself at a checkpoint or when the previous revision had none; else the
78
+ * smaller of the line delta against the previous raw and the raw. Returns the
79
+ * blob's hex hash.
80
+ */
81
+ export function writeFrontmatterBlob(db, raw, baseRaw, checkpoint) {
82
+ if (checkpoint || baseRaw === null)
83
+ return putBlob(db, raw);
84
+ const delta = encodeFrontmatterDelta(baseRaw, raw);
85
+ return putBlob(db, byteLength(delta) < byteLength(raw) ? delta : raw);
50
86
  }
51
87
  /** Append a commit; assigns the per-repo total-order seq. */
52
88
  export function newCommit(db, input) {
@@ -68,23 +104,29 @@ export function newCommit(db, input) {
68
104
  });
69
105
  return { commitId, seq: row.seq };
70
106
  }
107
+ /** The previous revision of a document (its greatest seq), or undefined — the base a delta is written against. */
108
+ export function previousRevision(db, docId) {
109
+ return db
110
+ .prepare("SELECT rev_id, seq, root_tree, frontmatter_blob, archive FROM revisions WHERE doc_id = ? ORDER BY seq DESC LIMIT 1")
111
+ .get(docId);
112
+ }
71
113
  /** Append a revision; assigns the per-document monotonic seq. */
72
114
  export function writeRevision(db, input) {
73
115
  const revId = mintId("r");
74
- const row = db
116
+ const seq = input.seq ?? db
75
117
  .prepare("SELECT COALESCE(MAX(seq),0)+1 AS seq FROM revisions WHERE doc_id = ?")
76
- .get(input.docId);
118
+ .get(input.docId).seq;
77
119
  db.prepare(`INSERT INTO revisions (rev_id, doc_id, seq, root_tree, frontmatter_blob, rendered_hash, path, commit_id)
78
120
  VALUES (@revId, @docId, @seq, @rootTree, @frontmatterBlob, @renderedHash, @path, @commitId)`).run({
79
121
  revId,
80
122
  docId: input.docId,
81
- seq: row.seq,
123
+ seq,
82
124
  rootTree: Buffer.from(input.rootTreeHex, "hex"),
83
125
  frontmatterBlob: input.frontmatterBlobHex ? Buffer.from(input.frontmatterBlobHex, "hex") : null,
84
126
  renderedHash: input.renderedHash,
85
127
  path: input.path,
86
128
  commitId: input.commitId,
87
129
  });
88
- return { revId, seq: row.seq };
130
+ return { revId, seq };
89
131
  }
90
132
  //# sourceMappingURL=writers.js.map
@@ -308,6 +308,11 @@ export const jsonAdapter = {
308
308
  extractJsonEdges(metadata, "", edges, new Set());
309
309
  return edges;
310
310
  },
311
+ // The document's authored mapping (spec/properties §3.4, 1.2): the root object
312
+ // of a well-formed JSON text; a non-object root (an array, a scalar), a parse
313
+ // error or an empty file yields null — no frontmatter rows. `JSON.parse` is
314
+ // strict (no comments, no trailing commas, no BOM); a repeated key keeps the
315
+ // LAST value at the FIRST position (the host's object semantics, §8).
311
316
  extractMetadata(source) {
312
317
  try {
313
318
  const parsed = JSON.parse(source);
@@ -1,7 +1,7 @@
1
1
  // YAML format adapter. Decomposes YAML files into blocks with yaml:-prefixed
2
2
  // kinds, extracts $ref/extends/$schema edges, and projects top-level mapping
3
3
  // entries as metadata.
4
- import { parseDocument, isMap, isSeq, isScalar, } from "yaml";
4
+ import { parseAllDocuments, parseDocument, isMap, isSeq, isScalar, } from "yaml";
5
5
  import { AdapterCapability } from "./adapter.js";
6
6
  import { normalizeText } from "../core/hash.js";
7
7
  export const YAML_FORMAT = "yaml";
@@ -358,10 +358,22 @@ export const yamlAdapter = {
358
358
  walk(blocks);
359
359
  return nodes;
360
360
  },
361
+ // The document's authored mapping (spec/properties §3.4, 1.2): the FIRST
362
+ // document of the stream, parsed under the same YAML 1.2 core-schema contract
363
+ // as a Markdown frontmatter fence (§4), when it parses without error and its
364
+ // root is a mapping. A stream's later documents are ignored (a multi-document
365
+ // file is first-document-only, by the spec); a syntax error — including a
366
+ // duplicate key — a non-mapping root (a sequence, a scalar) or an empty file
367
+ // yields null: no frontmatter rows, exactly as a malformed fence yields none.
368
+ // `parseDocument` is NOT used here: it is lenient (it records errors on the
369
+ // document and still returns contents), which §3.4 refuses.
361
370
  extractMetadata(source) {
362
371
  try {
363
- const doc = parseDocument(source);
364
- const obj = doc.toJS();
372
+ const docs = parseAllDocuments(source);
373
+ const first = Array.isArray(docs) ? docs[0] : undefined;
374
+ if (!first || first.errors.length > 0)
375
+ return null;
376
+ const obj = first.toJS();
365
377
  if (obj && typeof obj === "object" && !Array.isArray(obj)) {
366
378
  return obj;
367
379
  }
@@ -64,21 +64,7 @@ export declare function docDiffUnified(store: Store, docId: string, opts?: {
64
64
  * a single special case: the empty text has *no* lines (an empty file is zero
65
65
  * lines, not one empty line), so a diff from/to nothing is `@@ -0,0 +1,n @@`. */
66
66
  export declare function diffLines(text: string): string[];
67
- /** One step of the edit script: `keep` consumes a line from both sides,
68
- * `delete` one from the old text, `insert` one from the new text. */
69
- export interface EditOp {
70
- kind: "keep" | "delete" | "insert";
71
- line: string;
72
- }
73
- /**
74
- * Myers' O(ND) shortest edit script (forward, with a per-`d` trace for the
75
- * backtrack). `V[k]` is the furthest x on diagonal `k = x - y` reachable with
76
- * `d` edits. The canonical tie rule, identical in both engines: at each step
77
- * take the diagonal from `k+1` (moving down — an insertion of `b[y]`) when
78
- * `k == -d || (k != d && V[k-1] < V[k+1])`, else from `k-1` (moving right —
79
- * a deletion of `a[x]`). On a tie (`V[k-1] == V[k+1]`) that is the deletion.
80
- */
81
- export declare function myersScript(a: string[], b: string[]): EditOp[];
67
+ export { myersScript, type EditOp } from "../core/edit-script.js";
82
68
  /**
83
69
  * The unified diff of two texts (spec/surface §3): hunks of `DIFF_CONTEXT` (3)
84
70
  * lines of context; a change group extends to include the next change when
@@ -4,6 +4,8 @@ import { isValidId } from "../core/ids.js";
4
4
  import { EngineError } from "../mcp/errors.js";
5
5
  import { referencePath, storagePath } from "../core/paths.js";
6
6
  import { revisionBody } from "../core/store/archive.js";
7
+ import { myersScript } from "../core/edit-script.js";
8
+ import { TreeResolver } from "../core/store/delta.js";
7
9
  /** blocks_history: a block's biography from block_changes + commits (06 §3). */
8
10
  export function historyNode(store, blockId, opts = {}) {
9
11
  const limit = opts.limit ?? 100;
@@ -64,20 +66,19 @@ function blocksAtRevision(store, docId, revId) {
64
66
  throw new RevisionNotFound(docId, revId);
65
67
  const out = new Map();
66
68
  const blob = store.db.prepare(`SELECT bytes FROM ${rev.prefix}blobs WHERE hash = ?`);
67
- const treeNode = store.db.prepare(`SELECT entries FROM ${rev.prefix}tree_nodes WHERE hash = ?`);
68
- const walk = (treeHashHex) => {
69
- const node = treeNode.get(Buffer.from(treeHashHex, "hex"));
70
- if (!node)
71
- return;
72
- const entries = JSON.parse(node.entries);
73
- for (const [blockId, rawHashHex, childHashHex] of entries) {
69
+ // Every list resolved in the revision's context (spec/store §6, 16.0: a
70
+ // delta node walks back to the previous revisions' same list).
71
+ const tree = new TreeResolver(store.db, rev.prefix).tree(rev.doc_id, rev.seq, rev.root_tree.toString("hex"));
72
+ const walk = (entries) => {
73
+ for (const [blockId, rawHashHex] of entries) {
74
74
  const raw = blob.get(Buffer.from(rawHashHex, "hex"))?.bytes.toString("utf8") ?? "";
75
75
  out.set(blockId, raw);
76
- if (childHashHex)
77
- walk(childHashHex);
76
+ const children = tree.byBlock.get(blockId);
77
+ if (children)
78
+ walk(children.entries);
78
79
  }
79
80
  };
80
- walk(rev.root_tree.toString("hex"));
81
+ walk(tree.root.entries);
81
82
  return out;
82
83
  }
83
84
  /**
@@ -130,70 +131,9 @@ export function diffLines(text) {
130
131
  return text === "" ? [] : text.split("\n");
131
132
  }
132
133
  const DIFF_CONTEXT = 3;
133
- /**
134
- * Myers' O(ND) shortest edit script (forward, with a per-`d` trace for the
135
- * backtrack). `V[k]` is the furthest x on diagonal `k = x - y` reachable with
136
- * `d` edits. The canonical tie rule, identical in both engines: at each step
137
- * take the diagonal from `k+1` (moving down — an insertion of `b[y]`) when
138
- * `k == -d || (k != d && V[k-1] < V[k+1])`, else from `k-1` (moving right —
139
- * a deletion of `a[x]`). On a tie (`V[k-1] == V[k+1]`) that is the deletion.
140
- */
141
- export function myersScript(a, b) {
142
- const n = a.length;
143
- const m = b.length;
144
- const max = n + m;
145
- // V is indexed by k ∈ [-max-1, max+1]; `off` maps it onto a plain array.
146
- const off = max + 1;
147
- const v = new Array(2 * max + 3).fill(0);
148
- const trace = [];
149
- let found = false;
150
- for (let d = 0; d <= max && !found; d++) {
151
- trace.push(v.slice());
152
- for (let k = -d; k <= d; k += 2) {
153
- let x;
154
- if (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]))
155
- x = v[off + k + 1];
156
- else
157
- x = v[off + k - 1] + 1;
158
- let y = x - k;
159
- while (x < n && y < m && a[x] === b[y]) {
160
- x++;
161
- y++;
162
- }
163
- v[off + k] = x;
164
- if (x >= n && y >= m) {
165
- found = true;
166
- break;
167
- }
168
- }
169
- }
170
- // Backtrack from (n, m) through the trace, emitting ops newest-first.
171
- const ops = [];
172
- let x = n;
173
- let y = m;
174
- for (let d = trace.length - 1; d >= 0; d--) {
175
- const vd = trace[d];
176
- const k = x - y;
177
- const prevK = k === -d || (k !== d && vd[off + k - 1] < vd[off + k + 1]) ? k + 1 : k - 1;
178
- const prevX = vd[off + prevK];
179
- const prevY = prevX - prevK;
180
- while (x > prevX && y > prevY) {
181
- x--;
182
- y--;
183
- ops.push({ kind: "keep", line: a[x] });
184
- }
185
- if (d > 0) {
186
- if (x === prevX)
187
- ops.push({ kind: "insert", line: b[prevY] });
188
- else
189
- ops.push({ kind: "delete", line: a[prevX] });
190
- }
191
- x = prevX;
192
- y = prevY;
193
- }
194
- ops.reverse();
195
- return ops;
196
- }
134
+ // The edit script lives in core/edit-script.ts (shared with the store's delta
135
+ // nodes, spec/store §4.1); re-exported here for the surface and its tests.
136
+ export { myersScript } from "../core/edit-script.js";
197
137
  /**
198
138
  * The unified diff of two texts (spec/surface §3): hunks of `DIFF_CONTEXT` (3)
199
139
  * lines of context; a change group extends to include the next change when
@@ -14,6 +14,17 @@ export declare function stringList(opts?: {
14
14
  export declare function isStringList(schema: z.ZodTypeAny): boolean;
15
15
  /** The parts of a comma-joined string: split on `,`, trimmed, empty parts dropped. */
16
16
  export declare function commaSplit(s: string): string[];
17
+ /**
18
+ * (4.11) A string whose trimmed form opens `[`, read as JSON: `{ list }` when it
19
+ * is a JSON array of strings (accepted as that list); otherwise `{ fix }` — the
20
+ * list the error's corrected call should carry: the parsed array when the text
21
+ * parsed (a JSON array that is not all strings), else the comma split.
22
+ */
23
+ export declare function jsonArrayString(s: string): {
24
+ list: string[];
25
+ } | {
26
+ fix: unknown[];
27
+ } | null;
17
28
  /**
18
29
  * Validate `raw` against `shape`: unknown names first (in bytewise order), then
19
30
  * each declared argument (bytewise order) for presence and type. Returns the
@@ -13,13 +13,19 @@ export function argumentError(tool, message, shape, fix) {
13
13
  const full = `${message} (${argumentsHint(tool, shape)})`;
14
14
  return new EngineError("filter_invalid", full, { data: { reason: full, hint: "arguments", ...(fix !== undefined ? { fix } : {}) } });
15
15
  }
16
- // ---- string lists (spec/surface §4 "Arguments", 4.5) ---------------------------------
16
+ // ---- string lists (spec/surface §4 "Arguments", 4.5 / 4.11) ---------------------------
17
17
  //
18
18
  // Every list-of-strings argument (`docs`, `ids`, `roots`, `blocks`, `select`,
19
19
  // `unset`) also takes ONE bare string as a one-element list — the first guess
20
- // of a caller who has one ref. A comma-joined string is still "must be an array
21
- // of strings", but that error carries a `fix`: the same call with the string
22
- // split on commas (each part trimmed, empty parts dropped), canonical JSON.
20
+ // of a caller who has one ref — and (4.11) a string that IS a JSON array of
21
+ // strings (`"[\"b_1\",\"b_2\"]"`, the list stringified by a client that
22
+ // serializes every argument) as that array: the intent is unambiguous, the
23
+ // same tolerance class as the bare string. A comma-joined string is still
24
+ // "must be an array of strings", but that error carries a `fix`: the same call
25
+ // with the string split on commas (each part trimmed, empty parts dropped),
26
+ // canonical JSON. A string opening `[` that is not a JSON array of strings is
27
+ // the same error, its `fix` the parsed array when it parsed (a JSON array of
28
+ // numbers) and the comma split otherwise (a malformed `[`).
23
29
  const STRING_LISTS = new WeakSet();
24
30
  /** The schema of a string-list argument: an array of strings, or one bare string (normalized to `[s]` by `validateArguments`). */
25
31
  export function stringList(opts = {}) {
@@ -36,6 +42,29 @@ export function isStringList(schema) {
36
42
  export function commaSplit(s) {
37
43
  return s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
38
44
  }
45
+ /**
46
+ * (4.11) A string whose trimmed form opens `[`, read as JSON: `{ list }` when it
47
+ * is a JSON array of strings (accepted as that list); otherwise `{ fix }` — the
48
+ * list the error's corrected call should carry: the parsed array when the text
49
+ * parsed (a JSON array that is not all strings), else the comma split.
50
+ */
51
+ export function jsonArrayString(s) {
52
+ const trimmed = s.trim();
53
+ if (!trimmed.startsWith("["))
54
+ return null;
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(trimmed);
58
+ }
59
+ catch {
60
+ return { fix: commaSplit(s) };
61
+ }
62
+ if (!Array.isArray(parsed))
63
+ return { fix: commaSplit(s) };
64
+ if (parsed.every((x) => typeof x === "string"))
65
+ return { list: parsed };
66
+ return { fix: parsed };
67
+ }
39
68
  /**
40
69
  * Validate `raw` against `shape`: unknown names first (in bytewise order), then
41
70
  * each declared argument (bytewise order) for presence and type. Returns the
@@ -61,6 +90,20 @@ export function validateArguments(tool, shape, raw) {
61
90
  continue;
62
91
  }
63
92
  if (isStringList(schema) && typeof value === "string") {
93
+ // (4.11) a stringified JSON array of strings is that list — checked
94
+ // against the list schema itself (the `roots` minimum holds for it too);
95
+ // any other `[…` is the array error with the parsed array (or the comma
96
+ // split) as its `fix`.
97
+ const json = jsonArrayString(value);
98
+ if (json !== null) {
99
+ if ("fix" in json)
100
+ throw argumentError(tool, `\`${key}\` must be an array of strings`, shape, canonicalJson({ ...args, [key]: json.fix }));
101
+ const parsed = schema.safeParse(json.list);
102
+ if (!parsed.success)
103
+ throw argumentError(tool, issueMessage(key, schema, parsed.error.issues), shape);
104
+ out[key] = json.list;
105
+ continue;
106
+ }
64
107
  // (4.5) one bare string is a one-element list; a comma-joined one is the
65
108
  // array error with the split list as its `fix`.
66
109
  if (value.includes(","))
@@ -22,7 +22,7 @@ export function errorBody(err) {
22
22
  if (err instanceof EngineError)
23
23
  return { error: err.code, message: err.message, ...(err.data !== undefined ? { data: err.data } : {}), retriable: err.retriable };
24
24
  if (err instanceof FilterInvalid)
25
- return { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
25
+ return { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint, ...(err.fix !== undefined ? { fix: err.fix } : {}) }, retriable: false };
26
26
  if (err instanceof CursorInvalid)
27
27
  return { error: "filter_invalid", message: err.message, data: { reason: err.reason ?? `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
28
28
  if (err instanceof MutationError)