@omgbase/core 0.28.0 → 0.29.2
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 +7 -5
- package/dist/src/core/read/document.d.ts +11 -10
- package/dist/src/core/read/document.js +12 -11
- package/dist/src/core/read/nodes.d.ts +10 -10
- package/dist/src/core/read/nodes.js +11 -11
- package/dist/src/core/read/reader.d.ts +37 -24
- package/dist/src/core/read/reader.js +44 -31
- package/dist/src/graph/history.d.ts +25 -22
- package/dist/src/graph/history.js +17 -17
- package/dist/src/graph/inbound-links.d.ts +7 -7
- package/dist/src/graph/inbound-links.js +7 -7
- package/dist/src/graph/link-health.d.ts +9 -9
- package/dist/src/graph/link-health.js +9 -9
- package/dist/src/graph/links.d.ts +3 -3
- package/dist/src/graph/links.js +4 -4
- package/dist/src/mcp/arguments.d.ts +10 -3
- package/dist/src/mcp/arguments.js +32 -9
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/graph.d.ts +22 -22
- package/dist/src/mcp/graph.js +54 -47
- package/dist/src/mcp/reference.d.ts +29 -1
- package/dist/src/mcp/reference.js +398 -255
- package/dist/src/mcp/server.d.ts +9 -0
- package/dist/src/mcp/server.js +170 -108
- package/dist/src/mutate/apply.d.ts +6 -6
- package/dist/src/mutate/apply.js +1 -1
- package/dist/src/mutate/canonical.d.ts +1 -1
- package/dist/src/mutate/canonical.js +2 -2
- package/dist/src/mutate/commit.d.ts +3 -3
- package/dist/src/mutate/commit.js +4 -4
- package/dist/src/mutate/docs.d.ts +1 -1
- package/dist/src/mutate/docs.js +8 -8
- package/dist/src/mutate/effects.d.ts +17 -17
- package/dist/src/mutate/effects.js +113 -113
- package/dist/src/mutate/macros.d.ts +5 -5
- package/dist/src/mutate/macros.js +1 -1
- package/dist/src/mutate/materialize.js +2 -2
- package/dist/src/mutate/ops.js +2 -2
- package/dist/src/mutate/opset.d.ts +7 -7
- package/dist/src/mutate/plan-update.js +2 -2
- package/dist/src/mutate/plan.d.ts +8 -8
- package/dist/src/mutate/plan.js +25 -25
- package/dist/src/mutate/select.d.ts +4 -4
- package/dist/src/mutate/select.js +57 -56
- package/dist/src/mutate/sugar.d.ts +10 -10
- package/dist/src/mutate/sugar.js +21 -21
- package/dist/src/oqx-js/context.d.ts +8 -4
- package/dist/src/oqx-js/context.js +206 -189
- package/dist/src/oqx-js/names.d.ts +28 -17
- package/dist/src/oqx-js/names.js +115 -78
- package/dist/src/oqx-js/planner.d.ts +4 -8
- package/dist/src/oqx-js/planner.js +6 -10
- package/dist/src/oqx-js/root-row.d.ts +6 -5
- package/dist/src/oqx-js/root-row.js +15 -16
- package/dist/src/oqx-js/run.d.ts +18 -17
- package/dist/src/oqx-js/run.js +57 -54
- package/dist/src/oqx-js/sql/translate.d.ts +5 -5
- package/dist/src/oqx-js/sql/translate.js +162 -161
- package/dist/src/oqx-js/store-index.d.ts +1 -1
- package/dist/src/oqx-js/store-index.js +28 -30
- package/dist/src/search/external.d.ts +10 -0
- package/dist/src/search/external.js +20 -2
- package/dist/src/search/resolve.d.ts +2 -2
- package/dist/src/search/resolve.js +1 -1
- package/dist/src/search/rrf.js +2 -2
- package/dist/src/search/text.d.ts +5 -5
- package/dist/src/search/text.js +2 -2
- package/dist/src/spec-versions.d.ts +7 -7
- package/dist/src/spec-versions.js +6 -6
- package/dist/src/surface-paths.d.ts +18 -17
- package/dist/src/surface-paths.js +23 -21
- package/dist/src/sync/admin.d.ts +2 -2
- package/dist/src/sync/admin.js +1 -1
- package/package.json +2 -2
package/dist/src/mcp/server.js
CHANGED
|
@@ -12,19 +12,19 @@ import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
|
|
|
12
12
|
import { graphNeighborhood } from "./graph.js";
|
|
13
13
|
import { textSearch } from "../search/text.js";
|
|
14
14
|
import { EngineError, errorBody } from "./errors.js";
|
|
15
|
-
import { argumentError, stringList, validateArguments } from "./arguments.js";
|
|
15
|
+
import { argumentError, nearest, stringList, validateArguments } from "./arguments.js";
|
|
16
16
|
import { linksRetarget, linksRepair } from "../mutate/macros.js";
|
|
17
17
|
import { canonicalDocPath } from "../mutate/docs.js";
|
|
18
18
|
import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
|
|
19
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";
|
|
20
|
-
import { fixTool } from "../mutate/canonical.js";
|
|
20
|
+
import { canonicalJson, fixTool } from "../mutate/canonical.js";
|
|
21
21
|
import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
|
|
22
22
|
import { linksStale, linksStaleSummary } from "../graph/link-health.js";
|
|
23
23
|
import { resolve as resolveThing } from "../search/resolve.js";
|
|
24
24
|
import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
|
|
25
25
|
import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
|
|
26
26
|
import { withWriterLockAsync } from "../sync/writer-lock.js";
|
|
27
|
-
import {
|
|
27
|
+
import { QUERY_SYNTAX_CARD, QUERY_SYNTAX_SECTION_NAMES, QUERY_SYNTAX_SECTIONS, querySyntaxSection } from "./reference.js";
|
|
28
28
|
import { versionInfo } from "../version.js";
|
|
29
29
|
import { storagePath, referencePath } from "../core/paths.js";
|
|
30
30
|
import { isContainerBlock } from "../format/index.js";
|
|
@@ -84,7 +84,7 @@ const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
|
|
|
84
84
|
function selectorArgs(args) {
|
|
85
85
|
return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
|
|
86
86
|
}
|
|
87
|
-
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: document, field: \"
|
|
87
|
+
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: document, field: \"_frontmatter.<key>\", 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: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", _id? | _path? } | { kind: \"block\", _id } | { kind: \"node\", _id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"_attrs.checked\"; a document's \"_frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
|
|
88
88
|
// An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
|
|
89
89
|
// each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
|
|
90
90
|
// a rung is a line beginning `{` with the call's arguments as JSON, three
|
|
@@ -102,7 +102,7 @@ const ladder = (...lines) => lines.join("\n");
|
|
|
102
102
|
// The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
|
|
103
103
|
// the spec minter gives `/processes/calcination.md`'s ingest revision over the
|
|
104
104
|
// alchemy corpus (`examples.json` runs the rung before any write).
|
|
105
|
-
const REVISION_RUNG = "{\"
|
|
105
|
+
const REVISION_RUNG = "{\"_path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: revisions { _rev where _path == \"/processes/calcination.md\" })";
|
|
106
106
|
function ok(payload) {
|
|
107
107
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
108
108
|
}
|
|
@@ -128,6 +128,17 @@ export const WRITE_TOOLS = new Set([
|
|
|
128
128
|
"docs_create", "docs_move", "docs_delete", "docs_set_meta", "docs_update",
|
|
129
129
|
"observe", "observe_many", "observe_delete",
|
|
130
130
|
]);
|
|
131
|
+
/**
|
|
132
|
+
* How many plans a server remembers for `execute { plan_id }` alone (spec/surface
|
|
133
|
+
* §4 `execute`, 5.1): the most recent `plan` results of this process, by
|
|
134
|
+
* `plan_id`, least recently produced evicted first. The same bound as the Rust
|
|
135
|
+
* catalog's `PLAN_MEMORY`.
|
|
136
|
+
*/
|
|
137
|
+
export const PLAN_MEMORY = 64;
|
|
138
|
+
/** The message of `plan_unknown` (5.1), spelled alike by both engines. */
|
|
139
|
+
export function planUnknownMessage(planId) {
|
|
140
|
+
return `this server has no plan ${planId} — re-run plan, or pass the plan (or \`plan_id\` + \`effects\`) with execute`;
|
|
141
|
+
}
|
|
131
142
|
export function buildServer(ctx) {
|
|
132
143
|
const server = new McpServer({ name: "omgbase", version: VERSION });
|
|
133
144
|
const { store } = ctx;
|
|
@@ -157,6 +168,25 @@ export function buildServer(ctx) {
|
|
|
157
168
|
const profile = ctx.tools ?? "full";
|
|
158
169
|
const inProfile = PROFILES[profile];
|
|
159
170
|
const registry = new Map();
|
|
171
|
+
// The plans this server produced (spec/surface §4 `execute`, 5.1): `plan`'s
|
|
172
|
+
// results by `plan_id`, the last PLAN_MEMORY of them, so `execute { plan_id }`
|
|
173
|
+
// alone runs the FULL plan form — `stale_plan` and `plan_mismatch` told apart
|
|
174
|
+
// exactly as if the client had echoed the Plan — without the client replaying
|
|
175
|
+
// 5–19 kB of plan as its own output. Per process: a plan_id this server never
|
|
176
|
+
// produced (another server's, a restart) is `plan_unknown`, retriable.
|
|
177
|
+
const plans = new Map();
|
|
178
|
+
function rememberPlan(plan) {
|
|
179
|
+
plans.delete(plan.plan_id);
|
|
180
|
+
plans.set(plan.plan_id, plan);
|
|
181
|
+
while (plans.size > PLAN_MEMORY)
|
|
182
|
+
plans.delete(plans.keys().next().value);
|
|
183
|
+
}
|
|
184
|
+
function rememberedPlan(planId) {
|
|
185
|
+
const plan = plans.get(planId);
|
|
186
|
+
if (!plan)
|
|
187
|
+
throw new EngineError("plan_unknown", planUnknownMessage(planId), { data: { plan_id: planId }, retriable: true });
|
|
188
|
+
return plan;
|
|
189
|
+
}
|
|
160
190
|
function tool(name, def, handler) {
|
|
161
191
|
if (!inProfile(name))
|
|
162
192
|
return;
|
|
@@ -205,7 +235,7 @@ export function buildServer(ctx) {
|
|
|
205
235
|
const REPO_ARG = { repo: z.string().optional() };
|
|
206
236
|
// The document locator every `docs_*` tool takes (§4 "Arguments"): the row's
|
|
207
237
|
// own attributes, at least one of them.
|
|
208
|
-
const DOC_ARGS = {
|
|
238
|
+
const DOC_ARGS = { _id: z.string().optional(), _path: z.string().optional() };
|
|
209
239
|
// Wrap a successful write's result: notify the host so it can schedule a
|
|
210
240
|
// background embed drain. onMutation must be cheap/non-blocking (see
|
|
211
241
|
// ServerContext); we never await it, keeping it off the response path.
|
|
@@ -214,15 +244,16 @@ export function buildServer(ctx) {
|
|
|
214
244
|
return ok(payload);
|
|
215
245
|
}
|
|
216
246
|
// ---- the document locator (§4 "Arguments") ---------------------------------
|
|
217
|
-
// `
|
|
247
|
+
// `_id` is the document's id, `_path` its path (either form, §1 "Paths"); at
|
|
218
248
|
// least one is required, and when both are given they must name the same
|
|
219
|
-
// document. A miss is `doc_missing` "no document with
|
|
249
|
+
// document. A miss is `doc_missing` "no document with _id or _path "<value>""
|
|
220
250
|
// with the arguments as given in `data`;
|
|
221
|
-
// neither given is `filter_invalid` "pass
|
|
222
|
-
// `filter_invalid` "
|
|
223
|
-
// never falls through from an id to a path.
|
|
251
|
+
// neither given is `filter_invalid` "pass _id or _path"; a disagreement is
|
|
252
|
+
// `filter_invalid` "_id "…" and _path "…" name different documents". `findDoc`
|
|
253
|
+
// never falls through from an id to a path. A bare `id`/`path` never reaches
|
|
254
|
+
// here: `validateArguments` refuses it with the `_` spelling and a `fix` (5.0).
|
|
224
255
|
function locateDoc(repoId, toolName, shape, args) {
|
|
225
|
-
const [idKey, pathKey] = ["
|
|
256
|
+
const [idKey, pathKey] = ["_id", "_path"];
|
|
226
257
|
const id = args[idKey];
|
|
227
258
|
const path = args[pathKey];
|
|
228
259
|
if (id === undefined && path === undefined)
|
|
@@ -257,7 +288,7 @@ export function buildServer(ctx) {
|
|
|
257
288
|
return row.doc_id;
|
|
258
289
|
}
|
|
259
290
|
function blockMissing(value, hint) {
|
|
260
|
-
return new EngineError("block_missing", `no block with id ${JSON.stringify(value)}${hint ? ` (${hint})` : ""}`, { data: {
|
|
291
|
+
return new EngineError("block_missing", `no block with id ${JSON.stringify(value)}${hint ? ` (${hint})` : ""}`, { data: { _id: value } });
|
|
261
292
|
}
|
|
262
293
|
// `nodes_block_read`: the block a node was projected from — the node must
|
|
263
294
|
// exist and name a live block (a block-less node, e.g. one derived from the
|
|
@@ -266,10 +297,10 @@ export function buildServer(ctx) {
|
|
|
266
297
|
function blockOfNode(nodeId) {
|
|
267
298
|
const node = store.db.prepare("SELECT doc_id, block_id FROM nodes WHERE node_id = ?").get(nodeId);
|
|
268
299
|
if (!node)
|
|
269
|
-
throw new EngineError("block_missing", `no node with id ${JSON.stringify(nodeId)}`, { data: {
|
|
300
|
+
throw new EngineError("block_missing", `no node with id ${JSON.stringify(nodeId)}`, { data: { _id: nodeId } });
|
|
270
301
|
const live = node.block_id ? store.db.prepare("SELECT 1 FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(node.block_id) : undefined;
|
|
271
302
|
if (!node.block_id || !live)
|
|
272
|
-
throw new EngineError("block_missing", `node ${JSON.stringify(nodeId)} has no block`, { data: {
|
|
303
|
+
throw new EngineError("block_missing", `node ${JSON.stringify(nodeId)} has no block`, { data: { _id: nodeId, _doc_id: node.doc_id } });
|
|
273
304
|
return { docId: node.doc_id, blockId: node.block_id };
|
|
274
305
|
}
|
|
275
306
|
// `docs_append`'s `section`: a heading's TEXT, matched against the normalized
|
|
@@ -294,13 +325,13 @@ export function buildServer(ctx) {
|
|
|
294
325
|
const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
295
326
|
const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
|
|
296
327
|
const fix = near.length === 1
|
|
297
|
-
? fixTool("blocks_append", {
|
|
298
|
-
: fixTool("docs_append", { ...(call.
|
|
299
|
-
throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section,
|
|
328
|
+
? fixTool("blocks_append", { _id: near[0].block_id, text: call.text })
|
|
329
|
+
: fixTool("docs_append", { ...(call._id !== undefined ? { _id: call._id } : {}), ...(call._path !== undefined ? { _path: call._path } : {}), text: call.text });
|
|
330
|
+
throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, _path: shown, fix } });
|
|
300
331
|
}
|
|
301
332
|
if (matches.length > 1) {
|
|
302
333
|
throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(section)} matches ${matches.length} headings in ${shown}; pass the heading's id to blocks_append`, {
|
|
303
|
-
data: { section,
|
|
334
|
+
data: { section, _path: shown, candidates: matches.map((m) => ({ _id: m.block_id, _doc_id: m.doc_id })), fix: fixTool("blocks_append", { _id: matches[0].block_id, text: call.text }) },
|
|
304
335
|
});
|
|
305
336
|
}
|
|
306
337
|
return matches[0].block_id;
|
|
@@ -356,9 +387,9 @@ export function buildServer(ctx) {
|
|
|
356
387
|
function sugar(repoId, rootPath, built) {
|
|
357
388
|
return okMutated(surfaceReceipt(runSugar(repoId, rootPath, built).receipt));
|
|
358
389
|
}
|
|
359
|
-
const blockTarget = (id) => ({ kind: "block", id });
|
|
390
|
+
const blockTarget = (id) => ({ kind: "block", _id: id });
|
|
360
391
|
// A parent ref as an effect target: a document ref is the document's top level, a block ref the block.
|
|
361
|
-
const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document",
|
|
392
|
+
const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", _id: p.docId } : { kind: "block", _id: p.parent };
|
|
362
393
|
// An empty block list is an argument error (3.2, §9) — it used to reach the
|
|
363
394
|
// kernel as a malformed op (`block_missing "block undefined not found"`).
|
|
364
395
|
function requireSome(tool, arg, list) {
|
|
@@ -404,7 +435,7 @@ export function buildServer(ctx) {
|
|
|
404
435
|
};
|
|
405
436
|
}
|
|
406
437
|
tool("docs_outline", {
|
|
407
|
-
description: "Orientation call. Returns a document's compact indented outline (alias/type/label per line, § marks section headings). IDs are stable; prefer them in follow-ups. Address the document by `
|
|
438
|
+
description: "Orientation call. Returns a document's compact indented outline (alias/type/label per line, § marks section headings). IDs are stable; prefer them in follow-ups. Address the document by `_id` or `_path` (at least one; a `_path` in either form). For the whole document body in one call, use docs_read.",
|
|
408
439
|
inputSchema: {
|
|
409
440
|
...DOC_ARGS,
|
|
410
441
|
resolution: z.enum(["skeleton", "outline"]).optional(),
|
|
@@ -428,7 +459,7 @@ export function buildServer(ctx) {
|
|
|
428
459
|
}
|
|
429
460
|
});
|
|
430
461
|
tool("docs_read", {
|
|
431
|
-
description: ladder("docs_read — One document, whole:
|
|
462
|
+
description: ladder("docs_read — One document, whole: _raw bytes plus properties { frontmatter, inline, computed } and _id/_path/rev.", "{\"_path\":\"/processes/calcination.md\"} by _path (either form)", "{\"_path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"_id\":\"d_14\"} by _id (a query hit's _id)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select _raw. Grammar: query_syntax."),
|
|
432
463
|
inputSchema: {
|
|
433
464
|
...DOC_ARGS,
|
|
434
465
|
include_ids: z.boolean().optional(),
|
|
@@ -450,7 +481,7 @@ export function buildServer(ctx) {
|
|
|
450
481
|
}
|
|
451
482
|
const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
|
|
452
483
|
if (!res)
|
|
453
|
-
throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.
|
|
484
|
+
throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args._id ?? args._path)}`, { data: { _id: docId } });
|
|
454
485
|
return ok(res);
|
|
455
486
|
}
|
|
456
487
|
catch (e) {
|
|
@@ -458,7 +489,7 @@ export function buildServer(ctx) {
|
|
|
458
489
|
}
|
|
459
490
|
});
|
|
460
491
|
tool("docs_read_many", {
|
|
461
|
-
description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`
|
|
492
|
+
description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`_raw` = complete file bytes verbatim, `properties` grouped by source, plus `_id`/`_path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `_id` → block `_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/blocks_search/blocks_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 (docs_get_many before surface 3.0).",
|
|
462
493
|
inputSchema: {
|
|
463
494
|
docs: stringList(),
|
|
464
495
|
include_ids: z.boolean().optional(),
|
|
@@ -479,9 +510,9 @@ export function buildServer(ctx) {
|
|
|
479
510
|
}
|
|
480
511
|
});
|
|
481
512
|
tool("blocks_read", {
|
|
482
|
-
description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full). `
|
|
513
|
+
description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full). `_id` is the block's id (`b_…`) and its document is inferred from it; an `n_` node id is not a block id — nodes_block_read reads the block a node was projected from (nodes_get before surface 3.0: blocks used to be called nodes here, and `nodes` now names the extracted-entity rows). Every resolution carries the block's `_id` and `_type`; raw and full add `_raw` (its source bytes) and `_hash` (its own raw hash) — the value update/split need in expect.content_hash, so you can fetch it before editing rather than reading it back from a conflict; full adds `_text`, `_attrs`, `_ordinal`, `_depth` and `parent_id` (null at the top level). Use this to expand the lean ids returned by query/blocks_resolve. To read a whole document in one call, use docs_read (blocks_read on a heading's id returns only that block, not its section).",
|
|
483
514
|
inputSchema: {
|
|
484
|
-
|
|
515
|
+
_id: z.string(),
|
|
485
516
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
486
517
|
...REPO_ARG,
|
|
487
518
|
},
|
|
@@ -491,11 +522,11 @@ export function buildServer(ctx) {
|
|
|
491
522
|
// `id` is a block id, unambiguously the `blocks_` kind's (§4 "Names and
|
|
492
523
|
// arguments"): an `n_` id names a NODE and is `block_missing` pointing at
|
|
493
524
|
// `nodes_block_read` (the name pass had dereferenced it here, §9).
|
|
494
|
-
if (args.
|
|
495
|
-
throw blockMissing(args.
|
|
496
|
-
const node = nodesGet(store, docOfBlock(args.
|
|
525
|
+
if (args._id.startsWith("n_"))
|
|
526
|
+
throw blockMissing(args._id, "an n_ id names a node — nodes_block_read reads its block");
|
|
527
|
+
const node = nodesGet(store, docOfBlock(args._id), args._id, args.resolution ? { resolution: args.resolution } : {});
|
|
497
528
|
if (!node)
|
|
498
|
-
throw blockMissing(args.
|
|
529
|
+
throw blockMissing(args._id);
|
|
499
530
|
return ok(node);
|
|
500
531
|
}
|
|
501
532
|
catch (e) {
|
|
@@ -503,19 +534,19 @@ export function buildServer(ctx) {
|
|
|
503
534
|
}
|
|
504
535
|
});
|
|
505
536
|
tool("nodes_block_read", {
|
|
506
|
-
description: "Read the block a NODE was projected from: `
|
|
537
|
+
description: "Read the block a NODE was projected from: `_id` is the node's id (`n_…`, from `query` over `nodes { … }`) and the result is exactly what blocks_read returns for that block — the same resolutions (skeleton|outline|text|raw|full, default full), the same `_id`/`_type`/`_raw`/`_hash`/`_text`/`_attrs`/`_ordinal`/`_depth`/`parent_id`. The hop from an occurrence (an `md:link`, `md:task`, `md:section`, … node) to the editable block that holds it, in one call; blocks_read itself takes a block id only. An unknown node, or a node that names no live block (a block-less node, such as one derived from the frontmatter), is block_missing.",
|
|
507
538
|
inputSchema: {
|
|
508
|
-
|
|
539
|
+
_id: z.string(),
|
|
509
540
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
510
541
|
...REPO_ARG,
|
|
511
542
|
},
|
|
512
543
|
}, async (args) => {
|
|
513
544
|
try {
|
|
514
545
|
repoScope(args.repo);
|
|
515
|
-
const { docId, blockId } = blockOfNode(args.
|
|
546
|
+
const { docId, blockId } = blockOfNode(args._id);
|
|
516
547
|
const node = nodesGet(store, docId, blockId, args.resolution ? { resolution: args.resolution } : {});
|
|
517
548
|
if (!node)
|
|
518
|
-
throw new EngineError("block_missing", `node ${JSON.stringify(args.
|
|
549
|
+
throw new EngineError("block_missing", `node ${JSON.stringify(args._id)} has no block`, { data: { _id: args._id, _doc_id: docId } });
|
|
519
550
|
return ok(node);
|
|
520
551
|
}
|
|
521
552
|
catch (e) {
|
|
@@ -523,9 +554,9 @@ export function buildServer(ctx) {
|
|
|
523
554
|
}
|
|
524
555
|
});
|
|
525
556
|
tool("blocks_read_many", {
|
|
526
|
-
description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `
|
|
557
|
+
description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `_ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block 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).",
|
|
527
558
|
inputSchema: {
|
|
528
|
-
|
|
559
|
+
_ids: stringList(),
|
|
529
560
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
530
561
|
budget_tokens: z.number().int().optional(),
|
|
531
562
|
...REPO_ARG,
|
|
@@ -533,7 +564,7 @@ export function buildServer(ctx) {
|
|
|
533
564
|
}, async (args) => {
|
|
534
565
|
try {
|
|
535
566
|
repoScope(args.repo);
|
|
536
|
-
const res = nodesGetMany(store, null, args.
|
|
567
|
+
const res = nodesGetMany(store, null, args._ids, {
|
|
537
568
|
...(args.resolution ? { resolution: args.resolution } : {}),
|
|
538
569
|
...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
|
|
539
570
|
});
|
|
@@ -544,10 +575,12 @@ export function buildServer(ctx) {
|
|
|
544
575
|
}
|
|
545
576
|
});
|
|
546
577
|
tool("docs_tree", {
|
|
547
|
-
description: ladder("docs_tree — Orientation: the
|
|
578
|
+
description: ladder("docs_tree — Orientation: the repo's directories with document and block counts, one small page (tree -L depth -d + du).", "{} the root: one row per directory ({ _path ending in /, docs, blocks }), files_here = documents at the root itself, total", "{\"path\":\"/processes/\",\"files\":true} descend (path is the prefix) and list its documents too ({ _id, _path, blocks })", "{\"depth\":2,\"updated\":true} nested directories to depth 2; updated adds _updated_at; limit/cursor page the rows", "Find documents by attribute, not by path: query (docs { select _path where ... }). Grammar: query_syntax."),
|
|
548
579
|
inputSchema: {
|
|
549
580
|
path: z.string().optional(),
|
|
550
581
|
depth: z.number().int().optional(),
|
|
582
|
+
files: z.boolean().optional(),
|
|
583
|
+
updated: z.boolean().optional(),
|
|
551
584
|
limit: z.number().int().optional(),
|
|
552
585
|
cursor: z.string().nullable().optional(),
|
|
553
586
|
budget_tokens: z.number().int().optional(),
|
|
@@ -559,6 +592,8 @@ export function buildServer(ctx) {
|
|
|
559
592
|
return ok(docsTree(store, repoId, {
|
|
560
593
|
...(args.path !== undefined ? { path: args.path } : {}),
|
|
561
594
|
...(args.depth !== undefined ? { depth: args.depth } : {}),
|
|
595
|
+
...(args.files !== undefined ? { files: args.files } : {}),
|
|
596
|
+
...(args.updated !== undefined ? { updated: args.updated } : {}),
|
|
562
597
|
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
563
598
|
...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
|
|
564
599
|
...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
|
|
@@ -569,7 +604,7 @@ export function buildServer(ctx) {
|
|
|
569
604
|
}
|
|
570
605
|
});
|
|
571
606
|
tool("docs_list", {
|
|
572
|
-
description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{
|
|
607
|
+
description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ _id, _path, _updated_at, blocks }], truncated, cursor }` — the document's `_id`, its `_path` (`/`-rooted, as every path the surface returns), last-commit timestamp (ISO, or null) and live block count — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `blocks_resolve`.",
|
|
573
608
|
inputSchema: {
|
|
574
609
|
path_glob: z.string().optional(),
|
|
575
610
|
limit: z.number().int().optional(),
|
|
@@ -592,11 +627,28 @@ export function buildServer(ctx) {
|
|
|
592
627
|
}
|
|
593
628
|
});
|
|
594
629
|
tool("query_syntax", {
|
|
595
|
-
description: ladder("query_syntax — The
|
|
596
|
-
inputSchema: {},
|
|
597
|
-
}, async () =>
|
|
630
|
+
description: ladder("query_syntax — The OQX reference: a card (clause order, the names rule and engine names per kind, consumers, follow, examples), then one chapter at a time.", "{} the ~3 kB card: { syntax, sections } — enough for most queries", "{\"section\":\"follow\"} one chapter in full ({ section, syntax }); the names are the card's sections list", "{\"section\":\"all\"} the whole manual (38 kB) — rarely needed", "Call the card before a non-trivial query; every other tool's examples end here, at query_syntax."),
|
|
631
|
+
inputSchema: { section: z.string().optional() },
|
|
632
|
+
}, async (args) => {
|
|
633
|
+
try {
|
|
634
|
+
// (5.1) The card by default; a chapter by its slug; `all` the manual. An
|
|
635
|
+
// unknown name is an argument error naming the chapters, its `fix` the
|
|
636
|
+
// call respelled with the nearest name (or `all`).
|
|
637
|
+
if (args.section === undefined)
|
|
638
|
+
return ok({ syntax: QUERY_SYNTAX_CARD, sections: QUERY_SYNTAX_SECTIONS.map((s) => s.slug) });
|
|
639
|
+
const syntax = querySyntaxSection(args.section);
|
|
640
|
+
if (syntax === null) {
|
|
641
|
+
const fix = canonicalJson({ section: nearest(args.section, QUERY_SYNTAX_SECTION_NAMES) ?? "all" });
|
|
642
|
+
throw argumentError("query_syntax", `unknown section \`${args.section}\`; the sections are ${QUERY_SYNTAX_SECTION_NAMES.join(", ")}`, registry.get("query_syntax").shape, fix);
|
|
643
|
+
}
|
|
644
|
+
return ok({ section: args.section, syntax });
|
|
645
|
+
}
|
|
646
|
+
catch (e) {
|
|
647
|
+
return fail(e);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
598
650
|
tool("query", {
|
|
599
|
-
description: ladder("query — rows (
|
|
651
|
+
description: ladder("query — rows (_id, _path, projection) from docs, blocks, nodes or links.", "{\"query\":\"docs { select _title, stage where \\\"process\\\" in tags order by _path limit 5 }\"} frontmatter; in; order, limit", "{\"query\":\"blocks { select _type, _text where _path == \\\"/processes/calcination.md\\\" }\"} a document's blocks in order", "{\"query\":\"nodes { select _block_id where _kind == \\\"md:section\\\" and _name == \\\"Tasks\\\" and _path == \\\"/processes/calcination.md\\\" }\"} a heading's block id", "{\"query\":\"nodes { select _path, _value where _kind == \\\"md:task\\\" and not checked }\"} open tasks", "{\"query\":\"links { select _path, _block_id where _dst_path == \\\"/processes/calcination.md\\\" }\"} inbound links (_path = source)", "{\"query\":\"docs { select _path where _title ~* \\\"salt\\\" }\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
|
|
600
652
|
inputSchema: {
|
|
601
653
|
query: z.string(),
|
|
602
654
|
limit: z.number().int().optional(),
|
|
@@ -624,7 +676,7 @@ export function buildServer(ctx) {
|
|
|
624
676
|
}
|
|
625
677
|
});
|
|
626
678
|
tool("graph", {
|
|
627
|
-
description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow
|
|
679
|
+
description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow _out_links { from _dst }` / `follow _in_links { from _doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"_title\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `_id`/`_path`/`degree` = min hops from a root/`frontier` bool + your projections — a name selection such as \"layer\" or \"_title\" is its own key, `_id`/`_path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `_id`, `_doc_id`/`_block_id` (the source), `_via_id`, `_dst_id`, `_dst_path`, `_uri`, `_dst_kind`, `_predicate`, `_provenance`, `_anchor`, `_field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ _id, _path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
|
|
628
680
|
inputSchema: {
|
|
629
681
|
roots: stringList({ min: 1 }),
|
|
630
682
|
degrees: z.number().int().optional(),
|
|
@@ -639,7 +691,7 @@ export function buildServer(ctx) {
|
|
|
639
691
|
const { repoId } = repoScope(args.repo);
|
|
640
692
|
// A `select` using semantic(...) needs a provider — surface the specific
|
|
641
693
|
// code, mirroring the `query` tool.
|
|
642
|
-
if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`
|
|
694
|
+
if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`docs { select x: ${s} }`).length > 0))
|
|
643
695
|
throw providerUnavailable();
|
|
644
696
|
return ok(await graphNeighborhood(store, repoId, args, rankableEmbed(repoId)));
|
|
645
697
|
}
|
|
@@ -648,7 +700,7 @@ export function buildServer(ctx) {
|
|
|
648
700
|
}
|
|
649
701
|
});
|
|
650
702
|
tool("blocks_search", {
|
|
651
|
-
description: "Full-text (FTS5, bm25-ranked) keyword search over block text: `{ hits: [{
|
|
703
|
+
description: "Full-text (FTS5, bm25-ranked) keyword search over block text: `{ hits: [{ _id (the block), _doc_id, _path, _type, _text, score }], truncated }`. Input is treated as a search box — plain words are ANDed, \"quoted phrases\" match adjacency, punctuation like / is safe (no query DSL). For structured filtering or frontmatter projection use `query` instead (see query_syntax). (text_search before surface 3.0.)",
|
|
652
704
|
inputSchema: { q: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
653
705
|
}, async (args) => {
|
|
654
706
|
try {
|
|
@@ -660,7 +712,7 @@ export function buildServer(ctx) {
|
|
|
660
712
|
}
|
|
661
713
|
});
|
|
662
714
|
tool("blocks_resolve", {
|
|
663
|
-
description: ladder("blocks_resolve — \"the id of the thing I mean\": a title or term, ranked to blocks {
|
|
715
|
+
description: ladder("blocks_resolve — \"the id of the thing I mean\": a title or term, ranked to blocks { _id, locator, preview, evidence }.", "{\"query\":\"calcination\"} best on short, entity-shaped input", "{\"query\":\"Emerald Tablet\",\"limit\":3} use the returned _id in the next call (docs_read, a plan target)", "Open questions or passages: query with text(\"...\") or order by semantic(\"...\") desc. Grammar: query_syntax."),
|
|
664
716
|
inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
665
717
|
}, async (args) => {
|
|
666
718
|
try {
|
|
@@ -677,7 +729,7 @@ export function buildServer(ctx) {
|
|
|
677
729
|
}
|
|
678
730
|
});
|
|
679
731
|
tool("apply", {
|
|
680
|
-
description: ladder("apply — plan and commit in one call; returns the Receipt { plan_id, commit, revisions, new_ids, committed, summary }.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/substances/salt.md\",\"field\":\"
|
|
732
|
+
description: ladder("apply — plan and commit in one call; returns the Receipt { plan_id, commit, revisions, new_ids, committed, summary }.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/substances/salt.md\",\"field\":\"_frontmatter.verified\",\"value\":false}],\"reason\":\"re-check salt\"} one scalar; a bare path is a document target", "{\"effects\":[{\"op\":\"insert\",\"into\":\"/lab/2026-02-notes.md\",\"at\":\"end\",\"markdown\":\"## Follow-ups\\n\\n- [ ] re-run cycle 4\"}]} append at a document's end", "{\"select\":\"blocks { _type == \\\"task\\\" and not checked and _path == \\\"/processes/calcination.md\\\" }\",\"effects\":[{\"op\":\"set\",\"field\":\"_attrs.checked\",\"value\":true}],\"expect\":{\"max\":10}} bulk check-off; expect.max is REQUIRED with select", "Plan first for anything you cannot undo by hand; every error's data.fix is a corrected call to copy. Grammar: query_syntax."),
|
|
681
733
|
inputSchema: {
|
|
682
734
|
effects: z.array(effectSchema),
|
|
683
735
|
select: z.string().optional(),
|
|
@@ -703,14 +755,18 @@ export function buildServer(ctx) {
|
|
|
703
755
|
// commits exactly a plan (the plan form, or the digest form `plan_id` +
|
|
704
756
|
// `effects`); `apply { effects }` above is plan + execute in one call.
|
|
705
757
|
tool("plan", {
|
|
706
|
-
description: ladder("plan — Preview
|
|
758
|
+
description: ladder("plan — Preview: the Plan (summary, $new.n ids) burns no id. Commit: execute { plan_id }. Bodies: diffs: true.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"_frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"docs { stage == \\\"nigredo\\\" }\",\"effects\":[{\"op\":\"set\",\"field\":\"_frontmatter.ok\",\"value\":true}]} no target: the hit", "{\"select\":\"docs { type == \\\"lab-note\\\" }\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${_title}](${_path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget; grammar: query_syntax."),
|
|
707
759
|
inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
|
|
708
760
|
}, async (args) => {
|
|
709
761
|
try {
|
|
710
762
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
711
763
|
if (!rootPath)
|
|
712
764
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
713
|
-
const
|
|
765
|
+
const planned = planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR });
|
|
766
|
+
// (5.1) remembered for `execute { plan_id }` alone — the Plan as planned,
|
|
767
|
+
// preview included (its `summary` is the Receipt's).
|
|
768
|
+
rememberPlan(planned);
|
|
769
|
+
const plan = surfacePlan(planned);
|
|
714
770
|
// (4.5) `preview.diffs` — whole before/after bodies of every touched
|
|
715
771
|
// document — only on request: `diffs: true`. The digest is over the
|
|
716
772
|
// core, never the preview, so `plan_id` is the same either way.
|
|
@@ -724,7 +780,7 @@ export function buildServer(ctx) {
|
|
|
724
780
|
}
|
|
725
781
|
});
|
|
726
782
|
tool("execute", {
|
|
727
|
-
description: ladder("execute — Commit
|
|
783
|
+
description: ladder("execute — Commit a plan from plan: by plan_id alone (this server remembers the plans it made), or the plan form (the whole Plan object), or the digest form (plan_id plus the same effects, re-planned and committed only if the digest still matches).", "{\"plan_id\":\"4cebb67faba4347b922151762478e60ddf56115a955489e222bee3c6bb2c7e7f\"} the id alone: the remembered plan runs; an id this server never planned is plan_unknown (re-run plan)", "Or {\"plan\": the Plan as returned} or {\"plan_id\", \"effects\"}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
|
|
728
784
|
inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), select: z.string().optional(), expect: selectionExpectSchema.optional(), reason: z.string().optional(), ...REPO_ARG },
|
|
729
785
|
}, async (args) => {
|
|
730
786
|
try {
|
|
@@ -732,17 +788,23 @@ export function buildServer(ctx) {
|
|
|
732
788
|
if (!rootPath)
|
|
733
789
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
734
790
|
const shape = registry.get("execute").shape;
|
|
791
|
+
// Three forms (spec/surface §4 `execute`): the plan form, the id alone
|
|
792
|
+
// (5.1: the plan this server remembers under it, run as the plan form),
|
|
793
|
+
// and the digest form (`plan_id` + `effects`, with `select`/`expect`).
|
|
735
794
|
const planForm = args.plan !== undefined;
|
|
736
795
|
const selector = args.select !== undefined || args.expect !== undefined;
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
796
|
+
const idForm = args.plan_id !== undefined && args.effects === undefined && !selector;
|
|
797
|
+
const digestForm = !idForm && (args.plan_id !== undefined || args.effects !== undefined || selector);
|
|
798
|
+
if (planForm === (idForm || digestForm))
|
|
799
|
+
throw argumentError("execute", "pass exactly one of `plan`, `plan_id` or `plan_id` + `effects`", shape);
|
|
740
800
|
if (digestForm && (args.plan_id === undefined || args.effects === undefined))
|
|
741
801
|
throw argumentError("execute", "the digest form needs both `plan_id` and `effects`", shape);
|
|
742
802
|
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
743
803
|
const receipt = planForm
|
|
744
804
|
? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
|
|
745
|
-
:
|
|
805
|
+
: idForm
|
|
806
|
+
? executePlan(store, { repoId, rootPath, plan: rememberedPlan(args.plan_id), origin, ...LOCK_DIR })
|
|
807
|
+
: executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
|
|
746
808
|
return okMutated(surfaceReceipt(receipt));
|
|
747
809
|
}
|
|
748
810
|
catch (e) {
|
|
@@ -775,13 +837,13 @@ export function buildServer(ctx) {
|
|
|
775
837
|
}
|
|
776
838
|
});
|
|
777
839
|
tool("blocks_update", {
|
|
778
|
-
description: "Replace a block's markdown and/or set a task's `checked` state, with compare-and-swap. `
|
|
779
|
-
inputSchema: {
|
|
840
|
+
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 — `blocks { 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`.",
|
|
841
|
+
inputSchema: { _id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
|
|
780
842
|
}, async (args) => {
|
|
781
843
|
try {
|
|
782
844
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
783
845
|
const root = requireRoot(rootPath);
|
|
784
|
-
const block = resolveBlockRef(repoId, args.
|
|
846
|
+
const block = resolveBlockRef(repoId, args._id);
|
|
785
847
|
const built = updateBlockEffects(block, { markdown: args.markdown, checked: args.checked, expect: args.expect });
|
|
786
848
|
// Nothing to set is an argument error (4.0): there is no effect for "touch".
|
|
787
849
|
if (built.effects.length === 0)
|
|
@@ -829,13 +891,13 @@ export function buildServer(ctx) {
|
|
|
829
891
|
}
|
|
830
892
|
});
|
|
831
893
|
tool("blocks_split", {
|
|
832
|
-
description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `
|
|
833
|
-
inputSchema: {
|
|
894
|
+
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`.",
|
|
895
|
+
inputSchema: { _id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
|
|
834
896
|
}, async (args) => {
|
|
835
897
|
try {
|
|
836
898
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
837
899
|
const root = requireRoot(rootPath);
|
|
838
|
-
const block = resolveBlockRef(repoId, args.
|
|
900
|
+
const block = resolveBlockRef(repoId, args._id);
|
|
839
901
|
// The planner pins the content CAS from the live row (the block is live: it resolved).
|
|
840
902
|
return sugar(repoId, root, splitEffects(block, args.at));
|
|
841
903
|
}
|
|
@@ -859,14 +921,14 @@ export function buildServer(ctx) {
|
|
|
859
921
|
}
|
|
860
922
|
});
|
|
861
923
|
tool("blocks_check", {
|
|
862
|
-
description: "Macro: check (or uncheck with checked:false) the given task blocks. `
|
|
863
|
-
inputSchema: {
|
|
924
|
+
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).",
|
|
925
|
+
inputSchema: { _ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
|
|
864
926
|
}, async (args) => {
|
|
865
927
|
try {
|
|
866
928
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
867
929
|
const root = requireRoot(rootPath);
|
|
868
|
-
requireSome("blocks_check", "
|
|
869
|
-
const blocks = args.
|
|
930
|
+
requireSome("blocks_check", "_ids", args._ids);
|
|
931
|
+
const blocks = args._ids.map((b) => resolveBlockRef(repoId, b));
|
|
870
932
|
// N × `set attrs.checked`, each CAS-pinned by the planner; a block named
|
|
871
933
|
// twice is one write (the planner drops the identical duplicate, 3.2).
|
|
872
934
|
return sugar(repoId, root, checkEffects(blocks, args.checked ?? true));
|
|
@@ -876,34 +938,34 @@ export function buildServer(ctx) {
|
|
|
876
938
|
}
|
|
877
939
|
});
|
|
878
940
|
tool("nodes_set", {
|
|
879
|
-
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`). `
|
|
880
|
-
inputSchema: {
|
|
941
|
+
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 `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).",
|
|
942
|
+
inputSchema: { _id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
|
|
881
943
|
}, async (args) => {
|
|
882
944
|
try {
|
|
883
945
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
884
946
|
const root = requireRoot(rootPath);
|
|
885
|
-
const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.
|
|
947
|
+
const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args._id, repoId);
|
|
886
948
|
if (!known)
|
|
887
|
-
throw new EngineError("block_missing", `no node with id ${JSON.stringify(args.
|
|
888
|
-
return sugar(repoId, root, nodeSetEffects(args.
|
|
949
|
+
throw new EngineError("block_missing", `no node with id ${JSON.stringify(args._id)}`, { data: { _id: args._id } });
|
|
950
|
+
return sugar(repoId, root, nodeSetEffects(args._id, args.prop, args.value));
|
|
889
951
|
}
|
|
890
952
|
catch (e) {
|
|
891
953
|
return fail(e);
|
|
892
954
|
}
|
|
893
955
|
});
|
|
894
956
|
tool("blocks_append", {
|
|
895
|
-
description: "Macro: append markdown INTO a block, by what the block is. `
|
|
896
|
-
inputSchema: {
|
|
957
|
+
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.",
|
|
958
|
+
inputSchema: { _id: z.string(), text: z.string(), ...REPO_ARG },
|
|
897
959
|
}, async (args) => {
|
|
898
960
|
try {
|
|
899
961
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
900
962
|
const root = requireRoot(rootPath);
|
|
901
|
-
const block = resolveBlockRef(repoId, args.
|
|
963
|
+
const block = resolveBlockRef(repoId, args._id);
|
|
902
964
|
const row = store.db
|
|
903
965
|
.prepare("SELECT b.type AS type, d.format AS format, b.parent_block AS parent, d.path AS path, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
|
|
904
966
|
.get(block);
|
|
905
967
|
if (!row)
|
|
906
|
-
throw blockMissing(args.
|
|
968
|
+
throw blockMissing(args._id);
|
|
907
969
|
let into;
|
|
908
970
|
if (row.type === "heading") {
|
|
909
971
|
into = { kind: "section", heading_id: block };
|
|
@@ -914,7 +976,7 @@ export function buildServer(ctx) {
|
|
|
914
976
|
else {
|
|
915
977
|
// The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
|
|
916
978
|
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)} } }`, {
|
|
917
|
-
data: {
|
|
979
|
+
data: { _id: block, _type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
|
|
918
980
|
});
|
|
919
981
|
}
|
|
920
982
|
return sugar(repoId, root, appendEffects(into, args.text));
|
|
@@ -924,7 +986,7 @@ export function buildServer(ctx) {
|
|
|
924
986
|
}
|
|
925
987
|
});
|
|
926
988
|
tool("docs_append", {
|
|
927
|
-
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 `
|
|
989
|
+
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.",
|
|
928
990
|
inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), ...REPO_ARG },
|
|
929
991
|
}, async (args) => {
|
|
930
992
|
try {
|
|
@@ -938,10 +1000,10 @@ export function buildServer(ctx) {
|
|
|
938
1000
|
let into;
|
|
939
1001
|
if (args.section !== undefined) {
|
|
940
1002
|
const info = findDoc(store, { docId });
|
|
941
|
-
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section, { ...(args.
|
|
1003
|
+
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section, { ...(args._id !== undefined ? { _id: args._id } : {}), ...(args._path !== undefined ? { _path: args._path } : {}), text: args.text }) };
|
|
942
1004
|
}
|
|
943
1005
|
else {
|
|
944
|
-
into = { kind: "document",
|
|
1006
|
+
into = { kind: "document", _id: docId };
|
|
945
1007
|
}
|
|
946
1008
|
return sugar(repoId, root, docsAppendEffects(into, args.text));
|
|
947
1009
|
}
|
|
@@ -950,7 +1012,7 @@ export function buildServer(ctx) {
|
|
|
950
1012
|
}
|
|
951
1013
|
});
|
|
952
1014
|
tool("links_retarget", {
|
|
953
|
-
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[{
|
|
1015
|
+
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`).",
|
|
954
1016
|
inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), ...REPO_ARG },
|
|
955
1017
|
}, async (args) => {
|
|
956
1018
|
try {
|
|
@@ -969,7 +1031,7 @@ export function buildServer(ctx) {
|
|
|
969
1031
|
}
|
|
970
1032
|
});
|
|
971
1033
|
tool("links_stale", {
|
|
972
|
-
description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each a link row under the `links` target's names: `
|
|
1034
|
+
description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each a link row under the `links` target's names: `_doc_id`, `_path` (the source document), `_block_id` (null for a frontmatter edge), `_predicate`, `_provenance`, `_dst_kind`, `target` = the canonical missing path, `/`-rooted like every path the surface returns (e.g. `/guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `_anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{_path,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
|
|
973
1035
|
inputSchema: { path_glob: z.string().optional(), limit: z.number().int().optional(), summary: z.boolean().optional(), ...REPO_ARG },
|
|
974
1036
|
}, async (args) => {
|
|
975
1037
|
try {
|
|
@@ -986,7 +1048,7 @@ export function buildServer(ctx) {
|
|
|
986
1048
|
}
|
|
987
1049
|
});
|
|
988
1050
|
tool("links_repair", {
|
|
989
|
-
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[{
|
|
1051
|
+
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`).",
|
|
990
1052
|
inputSchema: {
|
|
991
1053
|
repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
|
|
992
1054
|
from_target: z.string().optional(),
|
|
@@ -1034,7 +1096,7 @@ export function buildServer(ctx) {
|
|
|
1034
1096
|
/** `path_taken` exactly as the legacy document operations raised it: their message, no `data` (§9). */
|
|
1035
1097
|
const requireAbsent = (repoId, rootPath, rel, takenMessage) => requireAbsentPath(store, repoId, { rootPath }, rel, takenMessage);
|
|
1036
1098
|
tool("docs_create", {
|
|
1037
|
-
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 `
|
|
1099
|
+
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`).",
|
|
1038
1100
|
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
|
|
1039
1101
|
}, async (args) => {
|
|
1040
1102
|
try {
|
|
@@ -1049,14 +1111,14 @@ export function buildServer(ctx) {
|
|
|
1049
1111
|
}
|
|
1050
1112
|
});
|
|
1051
1113
|
tool("docs_move", {
|
|
1052
|
-
description: "Rename a document to a new path (`to_path`, `/`-rooted or bare); the document is addressed by `
|
|
1114
|
+
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_id` 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.",
|
|
1053
1115
|
inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
|
|
1054
1116
|
}, async (args) => {
|
|
1055
1117
|
try {
|
|
1056
1118
|
const scope = repoScope(args.repo);
|
|
1057
1119
|
const docId = locateDoc(scope.repoId, "docs_move", registry.get("docs_move").shape, args);
|
|
1058
1120
|
const rootPath = requireRoot(scope.rootPath);
|
|
1059
|
-
const doc = {
|
|
1121
|
+
const doc = { _id: docId, _path: pathOfDoc(docId) };
|
|
1060
1122
|
const to = canonicalDocPath(args.to_path);
|
|
1061
1123
|
requireAbsent(scope.repoId, rootPath, to, `a document already exists at ${to}`);
|
|
1062
1124
|
const retargetInbound = args.retarget_inbound !== false;
|
|
@@ -1073,21 +1135,21 @@ export function buildServer(ctx) {
|
|
|
1073
1135
|
}
|
|
1074
1136
|
});
|
|
1075
1137
|
tool("docs_delete", {
|
|
1076
|
-
description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Address it by `
|
|
1138
|
+
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 → \"\"`).",
|
|
1077
1139
|
inputSchema: { ...DOC_ARGS, ...REPO_ARG },
|
|
1078
1140
|
}, async (args) => {
|
|
1079
1141
|
try {
|
|
1080
1142
|
const scope = repoScope(args.repo);
|
|
1081
1143
|
const docId = locateDoc(scope.repoId, "docs_delete", registry.get("docs_delete").shape, args);
|
|
1082
1144
|
const rootPath = requireRoot(scope.rootPath);
|
|
1083
|
-
return sugar(scope.repoId, rootPath, deleteDocEffects({
|
|
1145
|
+
return sugar(scope.repoId, rootPath, deleteDocEffects({ _id: docId, _path: pathOfDoc(docId) }));
|
|
1084
1146
|
}
|
|
1085
1147
|
catch (e) {
|
|
1086
1148
|
return fail(e);
|
|
1087
1149
|
}
|
|
1088
1150
|
});
|
|
1089
1151
|
tool("docs_set_meta", {
|
|
1090
|
-
description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `
|
|
1152
|
+
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).",
|
|
1091
1153
|
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
|
|
1092
1154
|
}, async (args) => {
|
|
1093
1155
|
try {
|
|
@@ -1098,7 +1160,7 @@ export function buildServer(ctx) {
|
|
|
1098
1160
|
// One scalar effect per key, `unset` winning over `set` for a shared key;
|
|
1099
1161
|
// the planner drops a repeated `unset`. An empty patch is an argument
|
|
1100
1162
|
// error (3.3, §9) — it used to rewrite the frontmatter through the emitter for nothing.
|
|
1101
|
-
const built = setMetaEffects({
|
|
1163
|
+
const built = setMetaEffects({ _id: docId, _path: pathOfDoc(docId) }, args.set, args.unset);
|
|
1102
1164
|
if (built.effects.length === 0)
|
|
1103
1165
|
throw argumentError("docs_set_meta", "pass `set` and/or `unset`", shape);
|
|
1104
1166
|
return sugar(scope.repoId, rootPath, built);
|
|
@@ -1108,14 +1170,14 @@ export function buildServer(ctx) {
|
|
|
1108
1170
|
}
|
|
1109
1171
|
});
|
|
1110
1172
|
tool("docs_update", {
|
|
1111
|
-
description: "Whole-document update with smart identity preservation — sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\",
|
|
1173
|
+
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`).",
|
|
1112
1174
|
inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), ...REPO_ARG },
|
|
1113
1175
|
}, async (args) => {
|
|
1114
1176
|
try {
|
|
1115
1177
|
const scope = repoScope(args.repo);
|
|
1116
1178
|
const docId = locateDoc(scope.repoId, "docs_update", registry.get("docs_update").shape, args);
|
|
1117
1179
|
const rootPath = requireRoot(scope.rootPath);
|
|
1118
|
-
const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({
|
|
1180
|
+
const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ _id: docId }, args.content), args.reason);
|
|
1119
1181
|
return okMutated(surfaceReceipt(receipt));
|
|
1120
1182
|
}
|
|
1121
1183
|
catch (e) {
|
|
@@ -1123,7 +1185,7 @@ export function buildServer(ctx) {
|
|
|
1123
1185
|
}
|
|
1124
1186
|
});
|
|
1125
1187
|
tool("observe", {
|
|
1126
|
-
description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns `{
|
|
1188
|
+
description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns `{ _id, _path, rev, commitId, converged, echo, conflicted, dispositions }` — the document's row attributes plus a disposition summary (how identity threaded). The `path` ARGUMENT is a FILE event's path from the store's layer, not a row locator, so it keeps its bare name.",
|
|
1127
1189
|
inputSchema: { path: z.string(), content: z.string(), ...REPO_ARG },
|
|
1128
1190
|
}, async (args) => {
|
|
1129
1191
|
try {
|
|
@@ -1159,18 +1221,18 @@ export function buildServer(ctx) {
|
|
|
1159
1221
|
}
|
|
1160
1222
|
});
|
|
1161
1223
|
tool("blocks_history", {
|
|
1162
|
-
description: "A block's biography: the commits that CHANGED the block `
|
|
1163
|
-
inputSchema: {
|
|
1224
|
+
description: "A block's biography: the commits that CHANGED the block `_id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `_updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
|
|
1225
|
+
inputSchema: { _id: z.string(), limit: z.number().int().optional() },
|
|
1164
1226
|
}, async (args) => {
|
|
1165
1227
|
try {
|
|
1166
|
-
return ok(historyNode(store, args.
|
|
1228
|
+
return ok(historyNode(store, args._id, args.limit !== undefined ? { limit: args.limit } : {}));
|
|
1167
1229
|
}
|
|
1168
1230
|
catch (e) {
|
|
1169
1231
|
return fail(e);
|
|
1170
1232
|
}
|
|
1171
1233
|
});
|
|
1172
1234
|
tool("docs_diff", {
|
|
1173
|
-
description: "Block-grain diff between two revisions of a document: `[{ kind: added|removed|changed,
|
|
1235
|
+
description: "Block-grain diff between two revisions of a document: `[{ kind: added|removed|changed, _id (the block), before?, after? }]` in that order. Address the document by `_id` or `_path` (at least one); `from_rev`/`to_rev` are revision ids (from docs_history, changes_since or blocks_history). Contrast docs_diff_unified, the unified text diff. (diff before surface 3.0.)",
|
|
1174
1236
|
inputSchema: { ...DOC_ARGS, from_rev: z.string(), to_rev: z.string(), ...REPO_ARG },
|
|
1175
1237
|
}, async (args) => {
|
|
1176
1238
|
try {
|
|
@@ -1187,7 +1249,7 @@ export function buildServer(ctx) {
|
|
|
1187
1249
|
}
|
|
1188
1250
|
});
|
|
1189
1251
|
tool("docs_diff_unified", {
|
|
1190
|
-
description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{
|
|
1252
|
+
description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{ _id, _path, from, to, diff }` where `diff` is a unified diff of the two revisions' file texts — for each revision exactly the bytes docs_read_at returns, so hunk line numbers are real file lines (Myers shortest edit script; `@@ -a,b +c,d @@` hunks with 3 lines of context; lines prefixed `-`, `+` or a space; no file header; identical texts → \"\"). Address the document by `_id` or `_path` (at least one). `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `docs_diff`, which returns block-grain added/removed/changed entries. (diff_unified before surface 3.0.)",
|
|
1191
1253
|
inputSchema: { ...DOC_ARGS, from_rev: z.string().optional(), to_rev: z.string().optional(), ...REPO_ARG },
|
|
1192
1254
|
}, async (args) => {
|
|
1193
1255
|
try {
|
|
@@ -1196,7 +1258,7 @@ export function buildServer(ctx) {
|
|
|
1196
1258
|
return ok(docDiffUnified(store, docId, {
|
|
1197
1259
|
...(args.from_rev !== undefined ? { fromRev: args.from_rev } : {}),
|
|
1198
1260
|
...(args.to_rev !== undefined ? { toRev: args.to_rev } : {}),
|
|
1199
|
-
label: JSON.stringify(args.
|
|
1261
|
+
label: JSON.stringify(args._id ?? args._path),
|
|
1200
1262
|
}));
|
|
1201
1263
|
}
|
|
1202
1264
|
catch (e) {
|
|
@@ -1204,7 +1266,7 @@ export function buildServer(ctx) {
|
|
|
1204
1266
|
}
|
|
1205
1267
|
});
|
|
1206
1268
|
tool("docs_read_at", {
|
|
1207
|
-
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `
|
|
1269
|
+
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `_raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `_id`/`_path` (the path as of that revision)/`rev`. Sugar for `docs_read { _id | _path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`revisions { _path == … order by _seq }`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `_id` or `_path` plus a `rev` id. A revision whose bodies were moved to an archive segment (`_archived` in docs_history / the revisions target) reads through the segment when it is attached; otherwise the call fails `revision_archived` with `data: { rev, archive, path? }` — an operator attaches the file with `omg history attach`.",
|
|
1208
1270
|
inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
|
|
1209
1271
|
}, async (args) => {
|
|
1210
1272
|
try {
|
|
@@ -1217,7 +1279,7 @@ export function buildServer(ctx) {
|
|
|
1217
1279
|
}
|
|
1218
1280
|
});
|
|
1219
1281
|
tool("docs_history", {
|
|
1220
|
-
description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`
|
|
1282
|
+
description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`_id`, `_path`, `deleted`, `currentRev`), its ordered revision list (oldest→newest) — each version a `revisions` row under the row's names: `_rev`/`_seq`/`_commit`/`_ts`/`_origin`/`_actor`/`_hash`/`_is_current`/`_archived` (true when the revision's bodies live in an archive segment — the row stays, docs_read_at reads through the segment while it is attached). Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single document by `_id` or `_path`; at least one is required. `_hash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `_rev` into `docs_read_at` (whole doc AT that rev) or `docs_diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
|
|
1221
1283
|
inputSchema: {
|
|
1222
1284
|
path_glob: z.string().optional(),
|
|
1223
1285
|
...DOC_ARGS,
|
|
@@ -1228,17 +1290,17 @@ export function buildServer(ctx) {
|
|
|
1228
1290
|
}, async (args) => {
|
|
1229
1291
|
try {
|
|
1230
1292
|
const { repoId } = repoScope(args.repo);
|
|
1231
|
-
if (!args.path_glob && args.
|
|
1232
|
-
throw new EngineError("target_missing", "docs_history requires one of path_glob,
|
|
1293
|
+
if (!args.path_glob && args._id === undefined && args._path === undefined) {
|
|
1294
|
+
throw new EngineError("target_missing", "docs_history requires one of path_glob, _id or _path");
|
|
1233
1295
|
}
|
|
1234
1296
|
let doc;
|
|
1235
|
-
if (args.
|
|
1297
|
+
if (args._id !== undefined || args._path !== undefined) {
|
|
1236
1298
|
// A live document resolves through the locator; with include_deleted a
|
|
1237
1299
|
// tombstoned one is looked up by the same attribute.
|
|
1238
1300
|
const tomb = args.include_deleted
|
|
1239
|
-
? (args.
|
|
1240
|
-
? store.db.prepare("SELECT doc_id FROM docs WHERE doc_id = ? AND repo_id = ?").get(args.
|
|
1241
|
-
: store.db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, storagePath(args.
|
|
1301
|
+
? (args._id !== undefined
|
|
1302
|
+
? store.db.prepare("SELECT doc_id FROM docs WHERE doc_id = ? AND repo_id = ?").get(args._id, repoId)
|
|
1303
|
+
: store.db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, storagePath(args._path)))
|
|
1242
1304
|
: undefined;
|
|
1243
1305
|
doc = tomb ? tomb.doc_id : locateDoc(repoId, "docs_history", registry.get("docs_history").shape, args);
|
|
1244
1306
|
}
|
|
@@ -1254,7 +1316,7 @@ export function buildServer(ctx) {
|
|
|
1254
1316
|
}
|
|
1255
1317
|
});
|
|
1256
1318
|
tool("changes_since", {
|
|
1257
|
-
description: ladder("changes_since — Re-orient after time away: the commit feed after a cursor, each digest with its revisions [{
|
|
1319
|
+
description: ladder("changes_since — Re-orient after time away: the commit feed after a cursor, each digest with its revisions [{ _id, _path, _hash }].", "{} everything from the start; head is the current max seq", "{\"cursor\":20,\"limit\":5} commits after seq 20, five at a time (cursor pages while truncated)", "{\"origin\":\"api\"} only the writes made through the engine (observed = edits on disk; import)", "Poll with your last cursor: an empty page with cursor == head means nothing new. Read a changed document with docs_read, its blocks with query (grammar: query_syntax)."),
|
|
1258
1320
|
inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
1259
1321
|
}, async (args) => {
|
|
1260
1322
|
try {
|
|
@@ -1270,7 +1332,7 @@ export function buildServer(ctx) {
|
|
|
1270
1332
|
}
|
|
1271
1333
|
});
|
|
1272
1334
|
tool("repos_status", {
|
|
1273
|
-
description: "Repo counts (`{
|
|
1335
|
+
description: "Repo counts (`{ _id, slug, rootPath, docs, blocks, commits, openEdges, unconverged, disk }` — `_id` is the root row's id): docs, blocks, commits, open edges, unconverged doc count, and on-disk drift. " +
|
|
1274
1336
|
"`disk` reports a READ-ONLY scan of the working tree vs the DB: `changed` (files edited on disk but not re-ingested), " +
|
|
1275
1337
|
"`deleted` (docs whose file is gone from disk), `untracked` (new *.md not yet ingested), and `checked` " +
|
|
1276
1338
|
"(false when the server has no working-tree path — disk agreement is then UNVERIFIED, not clean).",
|