@omgbase/core 0.3.0 → 0.4.1

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/ids.d.ts +34 -3
  2. package/dist/src/core/ids.js +79 -6
  3. package/dist/src/core/ingest.js +15 -12
  4. package/dist/src/core/read/document.js +6 -1
  5. package/dist/src/core/read/nodes.js +5 -1
  6. package/dist/src/core/store/fts.d.ts +47 -7
  7. package/dist/src/core/store/fts.js +73 -9
  8. package/dist/src/core/store/rebuild.js +5 -4
  9. package/dist/src/core/store/store.d.ts +4 -0
  10. package/dist/src/core/store/store.js +40 -1
  11. package/dist/src/format/index.d.ts +12 -1
  12. package/dist/src/format/index.js +14 -1
  13. package/dist/src/format/markdown.d.ts +7 -0
  14. package/dist/src/format/markdown.js +7 -0
  15. package/dist/src/graph/history.d.ts +21 -0
  16. package/dist/src/graph/history.js +48 -6
  17. package/dist/src/graph/links.d.ts +2 -0
  18. package/dist/src/graph/links.js +22 -11
  19. package/dist/src/index.d.ts +8 -6
  20. package/dist/src/index.js +7 -5
  21. package/dist/src/mcp/errors.d.ts +8 -0
  22. package/dist/src/mcp/errors.js +17 -3
  23. package/dist/src/mcp/server.js +50 -57
  24. package/dist/src/mutate/apply.js +12 -4
  25. package/dist/src/mutate/doc-store.js +4 -1
  26. package/dist/src/mutate/docs.d.ts +19 -0
  27. package/dist/src/mutate/docs.js +64 -16
  28. package/dist/src/mutate/ops.js +25 -13
  29. package/dist/src/oqx-js/context.d.ts +11 -0
  30. package/dist/src/oqx-js/context.js +16 -0
  31. package/dist/src/oqx-js/planner.d.ts +20 -1
  32. package/dist/src/oqx-js/planner.js +95 -2
  33. package/dist/src/oqx-js/run.js +27 -2
  34. package/dist/src/oqx-js/sql/translate.d.ts +94 -1
  35. package/dist/src/oqx-js/sql/translate.js +263 -79
  36. package/dist/src/search/tasks.d.ts +15 -1
  37. package/dist/src/search/tasks.js +31 -3
  38. package/dist/src/sync/admin.d.ts +13 -0
  39. package/dist/src/sync/admin.js +11 -0
  40. package/dist/src/sync/external-source.js +20 -7
  41. package/dist/src/sync/plugin.d.ts +30 -5
  42. package/dist/src/sync/plugin.js +13 -1
  43. package/dist/src/sync/watcher.d.ts +2 -0
  44. package/dist/src/sync/watcher.js +10 -2
  45. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { findDocByRef } from "../core/read/reader.js";
2
2
  import { isValidId } from "../core/ids.js";
3
+ import { EngineError } from "../mcp/errors.js";
3
4
  /** history_node: a block's biography from block_changes + commits (06 §3). */
