@omgbase/core 0.11.1 → 0.15.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 +1 -1
- package/dist/src/core/ids.d.ts +4 -0
- package/dist/src/core/ids.js +17 -0
- package/dist/src/core/ingest.d.ts +39 -3
- package/dist/src/core/ingest.js +233 -202
- package/dist/src/core/store/schema.d.ts +3 -2
- package/dist/src/core/store/schema.js +28 -3
- package/dist/src/core/store/store.d.ts +9 -0
- package/dist/src/core/store/store.js +11 -0
- package/dist/src/graph/history.js +15 -12
- package/dist/src/index.d.ts +9 -5
- package/dist/src/index.js +14 -7
- package/dist/src/mcp/arguments.d.ts +9 -2
- package/dist/src/mcp/arguments.js +15 -2
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/errors.js +3 -0
- package/dist/src/mcp/server.d.ts +22 -0
- package/dist/src/mcp/server.js +262 -185
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/commit.d.ts +150 -0
- package/dist/src/mutate/commit.js +349 -0
- package/dist/src/mutate/doc-store.d.ts +3 -3
- package/dist/src/mutate/doc-store.js +21 -6
- package/dist/src/mutate/docs.d.ts +42 -87
- package/dist/src/mutate/docs.js +50 -204
- package/dist/src/mutate/effects.d.ts +331 -0
- package/dist/src/mutate/effects.js +981 -0
- package/dist/src/mutate/materialize.d.ts +49 -0
- package/dist/src/mutate/materialize.js +174 -0
- package/dist/src/mutate/opset.d.ts +27 -2
- package/dist/src/mutate/opset.js +12 -5
- package/dist/src/mutate/plan-update.d.ts +1 -46
- package/dist/src/mutate/plan-update.js +19 -67
- package/dist/src/mutate/plan.d.ts +200 -0
- package/dist/src/mutate/plan.js +472 -0
- package/dist/src/mutate/sugar.d.ts +135 -0
- package/dist/src/mutate/sugar.js +167 -0
- package/dist/src/mutate/tree.d.ts +4 -2
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +5 -5
- package/dist/src/surface-paths.d.ts +17 -26
- package/dist/src/surface-paths.js +27 -22
- package/dist/src/sync/checkpoint.d.ts +5 -2
- package/dist/src/sync/checkpoint.js +10 -6
- package/dist/src/sync/freshness.d.ts +10 -7
- package/dist/src/sync/freshness.js +15 -17
- package/dist/src/sync/observe.d.ts +4 -1
- package/dist/src/sync/observe.js +56 -5
- package/dist/src/sync/recovery.d.ts +4 -0
- package/dist/src/sync/recovery.js +4 -1
- package/dist/src/sync/stat-cache.d.ts +7 -0
- package/dist/src/sync/stat-cache.js +22 -0
- package/dist/src/sync/tombstone.js +23 -19
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +4 -1
- package/dist/src/sync/writer-lock.d.ts +15 -0
- package/dist/src/sync/writer-lock.js +104 -17
- package/package.json +1 -1
package/dist/src/mcp/server.js
CHANGED
|
@@ -13,48 +13,34 @@ import { graphNeighborhood } from "./graph.js";
|
|
|
13
13
|
import { textSearch } from "../search/text.js";
|
|
14
14
|
import { EngineError, errorBody } from "./errors.js";
|
|
15
15
|
import { argumentError, validateArguments } from "./arguments.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { renderOpsetPlan } from "../mutate/opset.js";
|
|
16
|
+
import { linksRetarget, linksRepair } from "../mutate/macros.js";
|
|
17
|
+
import { canonicalDocPath } from "../mutate/docs.js";
|
|
18
|
+
import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
|
|
19
|
+
import { appendEffects, applySugar, checkEffects, createEffects, deleteDocEffects, docsAppendEffects, insertEffects, mergeEffects, moveBlocksEffects, moveDocEffects, moveLinks, nodeSetEffects, removeBlocksEffects, requireAbsentPath, retargetEffects, setMetaEffects, splitEffects, updateBlockEffects, updateContentEffects, } from "../mutate/sugar.js";
|
|
21
20
|
import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
|
|
22
21
|
import { linksStale, linksStaleSummary } from "../graph/link-health.js";
|
|
23
22
|
import { resolve as resolveThing } from "../search/resolve.js";
|
|
24
23
|
import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
|
|
25
24
|
import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
|
|
25
|
+
import { withWriterLockAsync } from "../sync/writer-lock.js";
|
|
26
26
|
import { QUERY_SYNTAX } from "./reference.js";
|
|
27
27
|
import { versionInfo } from "../version.js";
|
|
28
28
|
import { storagePath, referencePath } from "../core/paths.js";
|
|
29
29
|
import { isContainerBlock } from "../format/index.js";
|
|
30
|
-
import {
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// `
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
30
|
+
import { surfaceInboundLink, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfacePlan, surfaceReceipt, } from "../surface-paths.js";
|
|
31
|
+
// The placement and guard schemas the block-level sugar shares (spec/mutate
|
|
32
|
+
// §1.1/§1.2; the effect vocabulary spells them the same way). blocks_insert /
|
|
33
|
+
// blocks_move carry the op-level expectation of §1.2: only
|
|
34
|
+
// `parent_children_hash` (the destination parent's live direct child ids joined
|
|
35
|
+
// by "," and sha256-hexed) has a meaning there — there is no target block whose
|
|
36
|
+
// content_hash could be checked — so their schema names that one key alone (an
|
|
37
|
+
// accidental `content_hash` is dropped, not an error).
|
|
38
38
|
const atSchema = z.union([
|
|
39
39
|
z.literal("start"),
|
|
40
40
|
z.literal("end"),
|
|
41
41
|
z.object({ before: z.string() }),
|
|
42
42
|
z.object({ after: z.string() }),
|
|
43
43
|
]);
|
|
44
|
-
const toSchema = z.object({
|
|
45
|
-
parent: z.union([
|
|
46
|
-
z.string().describe("a block id to nest under"),
|
|
47
|
-
z.object({ doc: z.literal(true) }).describe("the document's top level"),
|
|
48
|
-
z.object({ heading: z.string(), scope: z.literal("section") }).describe("a heading's section range"),
|
|
49
|
-
]),
|
|
50
|
-
at: atSchema,
|
|
51
|
-
});
|
|
52
|
-
// blocks_insert / blocks_move carry the op-level expectation of spec/mutate §1.2:
|
|
53
|
-
// only `parent_children_hash` (the destination parent's live direct child ids
|
|
54
|
-
// joined by "," and sha256-hexed) has a meaning there — there is no target block
|
|
55
|
-
// whose content_hash could be checked — so the schema names that one key alone
|
|
56
|
-
// (an accidental `content_hash` is dropped, not an error; the kernel would
|
|
57
|
-
// ignore it anyway).
|
|
58
44
|
const parentExpectSchema = z.object({
|
|
59
45
|
parent_children_hash: z.string().optional(),
|
|
60
46
|
});
|
|
@@ -62,14 +48,13 @@ const expectSchema = z.object({
|
|
|
62
48
|
content_hash: z.string().optional(),
|
|
63
49
|
parent_children_hash: z.string().optional(),
|
|
64
50
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
]);
|
|
51
|
+
// The public Effect vocabulary (spec/mutate §11, 2.3) — the `plan` / `execute` /
|
|
52
|
+
// `apply.effects` grammar. The boundary schema is deliberately loose (an
|
|
53
|
+
// object whose `op` is one of the nine): the planner's own `validateEffects`
|
|
54
|
+
// checks every key, target kind and field spelling and reports the failure as
|
|
55
|
+
// the same `filter_invalid { hint: "arguments" }` envelope, naming `effects[i]`
|
|
56
|
+
// and the offending key — a message both engines compose from the spec.
|
|
57
|
+
const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; any id may be $new.<n> for an identity an earlier effect creates");
|
|
73
58
|
function ok(payload) {
|
|
74
59
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
75
60
|
}
|
|
@@ -80,18 +65,56 @@ function fail(err) {
|
|
|
80
65
|
}
|
|
81
66
|
/** The message every tool uses for a `semantic(...)` call the server cannot rank. */
|
|
82
67
|
export const PROVIDER_UNAVAILABLE_MESSAGE = "semantic(...) needs an embedding provider; none is configured — set embedding.provider (omg config set embedding.provider <command|url>) and embed the repository (omg embed drain)";
|
|
68
|
+
/**
|
|
69
|
+
* The tools that can commit — the host serializes these under the writer lock
|
|
70
|
+
* of spec/sync §7. The same list as the Rust catalog's `Surface::is_write_tool`
|
|
71
|
+
* (`crates/omgbase-surface/src/catalog.rs`). `plan` is listed too: it reads
|
|
72
|
+
* the documents a write would touch, so a preview waits for a writer exactly as
|
|
73
|
+
* the write would (through 3.x the sugar's `dry_run` played that role).
|
|
74
|
+
*/
|
|
75
|
+
export const WRITE_TOOLS = new Set([
|
|
76
|
+
"apply", "plan", "execute",
|
|
77
|
+
"blocks_insert", "blocks_update", "blocks_move", "blocks_remove", "blocks_split", "blocks_merge", "blocks_check",
|
|
78
|
+
"nodes_set", "blocks_append", "docs_append",
|
|
79
|
+
"links_retarget", "links_repair",
|
|
80
|
+
"docs_create", "docs_move", "docs_delete", "docs_set_meta", "docs_update",
|
|
81
|
+
"observe", "observe_many", "observe_delete",
|
|
82
|
+
]);
|
|
83
83
|
export function buildServer(ctx) {
|
|
84
84
|
const server = new McpServer({ name: "omgbase", version: VERSION });
|
|
85
85
|
const { store } = ctx;
|
|
86
|
+
// The kernel's share of the workspace dir: spread into every ApplyRequest /
|
|
87
|
+
// DocOpContext so the stat cache is recorded on the MCP path too.
|
|
88
|
+
const LOCK_DIR = ctx.omgbaseDir !== undefined ? { omgbaseDir: ctx.omgbaseDir } : {};
|
|
86
89
|
// ---- registration: listing through the SDK, validation + dispatch ours ----
|
|
87
90
|
// `server.registerTool` publishes the name, description and JSON schema on
|
|
88
91
|
// `tools/list`; the `tools/call` handler is replaced below so the arguments
|
|
89
92
|
// are validated by `validateArguments` (spec/surface §4 "Arguments") and a
|
|
90
93
|
// failure is the engine's own `filter_invalid`, not the SDK's text.
|
|
94
|
+
//
|
|
95
|
+
// A write tool (`WRITE_TOOLS`) is wrapped at registration so its whole call —
|
|
96
|
+
// argument resolution, the kernel's file-CAS + commit, `docs_move`'s inbound
|
|
97
|
+
// retarget — runs under one writer-lock acquire when the host supplied the
|
|
98
|
+
// workspace dir (the kernel's own inner lock re-enters it). The handler
|
|
99
|
+
// catches its own errors; the only one escaping the wrapper is the lock's
|
|
100
|
+
// `WriterLockTimeout`, rendered through `fail` like every other failure — a
|
|
101
|
+
// plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
|
|
102
|
+
// (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
|
|
91
103
|
const registry = new Map();
|
|
92
104
|
function tool(name, def, handler) {
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
const lockDir = ctx.omgbaseDir;
|
|
106
|
+
const run = WRITE_TOOLS.has(name) && lockDir !== undefined
|
|
107
|
+
? async (args) => {
|
|
108
|
+
try {
|
|
109
|
+
return await withWriterLockAsync(lockDir, () => handler(args), ctx.writerLock);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
return fail(e);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
: handler;
|
|
116
|
+
server.registerTool(name, def, run);
|
|
117
|
+
registry.set(name, { shape: def.inputSchema, handler: run });
|
|
95
118
|
}
|
|
96
119
|
// Multi-repo (ADR-014): every tool accepts an optional `repo` slug. Resolve it
|
|
97
120
|
// to a repo id + its (derived) working-tree root per call; omitting it uses the
|
|
@@ -233,13 +256,6 @@ export function buildServer(ctx) {
|
|
|
233
256
|
const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
|
|
234
257
|
return row?.doc_id;
|
|
235
258
|
}
|
|
236
|
-
// Current raw-content hash (hex) of a block, for CAS `expect` pinning — mirrors
|
|
237
|
-
// the CLI's rawHashOf. Undefined for an unknown/deleted block (the op then runs
|
|
238
|
-
// unpinned, exactly as the CLI does when it can't read a hash).
|
|
239
|
-
function pinHash(blockId) {
|
|
240
|
-
const row = store.db.prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
|
|
241
|
-
return row?.h;
|
|
242
|
-
}
|
|
243
259
|
function resolveAt(repoId, at) {
|
|
244
260
|
if (!at || at === "start" || at === "end")
|
|
245
261
|
return at ?? "end";
|
|
@@ -247,11 +263,30 @@ export function buildServer(ctx) {
|
|
|
247
263
|
return { before: resolveBlockRef(repoId, at.before) };
|
|
248
264
|
return { after: resolveBlockRef(repoId, at.after) };
|
|
249
265
|
}
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
266
|
+
// The sugar tools are `apply { effects }` (spec/surface §4 "Sugar over
|
|
267
|
+
// effects", 4.0; ADR-021): each tool resolves its refs and runs its own
|
|
268
|
+
// pre-checks, hands the ids to the shared builder of `mutate/sugar.ts` (one
|
|
269
|
+
// builder per operation — the same table the `omg` verbs, the fixture runner
|
|
270
|
+
// and the Rust port use), and `applySugar` plans + executes the effects as one
|
|
271
|
+
// request under the tool's fixed `reason` with `actor: "agent:mcp"` — the
|
|
272
|
+
// planner's `effect_index` dropped from any error, so `data` stays as pinned.
|
|
273
|
+
// Every sugar tool returns the Receipt (paths rooted); the preview of any of
|
|
274
|
+
// them is `plan { effects }` — there is no `dry_run` since 4.0.
|
|
275
|
+
function runSugar(repoId, rootPath, sugar, reason) {
|
|
276
|
+
const { receipt } = applySugar(store, { repoId, rootPath, actor: "agent:mcp", ...(reason !== undefined ? { reason } : {}), ...LOCK_DIR }, sugar);
|
|
277
|
+
return { receipt: receipt };
|
|
278
|
+
}
|
|
279
|
+
function sugar(repoId, rootPath, built) {
|
|
280
|
+
return okMutated(surfaceReceipt(runSugar(repoId, rootPath, built).receipt));
|
|
281
|
+
}
|
|
282
|
+
const blockTarget = (id) => ({ kind: "block", id });
|
|
283
|
+
// A parent ref as an effect target: a document ref is the document's top level, a block ref the block.
|
|
284
|
+
const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", id: p.docId } : { kind: "block", id: p.parent };
|
|
285
|
+
// An empty block list is an argument error (3.2, §9) — it used to reach the
|
|
286
|
+
// kernel as a malformed op (`block_missing "block undefined not found"`).
|
|
287
|
+
function requireSome(tool, arg, list) {
|
|
288
|
+
if (list.length === 0)
|
|
289
|
+
throw argumentError(tool, `\`${arg}\` must name at least one block`, registry.get(tool).shape);
|
|
255
290
|
}
|
|
256
291
|
// Guard shared by every mutating tool: a sourceless (headless-only) repo has no
|
|
257
292
|
// working tree to write through, so mutation is disabled with a loud error.
|
|
@@ -551,11 +586,10 @@ export function buildServer(ctx) {
|
|
|
551
586
|
}
|
|
552
587
|
});
|
|
553
588
|
tool("apply", {
|
|
554
|
-
description: "The only real writer
|
|
589
|
+
description: "The only real writer: an ORDERED list of `effects` — the intentional-write vocabulary `create insert set unset move remove split merge retarget` over documents, blocks, nodes and sections (the vocabulary `plan` documents, spec/mutate §11) — planned and committed as ONE transaction with one commit. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids: { \"$new.n\": id }, committed: true }` — one revision per touched document with its hash after the change (the same shape a changes_since revision has), `new_ids` the ids minted for the plan's symbolic identities, `commit` null when there was nothing to commit. To PREVIEW, call `plan` with the same `effects`: it returns the frozen Plan (diffs, results, the identity consequences) and burns no id; `execute` then commits exactly that plan. Every mutating tool is sugar over this one and returns the same Receipt. Guards: `expect.content_hash` on a block effect is the CAS of spec/mutate §1.2 (get it from docs_read include_ids' `hashes`, or omit it — effects pin the live hash for you); `expect.parent_children_hash` is the destination-parent CAS of insert/move; `expect.revision` pins a document. Conflicts carry current truth (`data.current.hash` + `markdown`) so retry from the error, don't re-read. Through surface 3.x this tool also took the six kernel ops as `ops` and a `dry_run`; both are retired (the kernel ops are a library/fixture contract since spec/mutate 3.0; the preview is `plan`) and refused by name.",
|
|
555
590
|
inputSchema: {
|
|
556
|
-
|
|
591
|
+
effects: z.array(effectSchema),
|
|
557
592
|
reason: z.string().optional(),
|
|
558
|
-
dry_run: z.boolean().optional(),
|
|
559
593
|
...REPO_ARG,
|
|
560
594
|
},
|
|
561
595
|
}, async (args) => {
|
|
@@ -563,14 +597,52 @@ export function buildServer(ctx) {
|
|
|
563
597
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
564
598
|
if (!rootPath)
|
|
565
599
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
600
|
+
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
601
|
+
// Plan + execute under one lock and one transaction → the Receipt (spec/mutate §11.7).
|
|
602
|
+
return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, origin, ...LOCK_DIR })));
|
|
603
|
+
}
|
|
604
|
+
catch (e) {
|
|
605
|
+
return fail(e);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
// ---- the Effect layer's verbs (spec/mutate §11, spec/surface §4 — 3.1) ----
|
|
609
|
+
// `plan` reads only and returns the frozen Plan with its preview; `execute`
|
|
610
|
+
// commits exactly a plan (the plan form, or the digest form `plan_id` +
|
|
611
|
+
// `effects`); `apply { effects }` above is plan + execute in one call.
|
|
612
|
+
tool("plan", {
|
|
613
|
+
description: "Plan a write without writing: lower an ORDERED list of `effects` — the intentional-write vocabulary `create insert set unset move remove split merge retarget` over documents, blocks, nodes and sections (spec/mutate §11) — to the kernel ops and document operations they mean, check their preconditions now (a stale `expect` fails here as it would at commit), reject contradictory effects (`conflicting_effects`: two writes to one scalar with different values, an effect addressing a target an earlier one removed — an identical duplicate is dropped with a diagnostic), and return the frozen Plan: `{ version: 1, planner_v, effects (targets resolved; `$new.n` kept symbolic), opset, preconditions: { docs, blocks, parents, absent_paths }, new_ids: [{ symbol, kind }], plan_id, diagnostics, preview: { results, revisions, diffs } }`. `plan_id` is the sha256 of the plan's executable core, so an identical plan over identical state has the same id and a plan burns no real id (new identities are `$new.0, $new.1, …` — a created document AND each of its blocks, in pre-order, so later effects may insert into / move into / edit a document the same request creates — minted at execute and mapped in the receipt). `preview` also carries `identity` (per `set content` document: the whole-document planner's `{ matcher_v, summary, ops: [{ op_index, disposition, blocks, confidence, reason }], diagnostics }`) and `retarget` (per `retarget` effect: `{ effect_index, hits, pairs }`) when the plan has such effects. Hand the whole Plan to `execute` (or just its `plan_id` with the same `effects`); nothing has expiry — the preconditions decide. Effect shapes: { op: \"create\", path, markdown, frontmatter? }, { op: \"insert\", into: Target, at? = \"end\", markdown, expect? }, { op: \"set\", target, field, value, expect? } (fields: a document's `frontmatter.<key>` or `content`, a block's `markdown` or `attrs.checked`, a node's editable property), { op: \"unset\", target: document, field: \"frontmatter.<key>\" }, { op: \"move\", target: document, to, retarget_inbound? = true }, { op: \"move\", targets: [block…], into, at?, expect? }, { op: \"remove\", target: document } | { targets: [block…], expect? }, { op: \"split\", target: block, at: [byte…] }, { op: \"merge\", targets: [block…], separator? }, { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; `expect` carries `content_hash`, `parent_children_hash` and/or the document's `revision`. Block content hashes you omit are pinned from the live rows. The plan's core keeps paths in the storage form (they are digested); only `preview` is rooted.",
|
|
614
|
+
inputSchema: { effects: z.array(effectSchema), ...REPO_ARG },
|
|
615
|
+
}, async (args) => {
|
|
616
|
+
try {
|
|
617
|
+
const { repoId, rootPath } = repoScope(args.repo);
|
|
618
|
+
if (!rootPath)
|
|
619
|
+
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
620
|
+
return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...LOCK_DIR })));
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
return fail(e);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
tool("execute", {
|
|
627
|
+
description: "Commit exactly a plan made by `plan`, as one transaction with one commit. Two forms, exactly one of them: `plan` — the Plan object as returned (`preview` may be dropped; `version` other than 1 → `unsupported_plan_version`, an edited core whose digest no longer equals its `plan_id` → `plan_mismatch`); or `plan_id` + `effects` — the engine re-plans the effects and commits only when the new plan's digest equals `plan_id` (else `plan_mismatch { expected, actual }`: the effects or the documents they target changed). Every precondition the plan froze is re-validated inside the transaction; a document that moved on fails `stale_plan` (retriable: re-plan) with nothing written, and replaying an already executed plan fails the same way. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids: { \"$new.0\": \"b_…\", … }, committed: true }` — `new_ids` maps the plan's symbolic identities to the ids minted. The commit records the plan as its intent (`commits.ops`).",
|
|
628
|
+
inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), reason: z.string().optional(), ...REPO_ARG },
|
|
629
|
+
}, async (args) => {
|
|
630
|
+
try {
|
|
631
|
+
const { repoId, rootPath } = repoScope(args.repo);
|
|
632
|
+
if (!rootPath)
|
|
633
|
+
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
634
|
+
const shape = registry.get("execute").shape;
|
|
635
|
+
const planForm = args.plan !== undefined;
|
|
636
|
+
const digestForm = args.plan_id !== undefined || args.effects !== undefined;
|
|
637
|
+
if (planForm === digestForm)
|
|
638
|
+
throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
|
|
639
|
+
if (digestForm && (args.plan_id === undefined || args.effects === undefined))
|
|
640
|
+
throw argumentError("execute", "the digest form needs both `plan_id` and `effects`", shape);
|
|
641
|
+
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
642
|
+
const receipt = planForm
|
|
643
|
+
? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
|
|
644
|
+
: executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, origin, ...LOCK_DIR });
|
|
645
|
+
return okMutated(surfaceReceipt(receipt));
|
|
574
646
|
}
|
|
575
647
|
catch (e) {
|
|
576
648
|
return fail(e);
|
|
@@ -579,143 +651,132 @@ export function buildServer(ctx) {
|
|
|
579
651
|
// ---- block-level sugar (ref-accepting peers of the `apply` primitive) -------
|
|
580
652
|
// These mirror the CLI verbs `insert`/`update`/`move`/`rm`/`split`/`merge`
|
|
581
653
|
// 1:1. Each takes human REFS (a block id or anything resolveRef accepts),
|
|
582
|
-
// resolves them + pins CAS server-side, builds exactly the
|
|
583
|
-
// and applies
|
|
584
|
-
//
|
|
585
|
-
// store, so a remote client needs no
|
|
654
|
+
// resolves them + pins CAS server-side, builds exactly the effects the CLI
|
|
655
|
+
// would (the shared builders), and applies them. This is what lets
|
|
656
|
+
// `omg <verb> --server` feel identical to local: the resolution that used to
|
|
657
|
+
// live in the CLI now lives next to the store, so a remote client needs no
|
|
658
|
+
// local database.
|
|
586
659
|
tool("blocks_insert", {
|
|
587
|
-
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 blocks_append for \"into this heading's section / this container\" 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.
|
|
588
|
-
inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(),
|
|
660
|
+
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 blocks_append for \"into this heading's section / this container\" 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. Sugar for `apply { effects: [{ op: \"insert\", into, at, markdown, expect? }] }` — returns the Receipt (`new_ids` holds the inserted blocks' ids, `$new.0` first); preview with `plan` over that effect.",
|
|
661
|
+
inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
|
|
589
662
|
}, async (args) => {
|
|
590
663
|
try {
|
|
591
664
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
592
665
|
const root = requireRoot(rootPath);
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown, ...(args.expect ? { expect: args.expect } : {}) }];
|
|
599
|
-
return applyOps(repoId, root, ops, "blocks_insert", args.dry_run);
|
|
666
|
+
// `insert into document|block at …`: a document parent is the top level
|
|
667
|
+
// (the planner pins the op to it). The caller's expect rides through as
|
|
668
|
+
// the destination-parent expectation (spec/mutate §1.2).
|
|
669
|
+
const into = parentTarget(resolveParentRef(repoId, args.to));
|
|
670
|
+
return sugar(repoId, root, insertEffects(into, resolveAt(repoId, args.at), args.markdown, args.expect));
|
|
600
671
|
}
|
|
601
672
|
catch (e) {
|
|
602
673
|
return fail(e);
|
|
603
674
|
}
|
|
604
675
|
});
|
|
605
676
|
tool("blocks_update", {
|
|
606
|
-
description: "Replace a block's markdown
|
|
607
|
-
inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(),
|
|
677
|
+
description: "Replace a block's markdown and/or set a task's `checked` state, with compare-and-swap. `id` is the block's id (or any ref resolve accepts); `expect.content_hash` is pinned to the block's current bytes server-side when omitted (protects against a concurrent edit). `markdown` may parse to SEVERAL sibling blocks (e.g. a paragraph followed by a list): the target keeps its id and takes the first, the rest are inserted right after it with fresh ids — the Receipt's `new_ids` lists them (`$new.0`, …). A list item may likewise be replaced by a multi-item list (`- a\\n- b`), yielding sibling items. Task state is settable flat as `checked` (the same name reads flatten to — `from blocks where checked`), synced to the `[ ]`/`[x]` in the raw; every OTHER block attr (heading `level`, code `lang`, list `ordered`) is derived from the markdown, so set those by editing `markdown` (the 3.x `attrs` bag is retired and refused by name). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"markdown\" | \"attrs.checked\", value, expect? }] }` (both when both are given) — returns the Receipt; preview with `plan`.",
|
|
678
|
+
inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
|
|
608
679
|
}, async (args) => {
|
|
609
680
|
try {
|
|
610
681
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
611
682
|
const root = requireRoot(rootPath);
|
|
612
683
|
const block = resolveBlockRef(repoId, args.id);
|
|
613
|
-
const
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
: undefined;
|
|
619
|
-
const ops = [{ op: "update", block, ...(args.markdown !== undefined ? { markdown: args.markdown } : {}), ...(attrs ? { attrs } : {}), ...(expect ? { expect } : {}) }];
|
|
620
|
-
const res = surfaceApplyResult(apply(store, { repoId, rootPath: root, ops, origin: { actor: "agent:mcp", reason: "blocks_update" }, ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}) }));
|
|
621
|
-
// `ids` lists every resulting block (the target first, then any siblings
|
|
622
|
-
// minted from multi-block content); `id` keeps the target for convenience.
|
|
623
|
-
const ids = res.results[0]?.ids ?? [block];
|
|
624
|
-
const payload = { id: ids[0] ?? block, ids, ...res };
|
|
625
|
-
return args.dry_run ? ok(payload) : okMutated(payload);
|
|
684
|
+
const built = updateBlockEffects(block, { markdown: args.markdown, checked: args.checked, expect: args.expect });
|
|
685
|
+
// Nothing to set is an argument error (4.0): there is no effect for "touch".
|
|
686
|
+
if (built.effects.length === 0)
|
|
687
|
+
throw argumentError("blocks_update", "pass `markdown` and/or `checked`", registry.get("blocks_update").shape);
|
|
688
|
+
return sugar(repoId, root, built);
|
|
626
689
|
}
|
|
627
690
|
catch (e) {
|
|
628
691
|
return fail(e);
|
|
629
692
|
}
|
|
630
693
|
});
|
|
631
694
|
tool("blocks_move", {
|
|
632
|
-
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.
|
|
633
|
-
inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(),
|
|
695
|
+
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. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
|
|
696
|
+
inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
|
|
634
697
|
}, async (args) => {
|
|
635
698
|
try {
|
|
636
699
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
637
700
|
const root = requireRoot(rootPath);
|
|
701
|
+
requireSome("blocks_move", "blocks", args.blocks);
|
|
638
702
|
const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
|
|
639
|
-
const
|
|
703
|
+
const parentRef = resolveParentRef(repoId, args.to);
|
|
640
704
|
// The kernel reads `{ doc: true }` + start/end as "the SOURCE doc's top
|
|
641
705
|
// level", so a document parent is only honest when it IS the source doc.
|
|
642
|
-
if (typeof parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== docId) {
|
|
706
|
+
if (typeof parentRef.parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== parentRef.docId) {
|
|
643
707
|
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 } });
|
|
644
708
|
}
|
|
645
|
-
|
|
646
|
-
const ops = [{ op: "move", blocks, to, ...(args.expect ? { expect: args.expect } : {}) }];
|
|
647
|
-
return applyOps(repoId, root, ops, "blocks_move", args.dry_run);
|
|
709
|
+
return sugar(repoId, root, moveBlocksEffects(blocks, parentTarget(parentRef), resolveAt(repoId, args.at), args.expect));
|
|
648
710
|
}
|
|
649
711
|
catch (e) {
|
|
650
712
|
return fail(e);
|
|
651
713
|
}
|
|
652
714
|
});
|
|
653
715
|
tool("blocks_remove", {
|
|
654
|
-
description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks
|
|
655
|
-
inputSchema: { blocks: z.array(z.string()),
|
|
716
|
+
description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
|
|
717
|
+
inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
|
|
656
718
|
}, async (args) => {
|
|
657
719
|
try {
|
|
658
720
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
659
721
|
const root = requireRoot(rootPath);
|
|
722
|
+
requireSome("blocks_remove", "blocks", args.blocks);
|
|
660
723
|
const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
|
|
661
|
-
|
|
662
|
-
return applyOps(repoId, root, ops, "blocks_remove", args.dry_run);
|
|
724
|
+
return sugar(repoId, root, removeBlocksEffects(blocks));
|
|
663
725
|
}
|
|
664
726
|
catch (e) {
|
|
665
727
|
return fail(e);
|
|
666
728
|
}
|
|
667
729
|
});
|
|
668
730
|
tool("blocks_split", {
|
|
669
|
-
description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `id` is the block's id (or a ref); `at` is a list of integer byte offsets into the block's raw bytes (spec/mutate §2.5; an offset inside a multi-byte character rounds down to its start). CAS is pinned to the block's current bytes server-side.
|
|
670
|
-
inputSchema: { id: z.string(), at: z.array(z.number().int()),
|
|
731
|
+
description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `id` is the block's id (or a ref); `at` is a list of integer byte offsets into the block's raw bytes (spec/mutate §2.5; an offset inside a multi-byte character rounds down to its start). CAS is pinned to the block's current bytes server-side. Sugar for `apply { effects: [{ op: \"split\", target, at }] }` — returns the Receipt (`new_ids` holds the new siblings); preview with `plan`.",
|
|
732
|
+
inputSchema: { id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
|
|
671
733
|
}, async (args) => {
|
|
672
734
|
try {
|
|
673
735
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
674
736
|
const root = requireRoot(rootPath);
|
|
675
737
|
const block = resolveBlockRef(repoId, args.id);
|
|
676
|
-
|
|
677
|
-
return
|
|
738
|
+
// The planner pins the content CAS from the live row (the block is live: it resolved).
|
|
739
|
+
return sugar(repoId, root, splitEffects(block, args.at));
|
|
678
740
|
}
|
|
679
741
|
catch (e) {
|
|
680
742
|
return fail(e);
|
|
681
743
|
}
|
|
682
744
|
});
|
|
683
745
|
tool("blocks_merge", {
|
|
684
|
-
description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2).
|
|
685
|
-
inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(),
|
|
746
|
+
description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
|
|
747
|
+
inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
|
|
686
748
|
}, async (args) => {
|
|
687
749
|
try {
|
|
688
750
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
689
751
|
const root = requireRoot(rootPath);
|
|
752
|
+
requireSome("blocks_merge", "blocks", args.blocks);
|
|
690
753
|
const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
|
|
691
|
-
|
|
692
|
-
return applyOps(repoId, root, ops, "blocks_merge", args.dry_run);
|
|
754
|
+
return sugar(repoId, root, mergeEffects(blocks, args.separator));
|
|
693
755
|
}
|
|
694
756
|
catch (e) {
|
|
695
757
|
return fail(e);
|
|
696
758
|
}
|
|
697
759
|
});
|
|
698
760
|
tool("blocks_check", {
|
|
699
|
-
description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs).
|
|
700
|
-
inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(),
|
|
761
|
+
description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
|
|
762
|
+
inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
|
|
701
763
|
}, async (args) => {
|
|
702
764
|
try {
|
|
703
765
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
704
766
|
const root = requireRoot(rootPath);
|
|
767
|
+
requireSome("blocks_check", "ids", args.ids);
|
|
705
768
|
const blocks = args.ids.map((b) => resolveBlockRef(repoId, b));
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
: blocks.map((block) => ({ op: "update", block, attrs: { checked: false }, ...(() => { const h = pinHash(block); return h ? { expect: { content_hash: h } } : {}; })() }));
|
|
710
|
-
return applyOps(repoId, root, ops, "blocks_check", args.dry_run);
|
|
769
|
+
// N × `set attrs.checked`, each CAS-pinned by the planner; a block named
|
|
770
|
+
// twice is one write (the planner drops the identical duplicate, 3.2).
|
|
771
|
+
return sugar(repoId, root, checkEffects(blocks, args.checked ?? true));
|
|
711
772
|
}
|
|
712
773
|
catch (e) {
|
|
713
774
|
return fail(e);
|
|
714
775
|
}
|
|
715
776
|
});
|
|
716
777
|
tool("nodes_set", {
|
|
717
|
-
description: "Macro: surgically set one editable property of a projected node (e.g. a link's `name` text or `value` target, a task's `checked`). `id` is the node's id (from `query` over `from nodes` — the one tool that addresses `nodes` rows), `prop` the property, `value` its new value. Resolves the node to its block and rewrites only that node's span
|
|
718
|
-
inputSchema: { id: z.string(), prop: z.string(), value: z.string(),
|
|
778
|
+
description: "Macro: surgically set one editable property of a projected node (e.g. a link's `name` text or `value` target, a task's `checked`). `id` is the node's id (from `query` over `from nodes` — the one tool that addresses `nodes` rows), `prop` the property, `value` its new value. Resolves the node to its block and rewrites only that node's span. Nodes are read-only projections — this is the affordance to edit what a node represents without hand-rewriting the block. Errors node_not_editable (with the editable prop list) when the kind/prop has no editor. Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"node\", id }, field: prop, value }] }` — returns the Receipt; preview with `plan` (node_set before surface 3.0).",
|
|
779
|
+
inputSchema: { id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
|
|
719
780
|
}, async (args) => {
|
|
720
781
|
try {
|
|
721
782
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -723,16 +784,15 @@ export function buildServer(ctx) {
|
|
|
723
784
|
const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.id, repoId);
|
|
724
785
|
if (!known)
|
|
725
786
|
throw new EngineError("block_missing", `no node with id ${JSON.stringify(args.id)}`, { data: { id: args.id } });
|
|
726
|
-
|
|
727
|
-
return applyOps(repoId, root, ops, "nodes_set", args.dry_run);
|
|
787
|
+
return sugar(repoId, root, nodeSetEffects(args.id, args.prop, args.value));
|
|
728
788
|
}
|
|
729
789
|
catch (e) {
|
|
730
790
|
return fail(e);
|
|
731
791
|
}
|
|
732
792
|
});
|
|
733
793
|
tool("blocks_append", {
|
|
734
|
-
description: "Macro: append markdown INTO a block, by what the block is. `id` is a block's id (or a ref). A HEADING: the `text` is inserted at the end of its section range (before the next heading of equal or higher level) —
|
|
735
|
-
inputSchema: { id: z.string(), text: z.string(),
|
|
794
|
+
description: "Macro: append markdown INTO a block, by what the block is. `id` is a block's id (or a ref). A HEADING: the `text` is inserted at the end of its section range (before the next heading of equal or higher level) — `insert into { kind: \"section\", heading_id }`. A CONTAINER (a list, list item, task, blockquote or table — anything the kernel accepts as a parent): inserted at the end of its children (`insert into { kind: \"block\", id } at end`). A LEAF (a paragraph, code fence, …) is `block_not_container` — append means \"into\", never a sibling insert; place after it with `blocks_insert { to: <its parent>, at: { after: <id> } }`. Sugar for that one `insert` effect — returns the Receipt; preview with `plan`. To append at a whole document's end, or inside a section named by its heading TEXT, use docs_append.",
|
|
795
|
+
inputSchema: { id: z.string(), text: z.string(), ...REPO_ARG },
|
|
736
796
|
}, async (args) => {
|
|
737
797
|
try {
|
|
738
798
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -743,65 +803,64 @@ export function buildServer(ctx) {
|
|
|
743
803
|
.get(block);
|
|
744
804
|
if (!row)
|
|
745
805
|
throw blockMissing(args.id);
|
|
746
|
-
let
|
|
806
|
+
let into;
|
|
747
807
|
if (row.type === "heading") {
|
|
748
|
-
|
|
808
|
+
into = { kind: "section", heading_id: block };
|
|
749
809
|
}
|
|
750
810
|
else if (isContainerBlock(row.format ?? "markdown", { type: row.type, children: new Array(row.children) })) {
|
|
751
|
-
|
|
811
|
+
into = blockTarget(block);
|
|
752
812
|
}
|
|
753
813
|
else {
|
|
754
814
|
throw new EngineError("block_not_container", `block ${block} is a ${row.type}, not a heading or a container; append means into — insert after it with blocks_insert { to: <its parent>, at: { after: ${JSON.stringify(block)} } }`, {
|
|
755
815
|
data: { id: block, type: row.type },
|
|
756
816
|
});
|
|
757
817
|
}
|
|
758
|
-
return
|
|
818
|
+
return sugar(repoId, root, appendEffects(into, args.text));
|
|
759
819
|
}
|
|
760
820
|
catch (e) {
|
|
761
821
|
return fail(e);
|
|
762
822
|
}
|
|
763
823
|
});
|
|
764
824
|
tool("docs_append", {
|
|
765
|
-
description: "Macro: append markdown to a document — at its END, or at the end of one of its sections when `section` names a heading by its TEXT (`\"Setup\"`; an ATX spelling `\"## Setup\"` is accepted). The journal/log/running-note primitive. ADDITIVE and identity-preserving, NOT a whole-body replace: the `text` is parsed into blocks and inserted as NEW blocks after the existing ones (at the document's top level, or inside the section's range), so every existing block keeps its stable `b_` id (omgbase deliberately has no whole-body `docs_put` — see mcp-api).
|
|
766
|
-
inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(),
|
|
825
|
+
description: "Macro: append markdown to a document — at its END, or at the end of one of its sections when `section` names a heading by its TEXT (`\"Setup\"`; an ATX spelling `\"## Setup\"` is accepted). The journal/log/running-note primitive. ADDITIVE and identity-preserving, NOT a whole-body replace: the `text` is parsed into blocks and inserted as NEW blocks after the existing ones (at the document's top level, or inside the section's range), so every existing block keeps its stable `b_` id (omgbase deliberately has no whole-body `docs_put` — see mcp-api). Sugar for one `insert at end` effect into the document or the resolved section — returns the Receipt (`new_ids` the appended blocks); preview with `plan`. Address the document by `id` or `path` (at least one). The document must already exist — a missing doc errors `doc_missing` (creating one is docs_create's job, never this); a `section` matching no heading in it is `parent_missing`, one matching several is `ambiguous_heading` with `candidates[{ id, doc_id }]` — pass that heading's id to blocks_append, which appends into a block by its id.",
|
|
826
|
+
inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), ...REPO_ARG },
|
|
767
827
|
}, async (args) => {
|
|
768
828
|
try {
|
|
769
829
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
770
830
|
const root = requireRoot(rootPath);
|
|
771
831
|
// Resolve the locator to a live doc id FIRST — a missing doc is doc_missing
|
|
772
832
|
// (locateDoc throws it), never an auto-create. Then expand to the single
|
|
773
|
-
// insert-at-end
|
|
774
|
-
// it (existing blocks keep their ids; only the appended blocks are minted).
|
|
833
|
+
// insert-at-end effect (document top level, or the named section) and
|
|
834
|
+
// apply it (existing blocks keep their ids; only the appended blocks are minted).
|
|
775
835
|
const docId = locateDoc(repoId, "docs_append", registry.get("docs_append").shape, args);
|
|
776
|
-
let
|
|
836
|
+
let into;
|
|
777
837
|
if (args.section !== undefined) {
|
|
778
838
|
const info = findDoc(store, { docId });
|
|
779
|
-
|
|
839
|
+
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section) };
|
|
780
840
|
}
|
|
781
841
|
else {
|
|
782
|
-
|
|
842
|
+
into = { kind: "document", id: docId };
|
|
783
843
|
}
|
|
784
|
-
return
|
|
844
|
+
return sugar(repoId, root, docsAppendEffects(into, args.text));
|
|
785
845
|
}
|
|
786
846
|
catch (e) {
|
|
787
847
|
return fail(e);
|
|
788
848
|
}
|
|
789
849
|
});
|
|
790
850
|
tool("links_retarget", {
|
|
791
|
-
description: "Macro: rewrite ONE link destination everywhere it is linked — the single-pair form of links_repair (same matching rules: whole link destinations only, leading `/` optional, fragments preserved; prose/inline code/code fences untouched). Scope source docs with `path_glob`.
|
|
792
|
-
inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(),
|
|
851
|
+
description: "Macro: rewrite ONE link destination everywhere it is linked — the single-pair form of links_repair (same matching rules: whole link destinations only, leading `/` optional, fragments preserved; prose/inline code/code fences untouched). Scope source docs with `path_glob`. COMMITS: sugar for `apply { effects: [{ op: \"retarget\", repairs: [{ from, to }], path_glob? }] }`; returns `hits[{id,path,oldRaw,newRaw}]` (the rewritten block and its document) and `pairs[{from,to,hits}]` plus the Receipt (`commit` null when nothing matched). To PREVIEW first, call `plan` with that one effect: its `preview.retarget[0]` carries the same `hits` and `pairs` and `preview.diffs` the files, and it fails exactly where the commit would (through surface 3.x this tool was a dry run by default, with `applied`).",
|
|
852
|
+
inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), ...REPO_ARG },
|
|
793
853
|
}, async (args) => {
|
|
794
854
|
try {
|
|
795
855
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
796
856
|
if (!rootPath)
|
|
797
857
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
798
|
-
|
|
858
|
+
// `hits`/`pairs` come from the macro's plan; the write is the `retarget`
|
|
859
|
+
// effect, whose lowering runs the same macro (same ops, same CAS pins).
|
|
860
|
+
const { hits: rawHits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
|
|
799
861
|
const hits = surfaceRetargetHits(rawHits);
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
if (dryRun)
|
|
803
|
-
return ok({ hits, pairs, applied: false, ...res });
|
|
804
|
-
return okMutated({ hits, pairs, applied: true, ...res });
|
|
862
|
+
const { receipt } = runSugar(repoId, rootPath, retargetEffects([{ from: args.from_target, to: args.to_target }], args.path_glob, "links_retarget"));
|
|
863
|
+
return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
|
|
805
864
|
}
|
|
806
865
|
catch (e) {
|
|
807
866
|
return fail(e);
|
|
@@ -825,13 +884,12 @@ export function buildServer(ctx) {
|
|
|
825
884
|
}
|
|
826
885
|
});
|
|
827
886
|
tool("links_repair", {
|
|
828
|
-
description: "Macro: BULK stale-link repair — rewrite one or MANY LINK DESTINATIONS in ONE changeset. Pass `repairs` (an array of {from,to}) to fix several dangling targets — e.g. those surfaced by links_stale — at once; a single {from_target,to_target} pair is also accepted. MATCHING: `from` must be the WHOLE destination of a Markdown link/image `[t](dest)`, a wikilink `[[dest]]`/`[[dest|alias]]`, or a bare-path inline field `key:: /dest` — a leading `/` is optional on either side (so links_stale's `target` or `authored` both work), and a trailing `#heading`/`^ref` fragment on the link is kept and re-appended to `to`. NOT rewritten: prose mentions, inline code, `code_fence` blocks, longer paths that merely contain `from`, and frontmatter values (use docs_set_meta). Scope source docs with `path_glob` (as in links_stale). Ops coalesce to one update per TOP-MOST block (a list and its items count once), so a batch never trips over its own re-minted children.
|
|
887
|
+
description: "Macro: BULK stale-link repair — rewrite one or MANY LINK DESTINATIONS in ONE changeset. Pass `repairs` (an array of {from,to}) to fix several dangling targets — e.g. those surfaced by links_stale — at once; a single {from_target,to_target} pair is also accepted. MATCHING: `from` must be the WHOLE destination of a Markdown link/image `[t](dest)`, a wikilink `[[dest]]`/`[[dest|alias]]`, or a bare-path inline field `key:: /dest` — a leading `/` is optional on either side (so links_stale's `target` or `authored` both work), and a trailing `#heading`/`^ref` fragment on the link is kept and re-appended to `to`. NOT rewritten: prose mentions, inline code, `code_fence` blocks, longer paths that merely contain `from`, and frontmatter values (use docs_set_meta). Scope source docs with `path_glob` (as in links_stale). Ops coalesce to one update per TOP-MOST block (a list and its items count once), so a batch never trips over its own re-minted children. COMMITS: sugar for `apply { effects: [{ op: \"retarget\", repairs, path_glob? }] }`; returns `hits[{id,path,oldRaw,newRaw}]` (the rewritten block and its document) and `pairs[{from,to,hits}]` (per-pair destination counts; 0 = nothing matched) plus the Receipt (`commit` null when nothing matched). To PREVIEW first, call `plan` with that one effect — its `preview.retarget[0]` carries the same `hits`/`pairs`, `preview.diffs` the files — and it fails exactly where the commit would (through surface 3.x this tool was a dry run by default, with `applied`).",
|
|
829
888
|
inputSchema: {
|
|
830
889
|
repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
|
|
831
890
|
from_target: z.string().optional(),
|
|
832
891
|
to_target: z.string().optional(),
|
|
833
892
|
path_glob: z.string().optional(),
|
|
834
|
-
dry_run: z.boolean().optional(),
|
|
835
893
|
...REPO_ARG,
|
|
836
894
|
},
|
|
837
895
|
}, async (args) => {
|
|
@@ -847,97 +905,116 @@ export function buildServer(ctx) {
|
|
|
847
905
|
if (!repairs || repairs.length === 0) {
|
|
848
906
|
throw new EngineError("target_missing", "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair");
|
|
849
907
|
}
|
|
850
|
-
const {
|
|
908
|
+
const { hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
|
|
851
909
|
const hits = surfaceRetargetHits(rawHits);
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
const dryRun = args.dry_run !== false;
|
|
855
|
-
const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_repair" }, dryRun }));
|
|
856
|
-
if (dryRun)
|
|
857
|
-
return ok({ hits, pairs, applied: false, ...res });
|
|
858
|
-
return okMutated({ hits, pairs, applied: true, ...res });
|
|
910
|
+
const { receipt } = runSugar(repoId, rootPath, retargetEffects(repairs, args.path_glob, "links_repair"));
|
|
911
|
+
return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
|
|
859
912
|
}
|
|
860
913
|
catch (e) {
|
|
861
914
|
return fail(e);
|
|
862
915
|
}
|
|
863
916
|
});
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
// (`
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
917
|
+
// ---- the document operations as effects (spec/surface §4 "The document operations", 3.3; 4.0) ----
|
|
918
|
+
// `docs_create` / `docs_delete` / `docs_move` / `docs_set_meta` are one document
|
|
919
|
+
// effect each — `create`, `remove { target: document }`, `move { target:
|
|
920
|
+
// document, to, retarget_inbound }`, one `set`/`unset frontmatter.<k>` per key —
|
|
921
|
+
// built by the shared builders (`mutate/sugar.ts`), planned and executed
|
|
922
|
+
// through the same seam as the block-level sugar under the `spec/mutate` §6
|
|
923
|
+
// reasons (`create <rel>`, `delete <path>`, `move <from> -> <to>`, `set_meta
|
|
924
|
+
// <path>`), each returning the Receipt (4.0). Each tool keeps its own
|
|
925
|
+
// pre-checks — `locateDoc`'s `doc_missing`, the create/move `path_taken` pair
|
|
926
|
+
// (no `data`, as pinned) — so the planner's own copies of them never fire and
|
|
927
|
+
// the error envelopes are unchanged. The workspace dir rides along (LOCK_DIR):
|
|
928
|
+
// the tool-boundary lock already holds the writer lock for the call, so the
|
|
929
|
+
// request's own lock re-enters it. MCP-originated writes are actor agent:mcp.
|
|
930
|
+
/** The storage-form path of a document `locateDoc` just found. */
|
|
931
|
+
const pathOfDoc = (docId) => findDoc(store, { docId }).path;
|
|
932
|
+
/** `path_taken` exactly as the legacy document operations raised it: their message, no `data` (§9). */
|
|
933
|
+
const requireAbsent = (repoId, rootPath, rel, takenMessage) => requireAbsentPath(store, repoId, { rootPath }, rel, takenMessage);
|
|
875
934
|
tool("docs_create", {
|
|
876
|
-
description: "Create a new document at `path` (the new document's path, either form) from complete file bytes (`markdown`), with optional structured `frontmatter
|
|
877
|
-
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(),
|
|
935
|
+
description: "Create a new document at `path` (the new document's path, either form) from complete file bytes (`markdown`), with optional structured `frontmatter`. Sugar for `apply { effects: [{ op: \"create\", path, markdown, frontmatter? }] }` — returns the Receipt: `revisions[0]` is the created document (its `id`, rooted `path`, `hash`), `new_ids[\"$new.0\"]` the same id and `$new.1…` its blocks in pre-order. Fails path_taken if it already exists. Preview with `plan` over that effect (`preview.diffs[path]` is `\"\" → the bytes`).",
|
|
936
|
+
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
|
|
878
937
|
}, async (args) => {
|
|
879
938
|
try {
|
|
880
|
-
|
|
939
|
+
const scope = repoScope(args.repo);
|
|
940
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
941
|
+
const rel = canonicalDocPath(args.path);
|
|
942
|
+
requireAbsent(scope.repoId, rootPath, rel, `document already exists at ${rel}`);
|
|
943
|
+
return sugar(scope.repoId, rootPath, createEffects(rel, args.markdown, args.frontmatter));
|
|
881
944
|
}
|
|
882
945
|
catch (e) {
|
|
883
946
|
return fail(e);
|
|
884
947
|
}
|
|
885
948
|
});
|
|
886
949
|
tool("docs_move", {
|
|
887
|
-
description: "Rename a document to a new path (`to_path`, `/`-rooted or bare
|
|
888
|
-
inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(),
|
|
950
|
+
description: "Rename a document to a new path (`to_path`, `/`-rooted or bare); the document is addressed by `id` or `path`. Block identity and history are preserved, and the inbound links FOLLOW it: every link written against the old path (Markdown links/images, wikilinks, bare-path inline fields — anchors, link text, titles and code spans preserved, absolute vs relative style kept) is rewritten to the new path in the same call as one CAS-checked commit, so a move never leaves the graph broken. Fails path_taken if the destination exists. Sugar for `apply { effects: [{ op: \"move\", target: { kind: \"document\", id }, to, retarget_inbound? }] }` — returns the Receipt (one revision per rewritten source; the moved document's own revision does not advance unless it links to itself) plus `retargeted` (the touched blocks/docs) and `dangling` — what could NOT be rewritten, i.e. frontmatter relations (block null; fix with docs_set_meta). Pass `retarget_inbound:false` to opt out for the rarer intent that the old path become unbound: the links are left as written, listed in `dangling` ({doc_id, path, block_id, target, anchor, field?} per occurrence — a link row under the `links` target's names) and reported by links_stale (their edges become `phantom:<old path>`); links already written against the new path start resolving to this doc either way. Preview with `plan` over that effect: `preview.diffs` holds the old path emptied, the new path filled and the rewritten sources.",
|
|
951
|
+
inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
|
|
889
952
|
}, async (args) => {
|
|
890
953
|
try {
|
|
891
954
|
const scope = repoScope(args.repo);
|
|
892
955
|
const docId = locateDoc(scope.repoId, "docs_move", registry.get("docs_move").shape, args);
|
|
893
|
-
|
|
956
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
957
|
+
const doc = { id: docId, path: pathOfDoc(docId) };
|
|
958
|
+
const to = canonicalDocPath(args.to_path);
|
|
959
|
+
requireAbsent(scope.repoId, rootPath, to, `a document already exists at ${to}`);
|
|
960
|
+
const retargetInbound = args.retarget_inbound !== false;
|
|
961
|
+
// Who links here BY PATH, as written — read before the path changes — and
|
|
962
|
+
// the rewrite the move carries (`moveLinks` is pure over the current
|
|
963
|
+
// blocks, so computed here, before the commit, it equals what the planner
|
|
964
|
+
// lowered, `spec/mutate` §11.3) → `dangling`/`retargeted`.
|
|
965
|
+
const links = moveLinks(store, scope.repoId, doc, to, retargetInbound);
|
|
966
|
+
const { receipt } = runSugar(scope.repoId, rootPath, moveDocEffects(doc, to, retargetInbound));
|
|
967
|
+
return okMutated({ ...surfaceReceipt(receipt), retargeted: links.retargeted, dangling: links.dangling.map(surfaceInboundLink) });
|
|
894
968
|
}
|
|
895
969
|
catch (e) {
|
|
896
970
|
return fail(e);
|
|
897
971
|
}
|
|
898
972
|
});
|
|
899
973
|
tool("docs_delete", {
|
|
900
|
-
description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Address it by `id` or `path`.
|
|
901
|
-
inputSchema: { ...DOC_ARGS,
|
|
974
|
+
description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Address it by `id` or `path`. Sugar for `apply { effects: [{ op: \"remove\", target: { kind: \"document\", id } }] }` — returns the Receipt (a tombstone advances no revision: `revisions` is empty, `commit` the one commit). Preview with `plan` over that effect (`preview.diffs[path]` is `the bytes → \"\"`).",
|
|
975
|
+
inputSchema: { ...DOC_ARGS, ...REPO_ARG },
|
|
902
976
|
}, async (args) => {
|
|
903
977
|
try {
|
|
904
978
|
const scope = repoScope(args.repo);
|
|
905
979
|
const docId = locateDoc(scope.repoId, "docs_delete", registry.get("docs_delete").shape, args);
|
|
906
|
-
|
|
980
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
981
|
+
return sugar(scope.repoId, rootPath, deleteDocEffects({ id: docId, path: pathOfDoc(docId) }));
|
|
907
982
|
}
|
|
908
983
|
catch (e) {
|
|
909
984
|
return fail(e);
|
|
910
985
|
}
|
|
911
986
|
});
|
|
912
987
|
tool("docs_set_meta", {
|
|
913
|
-
description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path
|
|
914
|
-
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(),
|
|
988
|
+
description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
|
|
989
|
+
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
|
|
915
990
|
}, async (args) => {
|
|
916
991
|
try {
|
|
917
992
|
const scope = repoScope(args.repo);
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
993
|
+
const shape = registry.get("docs_set_meta").shape;
|
|
994
|
+
const docId = locateDoc(scope.repoId, "docs_set_meta", shape, args);
|
|
995
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
996
|
+
// One scalar effect per key, `unset` winning over `set` for a shared key;
|
|
997
|
+
// the planner drops a repeated `unset`. An empty patch is an argument
|
|
998
|
+
// error (3.3, §9) — it used to rewrite the frontmatter through the emitter for nothing.
|
|
999
|
+
const built = setMetaEffects({ id: docId, path: pathOfDoc(docId) }, args.set, args.unset);
|
|
1000
|
+
if (built.effects.length === 0)
|
|
1001
|
+
throw argumentError("docs_set_meta", "pass `set` and/or `unset`", shape);
|
|
1002
|
+
return sugar(scope.repoId, rootPath, built);
|
|
923
1003
|
}
|
|
924
1004
|
catch (e) {
|
|
925
1005
|
return fail(e);
|
|
926
1006
|
}
|
|
927
1007
|
});
|
|
928
1008
|
tool("docs_update", {
|
|
929
|
-
description: "Whole-document update with smart identity preservation. Submit the complete proposed `content` for a document (`id` or `path`); the engine reconciles it against the current tree, preserving stable block ids for structure that is recognizably the same (edits, moves, reorders), minting for new structure, and tombstoning removals —
|
|
930
|
-
inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(),
|
|
1009
|
+
description: "Whole-document update with smart identity preservation — sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"content\", value: content }] }` (surface 4.0). Submit the complete proposed `content` for a document (`id` or `path`); the engine reconciles it against the current tree, preserving stable block ids for structure that is recognizably the same (edits, moves, reorders), minting for new structure, and tombstoning removals — frontmatter changes included — and commits the derived ops as ONE transaction. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids, committed: true }`; content that already equals the document is a plan with nothing to do — no commit (`commit: null`, no revision). To INSPECT the identity consequences before committing, call `plan { effects: [that set] }`: its `preview.identity[<doc id>]` is `{ matcher_v, summary: { preserved, updated, moved, created, removed, split, merged, ambiguous }, ops: [{ op_index, disposition, blocks, confidence, reason }], diagnostics }` — each planned kernel op (`preview`'s `op_index` into the plan's `opset.ops`) annotated with the disposition it realizes — and the plan's `preconditions.docs` pin the document's revision and hash; then `execute` that plan, or call this tool. A document that changed since planning fails stale_plan (re-plan). Content the planner cannot reproduce byte-for-byte fails plan_not_convergent; the message names the first divergent byte and the proposed block (index/type) that failed to round-trip (full diagnostics in `data.diagnostics`).",
|
|
1010
|
+
inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), ...REPO_ARG },
|
|
931
1011
|
}, async (args) => {
|
|
932
1012
|
try {
|
|
933
1013
|
const scope = repoScope(args.repo);
|
|
934
1014
|
const docId = locateDoc(scope.repoId, "docs_update", registry.get("docs_update").shape, args);
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
}));
|
|
939
|
-
const payload = { opset, plan: renderOpsetPlan(opset), result };
|
|
940
|
-
return args.dry_run ? ok(payload) : okMutated(payload);
|
|
1015
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
1016
|
+
const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ id: docId }, args.content), args.reason);
|
|
1017
|
+
return okMutated(surfaceReceipt(receipt));
|
|
941
1018
|
}
|
|
942
1019
|
catch (e) {
|
|
943
1020
|
return fail(e);
|