4
5
  export function historyNode(store, blockId, opts = {}) {
5
6
  const limit = opts.limit ?? 100;
@@ -78,6 +79,25 @@ export function diffUnified(store, docId, fromRev, toRev) {
78
79
  const rendered = (rev) => [...blocksAtRevision(store, docId, rev).values()].join("\n");
79
80
  return unifiedDiff(rendered(fromRev), rendered(toRev));
80
81
  }
82
+ /**
83
+ * `diff_unified` over a resolved document: `to` defaults to the current revision,
84
+ * `from` to the one before it (or the current one again when there is only one:
85
+ * an empty diff). Shared by the MCP tool and `omg diff` so the shape cannot
86
+ * drift. `label` is how the caller named the document, for the error message.
87
+ * A document with no revision at all is `target_missing`; an unknown `rev` is
88
+ * `RevisionNotFound` (→ `target_missing` through `errorBody`).
89
+ */
90
+ export function docDiffUnified(store, docId, opts = {}) {
91
+ const revs = store.db
92
+ .prepare("SELECT rev_id FROM revisions WHERE doc_id = ? ORDER BY seq DESC LIMIT 2")
93
+ .all(docId);
94
+ const to = opts.toRev ?? revs[0]?.rev_id;
95
+ const from = opts.fromRev ?? revs[1]?.rev_id ?? revs[0]?.rev_id;
96
+ if (!to || !from)
97
+ throw new EngineError("target_missing", `no revisions to diff for ${opts.label ?? docId}`);
98
+ const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path ?? "";
99
+ return { doc: docId, path, from, to, diff: diffUnified(store, docId, from, to) };
100
+ }
81
101
  // ---- unified diff (spec/surface §3) ------------------------------------------
82
102
  // A line-grain unified diff with a deterministic Myers script, so both engines
83
103
  // (this reference and crates/omgbase-surface/src/history.rs) produce the same
@@ -232,14 +252,19 @@ export function changesSince(store, repoId, opts = {}) {
232
252
  params.push(opts.origin);
233
253
  params.push(limit + 1);
234
254
  const commits = store.db
235
- .prepare(`SELECT commit_id, seq, ts, origin, actor FROM commits WHERE repo_id = ? AND seq > ? ${originClause} ORDER BY seq LIMIT ?`)
255
+ .prepare(`SELECT commit_id, seq, ts, origin, actor, reason FROM commits WHERE repo_id = ? AND seq > ? ${originClause} ORDER BY seq LIMIT ?`)
236
256
  .all(...params);
237
257
  const truncated = commits.length > limit;
238
258
  const page = commits.slice(0, limit);
239
259
  const digests = page.map((c) => {
240
260
  const revs = store.db.prepare("SELECT r.doc_id AS doc, r.path AS path, r.rendered_hash AS rendered_hash FROM revisions r WHERE r.commit_id = ?").all(c.commit_id).map((r) => ({ doc: r.doc, path: r.path, contentHash: r.rendered_hash.toString("hex") }));
241
261
  const dispCounts = store.db.prepare("SELECT kind, count(*) n FROM dispositions WHERE commit_id = ? GROUP BY kind").all(c.commit_id);
242
- const summary = renderSummary(c.origin, c.actor, revs, dispCounts);
262
+ // A deletion or a move writes no revision: the tombstone names the commit
263
+ // (`docs.deleted_commit`), a move only its reason (`move <from> -> <to>`).
264
+ const deleted = revs.length === 0
265
+ ? store.db.prepare("SELECT path FROM docs WHERE deleted_commit = ? ORDER BY path").all(c.commit_id).map((r) => r.path)
266
+ : [];
267
+ const summary = renderSummary(c.origin, c.actor, revs, dispCounts, { deleted, moved: revs.length === 0 ? parseMove(c.reason) : null });
243
268
  return { commit: c.commit_id, seq: c.seq, ts: c.ts, origin: c.origin, actor: c.actor, summary, revisions: revs };
244
269
  });
245
270
  const nextCursor = page.length > 0 ? page[page.length - 1].seq : cursor;
@@ -333,12 +358,29 @@ function resolveDocRow(store, repoId, ref, includeDeleted) {
333
358
  : store.db.prepare("SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE repo_id = ? AND path = ?");
334
359
  return (asId ? stmt.get(ref) : stmt.get(repoId, ref));
335
360
  }
336
- function renderSummary(origin, actor, revs, disp) {
337
- const paths = revs.map((r) => r.path).join(", ");
361
+ /** The `reason` a document move records (`mutate/docs.ts`): `move <from> -> <to>`. */
362
+ function parseMove(reason) {
363
+ const m = reason === null ? null : /^move (.+) -> (.+)$/.exec(reason);
364
+ return m ? { from: m[1], to: m[2] } : null;
365
+ }
366
+ /**
367
+ * One line per commit: `<origin>[(<actor>)]: <paths> — <n> <kind>, …`. A
368
+ * commit that touched no revision — a document deletion (observed or api) or a
369
+ * move — says so instead: `deleted <path>` / `moved <from> → <to>`
370
+ * (spec/cli §6 `log`; the summary is unpinned by spec/surface).
371
+ */
372
+ function renderSummary(origin, actor, revs, disp, extra = { deleted: [], moved: null }) {
373
+ let subject = revs.map((r) => r.path).join(", ");
374
+ if (revs.length === 0) {
375
+ if (extra.deleted.length > 0)
376
+ subject = `deleted ${extra.deleted.join(", ")}`;
377
+ else if (extra.moved)
378
+ subject = `moved ${extra.moved.from} → ${extra.moved.to}`;
379
+ }
338
380
  const parts = disp.filter((d) => d.kind !== "same").map((d) => `${d.n} ${d.kind}`);
339
381
  const detail = parts.length > 0 ? ` — ${parts.join(", ")}` : "";
340
382
  if (origin === "api" || origin === "import")
341
- return `${origin}(${actor ?? "?"}): ${paths}${detail}`;
342
- return `observed: ${paths}${detail}`;
383
+ return `${origin}(${actor ?? "?"}): ${subject}${detail}`;
384
+ return `observed: ${subject}${detail}`;
343
385
  }
344
386
  //# sourceMappingURL=history.js.map
@@ -8,6 +8,8 @@ export interface LinkEdge {
8
8
  count: number;
9
9
  /** sample source block ids (out-edges only) */
10
10
  samples?: string[];
11
+ /** block-grain (`blocks: true`): the source block of this edge (null for a frontmatter edge) */
12
+ block?: string | null;
11
13
  }
12
14
  export interface LinksResult {
13
15
  out: LinkEdge[];
@@ -8,12 +8,12 @@ export function docLinks(store, docId, opts = {}) {
8
8
  if (dir === "out" || dir === "both") {
9
9
  if (opts.blocks) {
10
10
  const rows = store.db
11
- .prepare(`SELECT predicate, dst_node, dst_kind FROM edges
12
- WHERE src_doc = ? AND to_commit IS NULL ORDER BY predicate, dst_node`)
11
+ .prepare(`SELECT predicate, dst_node, dst_kind, src_block FROM edges
12
+ WHERE src_doc = ? AND to_commit IS NULL ORDER BY predicate, dst_node, src_block`)
13
13
  .all(docId);
14
14
  for (const r of rows)
15
15
  if (inPred(r.predicate))
16
- out.push({ predicate: r.predicate, node: r.dst_node, kind: r.dst_kind, count: 1 });
16
+ out.push({ predicate: r.predicate, node: r.dst_node, kind: r.dst_kind, count: 1, block: r.src_block });
17
17
  }
18
18
  else {
19
19
  const rows = store.db
@@ -28,14 +28,25 @@ export function docLinks(store, docId, opts = {}) {
28
28
  }
29
29
  }
30
30
  if (dir === "in" || dir === "both") {
31
- const rows = store.db
32
- .prepare(`SELECT predicate, src_doc, count(*) AS cnt FROM edges
33
- WHERE dst_node = ? AND to_commit IS NULL
34
- GROUP BY predicate, src_doc ORDER BY predicate, src_doc`)
35
- .all(docId);
36
- for (const r of rows)
37
- if (inPred(r.predicate))
38
- inbound.push({ predicate: r.predicate, node: r.src_doc, kind: "document", count: r.cnt });
31
+ if (opts.blocks) {
32
+ const rows = store.db
33
+ .prepare(`SELECT predicate, src_doc, src_block FROM edges
34
+ WHERE dst_node = ? AND to_commit IS NULL ORDER BY predicate, src_doc, src_block`)
35
+ .all(docId);
36
+ for (const r of rows)
37
+ if (inPred(r.predicate))
38
+ inbound.push({ predicate: r.predicate, node: r.src_doc, kind: "document", count: 1, block: r.src_block });
39
+ }
40
+ else {
41
+ const rows = store.db
42
+ .prepare(`SELECT predicate, src_doc, count(*) AS cnt FROM edges
43
+ WHERE dst_node = ? AND to_commit IS NULL
44
+ GROUP BY predicate, src_doc ORDER BY predicate, src_doc`)
45
+ .all(docId);
46
+ for (const r of rows)
47
+ if (inPred(r.predicate))
48
+ inbound.push({ predicate: r.predicate, node: r.src_doc, kind: "document", count: r.cnt });
49
+ }
39
50
  }
40
51
  return { out, in: inbound };
41
52
  }
@@ -19,7 +19,8 @@ export { ingestDirectory, type IngestDirResult } from "./sync/attach.js";
19
19
  export { ensureRepo } from "./core/attach.js";
20
20
  export { walkMarkdown, walkMarkdownAsync, nodeFs, type SyncFs, type FileStat } from "./sync/fs-util.js";
21
21
  export { Watcher, type WatcherOptions } from "./sync/watcher.js";
22
- export type { SyncSource, SourceCapabilities, SourceIdentity, SourceEntry, SourceItem, SourceWatch, WatchListener, } from "./sync/plugin.js";
22
+ export type { SyncSource, SourceCapabilities, SourceIdentity, SourceEntry, SourceItem, SourceWatch, WatchEvent, WatchListener, } from "./sync/plugin.js";
23
+ export { awaitReady, WATCH_READY_PATIENCE_MS } from "./sync/plugin.js";
23
24
  export { createExternalSource, type ExternalSourceSpec } from "./sync/external-source.js";
24
25
  export { ensureAdapter, listAdapters, createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, renderConfigFlags, type AdapterRow, type SourceRow, } from "./sync/sources.js";
25
26
  export { reconcileChanges, attachSource } from "./sync/driver.js";
@@ -27,7 +28,7 @@ export { observeFile, observeMany, observeOne, observeDelete, observeBatch, type
27
28
  export { buildServer, type ServerContext } from "./mcp/server.js";
28
29
  export { serveStdio, type ServeStdioHandle } from "./mcp/stdio.js";
29
30
  export { processCheckpoint, type CheckpointResult } from "./sync/checkpoint.js";
30
- export { reposStatus, syncStatus, type RepoStatus, type SyncStatus, type DiskStatus } from "./sync/admin.js";
31
+ export { reposStatus, syncStatus, reposList, type RepoStatus, type SyncStatus, type DiskStatus, type ReposList } from "./sync/admin.js";
31
32
  export { docsOutline, type OutlineResult, type OutlineOptions } from "./core/read/outline.js";
32
33
  export { docsRead, reconstructContent, type DocsReadResult, type DocsReadOptions } from "./core/read/document.js";
33
34
  export { nodesGet, nodesGetMany, type GetNode, type Resolution } from "./core/read/nodes.js";
@@ -37,18 +38,18 @@ export { resolveRef, type ResolvedRef } from "./core/read/refs.js";
37
38
  export { resolve, type ResolveHit, type ResolveInput } from "./search/resolve.js";
38
39
  export { oqxRun, oqxRunAsync, collectSemanticPhrases, parseOqx, type OqxResult, type OqxHit, type OqxOptions, type EmbedQuery } from "./oqx/run.js";
39
40
  export { EmbeddingWorker, SemanticUnavailable, contextPrefix, shouldEmbed, estimateTokens, type EmbeddingProvider, type EmbedTask, type DocEmbedTask, type DocEmbedBlockRef, type DocEmbedMethod, type DocVectorRow, } from "./search/embeddings.js";
40
- export { buildEmbedTasks, buildDocEmbedTasks } from "./search/tasks.js";
41
+ export { buildEmbedTasks, buildDocEmbedTasks, staleEmbedCount } from "./search/tasks.js";
41
42
  export { EmbedDrainer, type DrainerOptions } from "./search/drain.js";
42
43
  export { embeddingSettings, type EmbeddingSettings } from "./search/provider.js";
43
44
  export { createExternalProvider, type ExternalProvider } from "./search/external.js";
44
45
  export { hybridSearch, type HybridHit, type HybridInput } from "./search/rrf.js";
45
46
  export { vectorSearch, docVectorSearch, type VectorHit, type DocVectorHit } from "./search/vector.js";
46
- export { historyNode, diffUnified, unifiedDiff, changesSince, type NodeChange, type CommitDigest } from "./graph/history.js";
47
+ export { historyNode, diffUnified, docDiffUnified, diffBlocks, unifiedDiff, changesSince, RevisionNotFound, type NodeChange, type CommitDigest, type DocDiffUnified, type DiffEntry } from "./graph/history.js";
47
48
  export { docLinks, type LinksResult, type LinkEdge, type LinksOptions } from "./graph/links.js";
48
49
  export { FilterInvalid } from "./search/cel/parser.js";
49
- export { EngineError, type ErrorCode } from "./mcp/errors.js";
50
+ export { EngineError, errorBody, type ErrorCode, type ErrorBody } from "./mcp/errors.js";
50
51
  export { MutationError } from "./mutate/tree.js";
51
- export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter, type IdMinter, type IdPrefix } from "./core/ids.js";
52
+ export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter, repeatingMinter, registerIdOracle, type IdMinter, type IdOracle, type IdPrefix } from "./core/ids.js";
52
53
  export { pinClock, withClock } from "./core/clock.js";
53
54
  export { sha256 } from "./core/hash.js";
54
55
  export { ingestFile, type IngestResult } from "./core/ingest.js";
@@ -62,3 +63,4 @@ export { planUpdate, applyOpset, docsUpdate, type PlanUpdateOptions, type ApplyO
62
63
  export { opsetKernelOps, renderOpsetPlan, serializeOpset, parseOpset, type Opset, type PlanOp, type OpsetSummary, type OpsetPrecondition, type PlanDisposition, } from "./mutate/opset.js";
63
64
  export { rebuildIndex, type RebuildTarget } from "./core/store/rebuild.js";
64
65
  export { runGc, type GcResult } from "./core/store/gc.js";
66
+ export { LIVE_LEAF_SQL, ftsIndexedRowCount, liveLeafCount } from "./core/store/fts.js";
package/dist/src/index.js CHANGED
@@ -20,6 +20,7 @@ export { ingestDirectory } from "./sync/attach.js";
20
20
  export { ensureRepo } from "./core/attach.js";
21
21
  export { walkMarkdown, walkMarkdownAsync, nodeFs } from "./sync/fs-util.js";
22
22
  export { Watcher } from "./sync/watcher.js";
23
+ export { awaitReady, WATCH_READY_PATIENCE_MS } from "./sync/plugin.js";
23
24
  export { createExternalSource } from "./sync/external-source.js";
24
25
  export { ensureAdapter, listAdapters, createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, renderConfigFlags, } from "./sync/sources.js";
25
26
  export { reconcileChanges, attachSource } from "./sync/driver.js";
@@ -27,7 +28,7 @@ export { observeFile, observeMany, observeOne, observeDelete, observeBatch } fro
27
28
  export { buildServer } from "./mcp/server.js";
28
29
  export { serveStdio } from "./mcp/stdio.js";
29
30
  export { processCheckpoint } from "./sync/checkpoint.js";
30
- export { reposStatus, syncStatus } from "./sync/admin.js";
31
+ export { reposStatus, syncStatus, reposList } from "./sync/admin.js";
31
32
  // Read surface consumed by CLI read commands (11 §5.2–5.5).
32
33
  export { docsOutline } from "./core/read/outline.js";
33
34
  export { docsRead, reconstructContent } from "./core/read/document.js";
@@ -42,18 +43,18 @@ export { oqxRun, oqxRunAsync, collectSemanticPhrases, parseOqx } from "./oqx/run
42
43
  // Embeddings + semantic retrieval (05 §5–6). The provider is a plugin loaded by
43
44
  // package name; core carries no ML dependency.
44
45
  export { EmbeddingWorker, SemanticUnavailable, contextPrefix, shouldEmbed, estimateTokens, } from "./search/embeddings.js";
45
- export { buildEmbedTasks, buildDocEmbedTasks } from "./search/tasks.js";
46
+ export { buildEmbedTasks, buildDocEmbedTasks, staleEmbedCount } from "./search/tasks.js";
46
47
  export { EmbedDrainer } from "./search/drain.js";
47
48
  export { embeddingSettings } from "./search/provider.js";
48
49
  export { createExternalProvider } from "./search/external.js";
49
50
  export { hybridSearch } from "./search/rrf.js";
50
51
  export { vectorSearch, docVectorSearch } from "./search/vector.js";
51
- export { historyNode, diffUnified, unifiedDiff, changesSince } from "./graph/history.js";
52
+ export { historyNode, diffUnified, docDiffUnified, diffBlocks, unifiedDiff, changesSince, RevisionNotFound } from "./graph/history.js";
52
53
  export { docLinks } from "./graph/links.js";
53
54
  export { FilterInvalid } from "./search/cel/parser.js";
54
- export { EngineError } from "./mcp/errors.js";
55
+ export { EngineError, errorBody } from "./mcp/errors.js";
55
56
  export { MutationError } from "./mutate/tree.js";
56
- export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter } from "./core/ids.js";
57
+ export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter, repeatingMinter, registerIdOracle } from "./core/ids.js";
57
58
  // The clock seam (spec/surface §7.1): `omg mcp` pins the process clock under OMGBASE_SPEC_CLOCK.
58
59
  export { pinClock, withClock } from "./core/clock.js";
59
60
  export { sha256 } from "./core/hash.js";
@@ -72,4 +73,5 @@ export { planUpdate, applyOpset, docsUpdate, } from "./mutate/plan-update.js";
72
73
  export { opsetKernelOps, renderOpsetPlan, serializeOpset, parseOpset, } from "./mutate/opset.js";
73
74
  export { rebuildIndex } from "./core/store/rebuild.js";
74
75
  export { runGc } from "./core/store/gc.js";
76
+ export { LIVE_LEAF_SQL, ftsIndexedRowCount, liveLeafCount } from "./core/store/fts.js";
75
77
  //# sourceMappingURL=index.js.map
@@ -15,3 +15,11 @@ export declare class EngineError extends Error {
15
15
  });
16
16
  body(): EngineErrorBody;
17
17
  }
18
+ /** The rendered body of any thrown error (`error` is the code). */
19
+ export interface ErrorBody {
20
+ error: string;
21
+ message: string;
22
+ data?: unknown;
23
+ retriable: boolean;
24
+ }
25
+ export declare function errorBody(err: unknown): ErrorBody;
@@ -1,6 +1,7 @@
1
- // Error codes (06 §5). Shape: { error, message, data?, retriable }. Codes are
2
- // stable; prose is free to improve. FilterInvalid from the query compiler maps
3
- // to filter_invalid with its reason/hint in data.
1
+ import { FilterInvalid } from "../search/cel/parser.js";
2
+ import { CursorInvalid } from "../core/cursor.js";
3
+ import { MutationError } from "../mutate/tree.js";
4
+ import { RevisionNotFound } from "../graph/history.js";
4
5
  export class EngineError extends Error {
5
6
  code;
6
7
  data;
@@ -15,4 +16,17 @@ export class EngineError extends Error {
15
16
  return { error: this.code, message: this.message, data: this.data, retriable: this.retriable };
16
17
  }
17
18
  }
19
+ export function errorBody(err) {
20
+ if (err instanceof EngineError)
21
+ return { error: err.code, message: err.message, ...(err.data !== undefined ? { data: err.data } : {}), retriable: err.retriable };
22
+ if (err instanceof FilterInvalid)
23
+ return { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
24
+ if (err instanceof CursorInvalid)
25
+ return { error: "filter_invalid", message: err.message, data: { 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 };
26
+ if (err instanceof MutationError)
27
+ return { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
28
+ if (err instanceof RevisionNotFound)
29
+ return { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
30
+ return { error: "repo_not_found", message: String(err), retriable: false };
31
+ }
18
32
  //# sourceMappingURL=errors.js.map
@@ -4,7 +4,6 @@ import { docsOutline } from "../core/read/outline.js";
4
4
  import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
5
5
  import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
6
6
  import { findDoc, findDocByRef, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
7
- import { CursorInvalid } from "../core/cursor.js";
8
7
  import { resolveRef } from "../core/read/refs.js";
9
8
  import { isValidId } from "../core/ids.js";
10
9
  import { VERSION } from "../core/index.js";
@@ -12,18 +11,16 @@ import { normalizeText, normalizeVisibleText } from "../core/hash.js";
12
11
  import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
13
12
  import { graphNeighborhood } from "./graph.js";
14
13
  import { textSearch } from "../search/text.js";
15
- import { FilterInvalid } from "../search/cel/parser.js";
16
- import { EngineError } from "./errors.js";
14
+ import { EngineError, errorBody } from "./errors.js";
17
15
  import { apply } from "../mutate/apply.js";
18
- import { MutationError } from "../mutate/tree.js";
19
16
  import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair, nodeSet } from "../mutate/macros.js";
20
17
  import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
21
18
  import { planUpdate, docsUpdate } from "../mutate/plan-update.js";
22
19
  import { renderOpsetPlan } from "../mutate/opset.js";
23
- import { historyNode, diffBlocks, diffUnified, changesSince, docHistory, RevisionNotFound } from "../graph/history.js";
20
+ import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
24
21
  import { linksStale, linksStaleSummary } from "../graph/link-health.js";
25
22
  import { resolve as resolveThing } from "../search/resolve.js";
26
- import { reposStatus, syncStatus } from "../sync/admin.js";
23
+ import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
27
24
  import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
28
25
  import { QUERY_SYNTAX } from "./reference.js";
29
26
  // Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
@@ -45,6 +42,15 @@ const toSchema = z.object({
45
42
  ]),
46
43
  at: atSchema,
47
44
  });
45
+ // blocks_insert / blocks_move carry the op-level expectation of spec/mutate §1.2:
46
+ // only `parent_children_hash` (the destination parent's live direct child ids
47
+ // joined by "," and sha256-hexed) has a meaning there — there is no target block
48
+ // whose content_hash could be checked — so the schema names that one key alone
49
+ // (an accidental `content_hash` is dropped, not an error; the kernel would
50
+ // ignore it anyway).
51
+ const parentExpectSchema = z.object({
52
+ parent_children_hash: z.string().optional(),
53
+ });
48
54
  const expectSchema = z.object({
49
55
  content_hash: z.string().optional(),
50
56
  parent_children_hash: z.string().optional(),
@@ -61,20 +67,9 @@ function ok(payload) {
61
67
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
62
68
  }
63
69
  function fail(err) {
64
- let body;
65
- if (err instanceof EngineError)
66
- body = err.body();
67
- else if (err instanceof FilterInvalid)
68
- body = { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
69
- else if (err instanceof CursorInvalid)
70
- body = { error: "filter_invalid", message: err.message, data: { 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 };
71
- else if (err instanceof MutationError)
72
- body = { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
73
- else if (err instanceof RevisionNotFound)
74
- body = { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
75
- else
76
- body = { error: "repo_not_found", message: String(err), retriable: false };
77
- return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
70
+ // One mapping for every client (errors.ts `errorBody`): the CLI renders the
71
+ // same body, so a code can never differ between `omg` and `omg mcp`.
72
+ return { content: [{ type: "text", text: JSON.stringify(errorBody(err)) }], isError: true };
78
73
  }
79
74
  export function buildServer(ctx) {
80
75
  const server = new McpServer({ name: "omgbase", version: VERSION });
@@ -279,7 +274,7 @@ export function buildServer(ctx) {
279
274
  }
280
275
  });
281
276
  server.registerTool("docs_get_many", {
282
- description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a doc id OR a path, same id-or-path symmetry docs_read accepts in its `doc` field), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`content` = complete file bytes verbatim, `properties` grouped by source, plus `path`/`docId`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block id → content hash} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it. Use this after query/text_search/resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline.",
277
+ description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a doc id OR a path, same id-or-path symmetry docs_read accepts in its `doc` field), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`content` = complete file bytes verbatim, `properties` grouped by source, plus `path`/`docId`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block id → content hash} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/text_search/resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline.",
283
278
  inputSchema: {
284
279
  docs: z.array(z.string()),
285
280
  include_ids: z.boolean().optional(),
@@ -322,7 +317,7 @@ export function buildServer(ctx) {
322
317
  }
323
318
  });
324
319
  server.registerTool("nodes_get_many", {
325
- description: "Fetch up to 100 blocks by id (in request order) with budget truncation. Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side; `doc`/`path` is an optional SCOPE (ids from other docs then count as unresolved), not a requirement. Result carries `nodes`, `truncated` (cap or `budget_tokens` hit), and `unresolved` — the requested ids that name no live block (never silently dropped).",
320
+ description: "Fetch up to 100 blocks by id (in request order) with budget truncation. Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side; `doc`/`path` is an optional SCOPE (ids from other docs then count as unresolved), not a requirement. Result carries `nodes`, `truncated` (cap or `budget_tokens` hit — the first resolved node always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
326
321
  inputSchema: {
327
322
  doc: z.string().optional(),
328
323
  path: z.string().optional(),
@@ -540,16 +535,18 @@ export function buildServer(ctx) {
540
535
  // local: the resolution that used to live in the CLI now lives next to the
541
536
  // store, so a remote client needs no local database.
542
537
  server.registerTool("blocks_insert", {
543
- description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block, or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Expands to one insert op through the kernel writer.",
544
- inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
538
+ description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block — it must be a CONTAINER (a list, list item, task, blockquote or table); a leaf such as a heading or paragraph fails `type_mismatch` (spec/mutate §1.1) — use sections_append for \"under this heading\" or `at: {after: <ref>}` for \"right after this block\"; or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
539
+ inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
545
540
  }, async (args) => {
546
541
  try {
547
542
  const { repoId, rootPath } = repoScope(args.repo);
548
543
  const root = requireRoot(rootPath);
549
544
  const { parent, docId } = resolveParentRef(repoId, args.to);
550
545
  const to = { parent, at: resolveAt(repoId, args.at) };
551
- // A document parent carries no block to infer the doc from; pin it.
552
- const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown }];
546
+ // A document parent carries no block to infer the doc from; pin it. The
547
+ // caller's expect rides through as the op-level expectation (spec/mutate
548
+ // §1.2: checked against the destination parent before the op mutates).
549
+ const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown, ...(args.expect ? { expect: args.expect } : {}) }];
553
550
  return applyOps(repoId, root, ops, "blocks_insert", args.dry_run);
554
551
  }
555
552
  catch (e) {
@@ -583,8 +580,8 @@ export function buildServer(ctx) {
583
580
  }
584
581
  });
585
582
  server.registerTool("blocks_move", {
586
- description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref, or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. One move op through the kernel writer.",
587
- inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
583
+ description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. One move op through the kernel writer.",
584
+ inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
588
585
  }, async (args) => {
589
586
  try {
590
587
  const { repoId, rootPath } = repoScope(args.repo);
@@ -597,7 +594,7 @@ export function buildServer(ctx) {
597
594
  throw new EngineError("target_missing", `blocks_move cannot target another document's root (${args.to}); anchor on a block in that document with at.before/at.after`, { data: { to: args.to } });
598
595
  }
599
596
  const to = { parent, at: resolveAt(repoId, args.at) };
600
- const ops = [{ op: "move", blocks, to }];
597
+ const ops = [{ op: "move", blocks, to, ...(args.expect ? { expect: args.expect } : {}) }];
601
598
  return applyOps(repoId, root, ops, "blocks_move", args.dry_run);
602
599
  }
603
600
  catch (e) {
@@ -791,53 +788,56 @@ export function buildServer(ctx) {
791
788
  // Doc-level operations (06 §API). The MCP server serializes writes in-process,
792
789
  // so these pass no omgbaseDir (the flock is for cross-process CLI writers);
793
790
  // MCP-originated writes are actor agent:mcp.
794
- const docCtx = (scope) => {
791
+ // `dry_run` validates and returns the per-file `diffs` without writing
792
+ // (`DocOpContext.dryRun`), the docs-verb peer of `apply`'s preview.
793
+ const docCtx = (scope, dryRun) => {
795
794
  if (!scope.rootPath)
796
795
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
797
- return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp" };
796
+ return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp", ...(dryRun ? { dryRun: true } : {}) };
798
797
  };
798
+ const docResult = (res, dryRun) => (dryRun ? ok(res) : okMutated(res));
799
799
  server.registerTool("docs_create", {
800
- description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists.",
801
- inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
800
+ description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists. dry_run:true validates and returns the would-be file under `diffs` (committed:false) without writing.",
801
+ inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
802
802
  }, async (args) => {
803
803
  try {
804
- return okMutated(docsCreate(store, docCtx(repoScope(args.repo)), args.path, args.markdown, args.frontmatter));
804
+ return docResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter), args.dry_run);
805
805
  }
806
806
  catch (e) {
807
807
  return fail(e);
808
808
  }
809
809
  });
810
810
  server.registerTool("docs_move", {
811
- description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs.",
812
- inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
811
+ description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus any rewritten sources) — without renaming or committing.",
812
+ inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
813
813
  }, async (args) => {
814
814
  try {
815
- return okMutated(docsMove(store, docCtx(repoScope(args.repo)), args.doc, args.to_path, { retargetInbound: args.retarget_inbound === true }));
815
+ return docResult(docsMove(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, args.to_path, { retargetInbound: args.retarget_inbound === true }), args.dry_run);
816
816
  }
817
817
  catch (e) {
818
818
  return fail(e);
819
819
  }
820
820
  });
821
821
  server.registerTool("docs_delete", {
822
- description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path.",
823
- inputSchema: { doc: z.string(), ...REPO_ARG },
822
+ description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path. dry_run:true validates and returns the file's removal under `diffs` without deleting.",
823
+ inputSchema: { doc: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
824
824
  }, async (args) => {
825
825
  try {
826
- return okMutated(docsDelete(store, docCtx(repoScope(args.repo)), args.doc));
826
+ return docResult(docsDelete(store, docCtx(repoScope(args.repo), args.dry_run), args.doc), args.dry_run);
827
827
  }
828
828
  catch (e) {
829
829
  return fail(e);
830
830
  }
831
831
  });
832
832
  server.registerTool("docs_set_meta", {
833
- description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved.",
834
- inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
833
+ description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved. dry_run:true validates and returns the rewritten file under `diffs` without writing.",
834
+ inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
835
835
  }, async (args) => {
836
836
  try {
837
- return okMutated(docsSetMeta(store, docCtx(repoScope(args.repo)), args.doc, {
837
+ return docResult(docsSetMeta(store, docCtx(repoScope(args.repo), args.dry_run), args.doc, {
838
838
  ...(args.set ? { set: args.set } : {}),
839
839
  ...(args.unset ? { unset: args.unset } : {}),
840
- }));
840
+ }), args.dry_run);
841
841
  }
842
842
  catch (e) {
843
843
  return fail(e);
@@ -945,15 +945,11 @@ export function buildServer(ctx) {
945
945
  try {
946
946
  const { repoId } = repoScope(args.repo);
947
947
  const docId = resolveDocId(repoId, { doc: args.doc });
948
- const revs = store.db
949
- .prepare("SELECT rev_id FROM revisions WHERE doc_id = ? ORDER BY seq DESC LIMIT 2")
950
- .all(docId);
951
- const toRev = args.to_rev ?? revs[0]?.rev_id;
952
- const fromRev = args.from_rev ?? revs[1]?.rev_id ?? revs[0]?.rev_id;
953
- if (!toRev || !fromRev)
954
- throw new EngineError("target_missing", `no revisions to diff for ${JSON.stringify(args.doc)}`);
955
- const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path ?? "";
956
- return ok({ doc: docId, path, from: fromRev, to: toRev, diff: diffUnified(store, docId, fromRev, toRev) });
948
+ return ok(docDiffUnified(store, docId, {
949
+ ...(args.from_rev !== undefined ? { fromRev: args.from_rev } : {}),
950
+ ...(args.to_rev !== undefined ? { toRev: args.to_rev } : {}),
951
+ label: JSON.stringify(args.doc),
952
+ }));
957
953
  }
958
954
  catch (e) {
959
955
  return fail(e);
@@ -1067,10 +1063,7 @@ export function buildServer(ctx) {
1067
1063
  inputSchema: {},
1068
1064
  }, async () => {
1069
1065
  try {
1070
- const rows = store.db
1071
- .prepare("SELECT r.slug AS slug, MAX(CASE WHEN s.adapter = 'fs' THEN 1 ELSE 0 END) AS has_fs FROM repos r LEFT JOIN attachments a ON a.repo_id = r.repo_id LEFT JOIN sources s ON s.source_id = a.source_id GROUP BY r.repo_id, r.slug ORDER BY r.slug")
1072
- .all();
1073
- return ok({ repos: rows.map((r) => ({ slug: r.slug, hasSource: r.has_fs === 1 })) });
1066
+ return ok(reposList(store));
1074
1067
  }
1075
1068
  catch (e) {
1076
1069
  return fail(e);
@@ -9,6 +9,7 @@ import { renderDoc, markContainerDirty, MutationError } from "./tree.js";
9
9
  import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams, checkParentChildrenHash } from "./ops.js";
10
10
  import { withWriterLock } from "../sync/writer-lock.js";
11
11
  import { resolveDocStore } from "./doc-store.js";
12
+ import { isContainerBlock } from "../format/index.js";
12
13
  // Resolve "$n.ids[i]" placeholders in a string against prior op results.
13
14
  function resolvePlaceholder(value, results) {
14
15
  const m = /^\$(\d+)\.ids\[(\d+)\]$/.exec(value);
@@ -248,9 +249,11 @@ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
248
249
  throw new MutationError("block_missing", `block ${id} not found in source doc`, { op_index: opIndex });
249
250
  moving.push(found.block);
250
251
  }
251
- // §1.2: the destination parent's order CAS (in `dst`), before either tree moves.
252
+ // §1.1 container rule + §1.2 destination CAS (in `dst`), resolved once before
253
+ // either tree moves so a leaf destination errors with both trees untouched.
254
+ const pre = resolveDstTarget(dst, to, opIndex);
252
255
  if (expect?.parent_children_hash !== undefined)
253
- checkParentChildrenHash(resolveDstTarget(dst, to).siblings, expect, opIndex);
256
+ checkParentChildrenHash(pre.siblings, expect, opIndex);
254
257
  // remove from their source lists (marking each emptied owner dirty)
255
258
  for (const id of blockIds) {
256
259
  const f = locateMut(src, id);
@@ -260,7 +263,7 @@ function crossDocMove(src, dst, blockIds, to, opIndex, expect) {
260
263
  }
261
264
  }
262
265
  // Insert into dst at the resolved target; the destination owner re-renders.
263
- const target = resolveDstTarget(dst, to);
266
+ const target = resolveDstTarget(dst, to, opIndex);
264
267
  target.siblings.splice(target.index, 0, ...moving);
265
268
  markContainerDirty(dst, target.siblings);
266
269
  // Heal both documents' top-level seams (spec/mutate §2.3): a block that was
@@ -283,7 +286,7 @@ function locateMut(doc, blockId) {
283
286
  };
284
287
  return search(doc.children);
285
288
  }
286
- function resolveDstTarget(dst, to) {
289
+ function resolveDstTarget(dst, to, opIndex = 0) {
287
290
  if (typeof to.parent === "object" && "doc" in to.parent) {
288
291
  return { siblings: dst.children, index: resolveAt(dst.children, to.at) };
289
292
  }
@@ -291,6 +294,11 @@ function resolveDstTarget(dst, to) {
291
294
  const f = locateMut(dst, to.parent);
292
295
  if (!f)
293
296
  throw new MutationError("parent_missing", `parent ${to.parent} not found in dest`);
297
+ // spec/mutate §1.1 (1.2): a leaf destination would be re-rendered from its
298
+ // children and lose its own text — the same rule as the in-document ops.
299
+ if (!isContainerBlock(dst.format, f.block)) {
300
+ throw new MutationError("type_mismatch", `${to.parent} is a ${f.block.type}, not a container; place relative to it with at.before/at.after or append to its section`, { op_index: opIndex, block: to.parent, type: f.block.type });
301
+ }
294
302
  return { siblings: f.block.children, index: resolveAt(f.block.children, to.at) };
295
303
  }
296
304
  // heading/section: insert into top-level list at the anchor.
@@ -2,6 +2,7 @@ import { writeFileSync, readFileSync, existsSync, renameSync, unlinkSync, mkdirS
2
2
  import { join, dirname } from "node:path";
3
3
  import { sha256 } from "../core/hash.js";
4
4
  import { recordFileStat } from "../sync/freshness.js";
5
+ import { EngineError } from "../mcp/errors.js";
5
6
  /** Filesystem-backed DocStore rooted at a working-tree directory (v1 default). */
6
7
  export class FsDocStore {
7
8
  root;
@@ -66,6 +67,8 @@ export function resolveDocStore(req) {
66
67
  return req.docStore;
67
68
  if (req.rootPath != null)
68
69
  return new FsDocStore(req.rootPath);
69
- throw new Error("mutation requires a rootPath or an explicit docStore");
70
+ // Typed, so every client renders the same `repo_not_found` the MCP server's
71
+ // pre-checks emit (spec/surface §4; spec/cli §9 "sourceless writes").
72
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
70
73
  }
71
74
  //# sourceMappingURL=doc-store.js.map