@omgbase/core 0.11.1 → 0.19.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/size.d.ts +76 -0
- package/dist/src/core/store/size.js +223 -0
- 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 +12 -5
- package/dist/src/index.js +18 -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/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +27 -6
- package/dist/src/mcp/server.d.ts +56 -1
- package/dist/src/mcp/server.js +365 -203
- package/dist/src/mutate/apply.d.ts +93 -12
- package/dist/src/mutate/apply.js +203 -113
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- 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 +363 -0
- package/dist/src/mutate/effects.js +1161 -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 +210 -0
- package/dist/src/mutate/plan.js +512 -0
- package/dist/src/mutate/select.d.ts +89 -0
- package/dist/src/mutate/select.js +879 -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/oqx-js/planner.js +4 -0
- package/dist/src/oqx-js/run.d.ts +28 -1
- package/dist/src/oqx-js/run.js +85 -22
- package/dist/src/spec-versions.d.ts +6 -6
- 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/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- package/package.json +2 -2
package/dist/src/mcp/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { CallToolRequestSchema, ErrorCode
|
|
2
|
+
import { CallToolRequestSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { docsOutline } from "../core/read/outline.js";
|
|
5
5
|
import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
|
|
6
6
|
import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
|
|
7
|
-
import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT
|
|
7
|
+
import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT } from "../core/read/reader.js";
|
|
8
8
|
import { resolveRef } from "../core/read/refs.js";
|
|
9
9
|
import { VERSION } from "../core/index.js";
|
|
10
10
|
import { normalizeText, normalizeVisibleText } from "../core/hash.js";
|
|
@@ -13,48 +13,58 @@ 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 {
|
|
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";
|
|
20
|
+
import { 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
|
+
import { withWriterLockAsync } from "../sync/writer-lock.js";
|
|
26
27
|
import { QUERY_SYNTAX } from "./reference.js";
|
|
27
28
|
import { versionInfo } from "../version.js";
|
|
28
29
|
import { storagePath, referencePath } from "../core/paths.js";
|
|
29
30
|
import { isContainerBlock } from "../format/index.js";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
import { surfaceInboundLink, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfacePlan, surfaceReceipt, } from "../surface-paths.js";
|
|
32
|
+
export const TOOLS_PROFILES = ["full", "minimal"];
|
|
33
|
+
/**
|
|
34
|
+
* The `minimal` profile (spec/surface §4 "Profiles", 4.3): the ten tools an
|
|
35
|
+
* agent works a knowledge base with — `query` for every read it covers,
|
|
36
|
+
* `query_syntax` (the grammar the ladders point at), `docs_read` (whole
|
|
37
|
+
* document, `include_ids` for ids/hashes/parents), `docs_tree` (orientation),
|
|
38
|
+
* `blocks_resolve` (the one ranked hybrid lookup), `changes_since`
|
|
39
|
+
* (re-orientation), `plan` / `execute` / `apply` (the one writer and its
|
|
40
|
+
* preview) and `version` (which reports the profile). The same list as the Rust
|
|
41
|
+
* catalog's `MINIMAL_TOOLS` (`crates/omgbase-surface/src/catalog.rs`).
|
|
42
|
+
*/
|
|
43
|
+
export const MINIMAL_TOOLS = [
|
|
44
|
+
"query", "query_syntax", "docs_read", "docs_tree", "blocks_resolve", "changes_since", "plan", "execute", "apply", "version",
|
|
45
|
+
];
|
|
46
|
+
/** Each profile's membership test: `full` keeps every tool, `minimal` the ten of `MINIMAL_TOOLS`. */
|
|
47
|
+
export const PROFILES = {
|
|
48
|
+
full: () => true,
|
|
49
|
+
minimal: (tool) => MINIMAL_TOOLS.includes(tool),
|
|
50
|
+
};
|
|
51
|
+
/** Parse a `--tools` value / the `mcp.tools` setting; null for anything but the two names. */
|
|
52
|
+
export function parseToolsProfile(value) {
|
|
53
|
+
return value === "full" || value === "minimal" ? value : null;
|
|
54
|
+
}
|
|
55
|
+
// The placement and guard schemas the block-level sugar shares (spec/mutate
|
|
56
|
+
// §1.1/§1.2; the effect vocabulary spells them the same way). blocks_insert /
|
|
57
|
+
// blocks_move carry the op-level expectation of §1.2: only
|
|
58
|
+
// `parent_children_hash` (the destination parent's live direct child ids joined
|
|
59
|
+
// by "," and sha256-hexed) has a meaning there — there is no target block whose
|
|
60
|
+
// content_hash could be checked — so their schema names that one key alone (an
|
|
61
|
+
// accidental `content_hash` is dropped, not an error).
|
|
38
62
|
const atSchema = z.union([
|
|
39
63
|
z.literal("start"),
|
|
40
64
|
z.literal("end"),
|
|
41
65
|
z.object({ before: z.string() }),
|
|
42
66
|
z.object({ after: z.string() }),
|
|
43
67
|
]);
|
|
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
68
|
const parentExpectSchema = z.object({
|
|
59
69
|
parent_children_hash: z.string().optional(),
|
|
60
70
|
});
|
|
@@ -62,14 +72,33 @@ const expectSchema = z.object({
|
|
|
62
72
|
content_hash: z.string().optional(),
|
|
63
73
|
parent_children_hash: z.string().optional(),
|
|
64
74
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
// The public Effect vocabulary (spec/mutate §11, 2.3) — the `plan` / `execute` /
|
|
76
|
+
// `apply.effects` grammar. The boundary schema is deliberately loose (an
|
|
77
|
+
// object whose `op` is one of the nine): the planner's own `validateEffects`
|
|
78
|
+
// checks every key, target kind and field spelling and reports the failure as
|
|
79
|
+
// the same `filter_invalid { hint: "arguments" }` envelope, naming `effects[i]`
|
|
80
|
+
// and the offending key — a message both engines compose from the spec.
|
|
81
|
+
/** `expect: { max }` of a query-directed request (spec/mutate §11.8, 4.1): the bound on the selection. */
|
|
82
|
+
const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
|
|
83
|
+
/** The selector half of `plan` / `apply` / `execute` arguments, as given (absent keys stay absent so the planner tells "no expect" apart). */
|
|
84
|
+
function selectorArgs(args) {
|
|
85
|
+
return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
|
|
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, 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");
|
|
88
|
+
// An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
|
|
89
|
+
// each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
|
|
90
|
+
// a rung is a line beginning `{` with the call's arguments as JSON, three
|
|
91
|
+
// spaces, a comment; a line holding `…` or `<…>` is a schema line — and a last
|
|
92
|
+
// line pointing at `query_syntax`. The rungs are extracted mechanically
|
|
93
|
+
// (`mcp/examples.test.ts`) into `spec/surface/cases/examples.json` and run
|
|
94
|
+
// against the alchemy corpus, so every example stays true. Ids and paths are
|
|
95
|
+
// the alchemy fixture's (`d_14` is substances/salt.md, `b_153` the `Tasks`
|
|
96
|
+
// heading of processes/calcination.md). Since 4.4 the rungs are written in
|
|
97
|
+
// the `spec/mutate` 3.2 sugar — a bare path, id or `$new.n` as a target, the
|
|
98
|
+
// `-> expr` leader for an expression — and each trailing comment teaches one
|
|
99
|
+
// thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
|
|
100
|
+
// `mcp/examples.test.ts`).
|
|
101
|
+
const ladder = (...lines) => lines.join("\n");
|
|
73
102
|
function ok(payload) {
|
|
74
103
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
75
104
|
}
|
|
@@ -80,18 +109,66 @@ function fail(err) {
|
|
|
80
109
|
}
|
|
81
110
|
/** The message every tool uses for a `semantic(...)` call the server cannot rank. */
|
|
82
111
|
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)";
|
|
112
|
+
/**
|
|
113
|
+
* The tools that can commit — the host serializes these under the writer lock
|
|
114
|
+
* of spec/sync §7. The same list as the Rust catalog's `Surface::is_write_tool`
|
|
115
|
+
* (`crates/omgbase-surface/src/catalog.rs`). `plan` is listed too: it reads
|
|
116
|
+
* the documents a write would touch, so a preview waits for a writer exactly as
|
|
117
|
+
* the write would (through 3.x the sugar's `dry_run` played that role).
|
|
118
|
+
*/
|
|
119
|
+
export const WRITE_TOOLS = new Set([
|
|
120
|
+
"apply", "plan", "execute",
|
|
121
|
+
"blocks_insert", "blocks_update", "blocks_move", "blocks_remove", "blocks_split", "blocks_merge", "blocks_check",
|
|
122
|
+
"nodes_set", "blocks_append", "docs_append",
|
|
123
|
+
"links_retarget", "links_repair",
|
|
124
|
+
"docs_create", "docs_move", "docs_delete", "docs_set_meta", "docs_update",
|
|
125
|
+
"observe", "observe_many", "observe_delete",
|
|
126
|
+
]);
|
|
83
127
|
export function buildServer(ctx) {
|
|
84
128
|
const server = new McpServer({ name: "omgbase", version: VERSION });
|
|
85
129
|
const { store } = ctx;
|
|
130
|
+
// The kernel's share of the workspace dir: spread into every ApplyRequest /
|
|
131
|
+
// DocOpContext so the stat cache is recorded on the MCP path too.
|
|
132
|
+
const LOCK_DIR = ctx.omgbaseDir !== undefined ? { omgbaseDir: ctx.omgbaseDir } : {};
|
|
86
133
|
// ---- registration: listing through the SDK, validation + dispatch ours ----
|
|
87
134
|
// `server.registerTool` publishes the name, description and JSON schema on
|
|
88
135
|
// `tools/list`; the `tools/call` handler is replaced below so the arguments
|
|
89
136
|
// are validated by `validateArguments` (spec/surface §4 "Arguments") and a
|
|
90
137
|
// failure is the engine's own `filter_invalid`, not the SDK's text.
|
|
138
|
+
//
|
|
139
|
+
// A write tool (`WRITE_TOOLS`) is wrapped at registration so its whole call —
|
|
140
|
+
// argument resolution, the kernel's file-CAS + commit, `docs_move`'s inbound
|
|
141
|
+
// retarget — runs under one writer-lock acquire when the host supplied the
|
|
142
|
+
// workspace dir (the kernel's own inner lock re-enters it). The handler
|
|
143
|
+
// catches its own errors; the only one escaping the wrapper is the lock's
|
|
144
|
+
// `WriterLockTimeout`, rendered through `fail` like every other failure — a
|
|
145
|
+
// plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
|
|
146
|
+
// (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
|
|
147
|
+
//
|
|
148
|
+
// The profile (`ctx.tools`, spec/surface §4 "Profiles") decides which
|
|
149
|
+
// registrations happen at all: a tool outside it is neither listed nor
|
|
150
|
+
// callable — `tools/call` on its name is the same `unknown tool <name>` a
|
|
151
|
+
// name that never existed gets — and a tool inside it is registered exactly
|
|
152
|
+
// as under `full` (a profile filters listing and calling, never semantics).
|
|
153
|
+
const profile = ctx.tools ?? "full";
|
|
154
|
+
const inProfile = PROFILES[profile];
|
|
91
155
|
const registry = new Map();
|
|
92
156
|
function tool(name, def, handler) {
|
|
93
|
-
|
|
94
|
-
|
|
157
|
+
if (!inProfile(name))
|
|
158
|
+
return;
|
|
159
|
+
const lockDir = ctx.omgbaseDir;
|
|
160
|
+
const run = WRITE_TOOLS.has(name) && lockDir !== undefined
|
|
161
|
+
? async (args) => {
|
|
162
|
+
try {
|
|
163
|
+
return await withWriterLockAsync(lockDir, () => handler(args), ctx.writerLock);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return fail(e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
: handler;
|
|
170
|
+
server.registerTool(name, def, run);
|
|
171
|
+
registry.set(name, { shape: def.inputSchema, handler: run });
|
|
95
172
|
}
|
|
96
173
|
// Multi-repo (ADR-014): every tool accepts an optional `repo` slug. Resolve it
|
|
97
174
|
// to a repo id + its (derived) working-tree root per call; omitting it uses the
|
|
@@ -188,7 +265,12 @@ export function buildServer(ctx) {
|
|
|
188
265
|
// several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
|
|
189
266
|
// the heading's id to `blocks_append` instead). Never an id lookup — the
|
|
190
267
|
// id form is `blocks_append`'s (§9).
|
|
191
|
-
|
|
268
|
+
//
|
|
269
|
+
// Each miss carries a `fix` (spec/surface §9, 4.4): a literal corrected call.
|
|
270
|
+
// Several matches → `blocks_append` with the first candidate's id; none → the
|
|
271
|
+
// one heading whose text matches up to ASCII case, as `blocks_append`, else
|
|
272
|
+
// the same `docs_append` without `section` (the document's end).
|
|
273
|
+
function resolveSectionHeading(docId, docPath, section, call) {
|
|
192
274
|
const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
|
|
193
275
|
const rows = store.db
|
|
194
276
|
.prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
|
|
@@ -196,11 +278,16 @@ export function buildServer(ctx) {
|
|
|
196
278
|
const matches = rows.filter((r) => normalizeText(r.text) === needle);
|
|
197
279
|
const shown = referencePath(docPath);
|
|
198
280
|
if (matches.length === 0) {
|
|
199
|
-
|
|
281
|
+
const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
282
|
+
const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
|
|
283
|
+
const fix = near.length === 1
|
|
284
|
+
? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
|
|
285
|
+
: fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
|
|
286
|
+
throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
|
|
200
287
|
}
|
|
201
288
|
if (matches.length > 1) {
|
|
202
289
|
throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(section)} matches ${matches.length} headings in ${shown}; pass the heading's id to blocks_append`, {
|
|
203
|
-
data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })) },
|
|
290
|
+
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 }) },
|
|
204
291
|
});
|
|
205
292
|
}
|
|
206
293
|
return matches[0].block_id;
|
|
@@ -233,13 +320,6 @@ export function buildServer(ctx) {
|
|
|
233
320
|
const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
|
|
234
321
|
return row?.doc_id;
|
|
235
322
|
}
|
|
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
323
|
function resolveAt(repoId, at) {
|
|
244
324
|
if (!at || at === "start" || at === "end")
|
|
245
325
|
return at ?? "end";
|
|
@@ -247,11 +327,30 @@ export function buildServer(ctx) {
|
|
|
247
327
|
return { before: resolveBlockRef(repoId, at.before) };
|
|
248
328
|
return { after: resolveBlockRef(repoId, at.after) };
|
|
249
329
|
}
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
330
|
+
// The sugar tools are `apply { effects }` (spec/surface §4 "Sugar over
|
|
331
|
+
// effects", 4.0; ADR-021): each tool resolves its refs and runs its own
|
|
332
|
+
// pre-checks, hands the ids to the shared builder of `mutate/sugar.ts` (one
|
|
333
|
+
// builder per operation — the same table the `omg` verbs, the fixture runner
|
|
334
|
+
// and the Rust port use), and `applySugar` plans + executes the effects as one
|
|
335
|
+
// request under the tool's fixed `reason` with `actor: "agent:mcp"` — the
|
|
336
|
+
// planner's `effect_index` dropped from any error, so `data` stays as pinned.
|
|
337
|
+
// Every sugar tool returns the Receipt (paths rooted); the preview of any of
|
|
338
|
+
// them is `plan { effects }` — there is no `dry_run` since 4.0.
|
|
339
|
+
function runSugar(repoId, rootPath, sugar, reason) {
|
|
340
|
+
const { receipt } = applySugar(store, { repoId, rootPath, actor: "agent:mcp", ...(reason !== undefined ? { reason } : {}), ...LOCK_DIR }, sugar);
|
|
341
|
+
return { receipt: receipt };
|
|
342
|
+
}
|
|
343
|
+
function sugar(repoId, rootPath, built) {
|
|
344
|
+
return okMutated(surfaceReceipt(runSugar(repoId, rootPath, built).receipt));
|
|
345
|
+
}
|
|
346
|
+
const blockTarget = (id) => ({ kind: "block", id });
|
|
347
|
+
// A parent ref as an effect target: a document ref is the document's top level, a block ref the block.
|
|
348
|
+
const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", id: p.docId } : { kind: "block", id: p.parent };
|
|
349
|
+
// An empty block list is an argument error (3.2, §9) — it used to reach the
|
|
350
|
+
// kernel as a malformed op (`block_missing "block undefined not found"`).
|
|
351
|
+
function requireSome(tool, arg, list) {
|
|
352
|
+
if (list.length === 0)
|
|
353
|
+
throw argumentError(tool, `\`${arg}\` must name at least one block`, registry.get(tool).shape);
|
|
255
354
|
}
|
|
256
355
|
// Guard shared by every mutating tool: a sourceless (headless-only) repo has no
|
|
257
356
|
// working tree to write through, so mutation is disabled with a loud error.
|
|
@@ -316,7 +415,7 @@ export function buildServer(ctx) {
|
|
|
316
415
|
}
|
|
317
416
|
});
|
|
318
417
|
tool("docs_read", {
|
|
319
|
-
description: "
|
|
418
|
+
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)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
|
|
320
419
|
inputSchema: {
|
|
321
420
|
...DOC_ARGS,
|
|
322
421
|
include_ids: z.boolean().optional(),
|
|
@@ -422,7 +521,7 @@ export function buildServer(ctx) {
|
|
|
422
521
|
}
|
|
423
522
|
});
|
|
424
523
|
tool("docs_tree", {
|
|
425
|
-
description: "
|
|
524
|
+
description: ladder("docs_tree — Orientation: the shape of a repo in one small call, like tree -L depth with document and block counts per entry.", "{} the root at depth 1: one entry per directory (path ends in /) or shallow document, plus total", "{\"path\":\"/processes/\",\"depth\":1} descend into a directory entry", "{\"depth\":2,\"limit\":5} collapse deeper; limit/cursor page the entries, total always covers everything under path", "Ranked or attribute-based discovery: blocks_resolve, or query (select path from docs where ...). Grammar: query_syntax."),
|
|
426
525
|
inputSchema: {
|
|
427
526
|
path: z.string().optional(),
|
|
428
527
|
depth: z.number().int().optional(),
|
|
@@ -470,15 +569,16 @@ export function buildServer(ctx) {
|
|
|
470
569
|
}
|
|
471
570
|
});
|
|
472
571
|
tool("query_syntax", {
|
|
473
|
-
description: "
|
|
572
|
+
description: ladder("query_syntax — The full OQX reference: clause order, targets and fields, scalar semantics, nested queries and the root row, follow, paging, worked examples.", "{} no arguments; returns { syntax }", "Call it before writing a non-trivial query; every other tool's examples end here, at query_syntax."),
|
|
474
573
|
inputSchema: {},
|
|
475
574
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
476
575
|
tool("query", {
|
|
477
|
-
description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Clause order is fixed: select, from, where, frontier, follow, order by, limit, offset — `select` first (`select path, era from docs where era > 1600 order by era desc limit 3`), its keyword optional only there; `from docs where … select …` is a parse error naming the order. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `dst.title`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. The clauses, in writing order: `select` projects the hit's columns (`select path, title`; an alias per computed item, `n: size(tags)`; no `select` = the bare envelope); `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|links` selects the repository's docs/blocks/nodes/links (the `links` target is the authored link graph as first-class rows — one row per link RELATION; the `md:link` node is a link's occurrence in the text, `via_id` points at it, and a frontmatter link has a row and no node — `id`/`path` plus the plain fields doc_id/block_id/via_id/dst_id/dst_path/uri/predicate/provenance/dst_kind/anchor/field, with path/doc.* reaching the SOURCE doc and `dst` the destination DOCUMENT row when the link points at one — `dst.title`, `dst.layer`, `dst_kind == \"document\"`; absent for an external or block destination and for a dangling target); `where` filters the rows; then `frontier`, `follow`, `order by`, `limit`/`offset` (each below). Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … frontier … follow … order by … limit N offset N` (`frontier <pred>` is a clause of a body that has a `follow` — it marks the body's SEEDS, oqx 0.20). Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause carries its keyword, with one piece of sugar (oqx 0.17): a block's leading expression is its `where` when it is predicate-shaped (`nodes exists { kind == \"md:task\" }` ≡ `nodes exists { where kind == \"md:task\" }`); a bare name or dotted path is a projection, so filter on a bare field with `is active`, and `{ name where … }` is the projection + filter (`{ age >= 18, name }` is an error). A top-level predicate needs `where` (`where` follows `from`, so `age > 15 from people` is an error), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `docs count { … }` for the consumer, or `select count from docs` to project a field named count). More sugar, each desugaring in the parser to the explicit directive: a receiver block without a consumer is `collect` (`docs { type == \"x\" }`, `nodes { kind == \"md:task\" }`, `size(nodes { is checked })` — wherever a value stands, and after a `follow` destination too: `follow children { depth 2 }` is that destination's block; `x distinct { … }` is `x collect distinct { … }`); a tuple `(a, b)` (two or more expressions, concatenated one level flat) is a value and a receiver (`\"x\" in (tags, aliases)`, `follow (before, after) { depth 2 }`); brackets `x[p]` = `x first { where p }` (the first match or absent), `x[p]!` = `x single { where p }` required (exactly one, else filter_invalid), `x[n]` = `x first { offset n }` (positional; out of range is absent); postfix `x!` = required (the value, or a loud error naming the expression and the row — never a filter, never a coercion; tightest: `a!.b` vs `a.b!`); prefix `is x` / `not x` (truthiness); infix `x is y` / `x is not y` compares identity (`$key`: a store row's id, else the value — `x is null` is the absence test; for scalars `is` ≡ `==`); and a `select` item may use the items to its left (`boss: ^docs[slug == ^manager], name: boss.title`). `and` / `or` / `not` are the connectives; `&&` / `||` / `!` are accepted synonyms (the canonical printer writes the words). `where` may reference the same body's `select` aliases (`select path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (doc_id, block_id, kind/name/value; doc_id, type/text, ordinal/depth) or row attribute (id, path, raw, hash, updated_at) reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $it values }` yields a plain array. `$it` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $it == \"pricing\" }`); inside a block `^$it` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" and not checked }` = no open tasks; also a whole-query directive (`docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$it` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $it }`, `from nodes where kind == \"md:task\" and entries(attrs) exists { where $key == \"checked\" and $it }`; works on `data` (the merged authored bag: frontmatter + inline) / `frontmatter` / `inline` (nested dotted keys folded back — an authored `$`-prefixed key such as `$version` is reached as `frontmatter.$version` and listed by `entries(frontmatter)`; a bare `$version` is an unknown OQX name and reads nothing), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `and`, `or`, `not`, and grouping (`layer == \"canon\" or nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select path, open from docs where nodes collect { ^open: value where kind == \"md:task\" and not attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the root row's collections `^docs` / `^nodes` / `^blocks` / `^links` (the repository is OQX's root row, one scope out from a top-level row — one caret per enclosing block, or the absolute `0^docs` from any depth; an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `^<t> exists { where … == ^k }` = semi-join, `not … exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: ^nodes single { where kind == \"person\" and attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `^docs` / `0^docs` (the root row's collections; `^id` / `0^id` is the repository id, `^$it` the root object). A bare `docs` INSIDE a block is an error (\"did you mean `^docs`\"), never an empty read; `$repo` (surface < 2.0) is refused with the replacement spelled out. PATHS are `/`-rooted on the surface (surface 2.0): every path returned — `path`, an edge's `dst_path`, a hit's `path` — is the reference form (`/a/b.md`), and a string literal compared with `path`/`dst_path` by `==`/`!=` or passed to their `startsWith` accepts both forms (`path == \"a.md\"` and `path == \"/a.md\"` both match); a property's value is the author's, so a reference held in frontmatter compares with `^path` directly (`^docs { ^path in list(after) }` = the documents whose `after` names me). `refs(field)` resolves the document REFERENCES a property holds (a string or a list of `/a.md`, `a.md` or `d_…` elements; dangling and non-string elements dropped) to live docs rows, so it is a source (`from refs(\"/a.md\")`), a receiver (`refs(before) collect { path, when }`, `refs(see_also)[0]`) or a `follow` destination (`follow refs(before), refs(after)` walks a timeline both ways); `follow before` over the strings fails — a hit must be a document, block, node or edge row. Top-level consumers: the whole query can be a consumer directive over a bare root receiver (a target name at the root scope) to change its result shape — `docs count { … }`, `docs exists { … }` and `docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `docs first { … }` / `docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (A bare target at the root scope is the same collection `^<target>` reaches from inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" and text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider that has embedded this repository (else `provider_unavailable`, naming what to configure — never a silent unranked result) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; a brace after any destination opens that destination's block (oqx 0.20 — there is no options block), an ordinary block with two clauses of its own after `where`: `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier; only occurrences this destination reached), `{ depth <n> }` bounds the walk through this destination (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). Identity is `$key` — a store row's id; a block may project `$key: <expr>` to rekey the rows it yields (a destination block's `$key:` is not a column) — e.g. `follow out_links collect { $key: group from dst }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" and $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in a destination block's `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2). The authored citation graph is walked through the LINK ROWS with a destination block (docs→docs): `follow out_links collect { from dst }` reaches the documents a doc links TO (`dst` is a link's destination document; an external or dangling link has none and contributes no successor), `follow in_links collect { from doc }` its backlinks (`doc` is the link's source); `depth`/`frontier` are clauses of the block — `follow out_links collect { from dst depth 2 }`; filter the rows first for a predicate-restricted walk (`follow out_links collect { from dst where predicate == \"depends_on\" }`). There is no `out`/`in` relation (surface 3.0 — a doc's `out`/`in` is an ordinary absent property). Consumers compose over the walk (`blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" and name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out_links`/`doc.in_links` (a doc's outgoing/incoming links as rows; the documents at their other end are `out_links collect distinct { from dst }` / `in_links collect distinct { from doc }`); from links — `doc` (the source document) and `dst` (the destination document, when there is one); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_links` (this block's outgoing links as rows — body links + inline fields; frontmatter links are doc-grain, use `doc.out_links`); from any row — `^docs` / `^nodes` / `^blocks` / `^links` (the root row's collections; `0^docs` from any depth). Returns lean hits {id, path, ...projections} — the envelope keys are the attributes, so a hit's `id` pastes into the next `where id == …` unchanged, and is the value a tool's `id` argument takes (the name rule, surface 3.0: omgbase adds no `$` names — `$` belongs to OQX's `$it`/`$key`/`$depth`…; every engine attribute is bare snake_case and WINS on collision — id/path everywhere, raw/hash/updated_at on docs and blocks, title/tags/format on docs — and an authored key of the same name is reached through `data.<k>` / `frontmatter.<k>` / `inline.<k>`; `select id, path` is simply the envelope and any other projection whose key would be id/path must be aliased (`p: data.path`); every tool result spells a row with the same names) — with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
|
|
576
|
+
description: ladder("query — Read anything: rows with id and path plus your projection, from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where type == \\\"process\\\"\"} frontmatter fields by name", "{\"query\":\"select b: block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id by its text (the heading_id an insert into a section takes)", "{\"query\":\"select value from nodes where kind == \\\"md:task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\"} open tasks in one document (a task node's text is its value)", "{\"query\":\"docs count { where layer == \\\"canon\\\" }\"} a scalar", "{\"query\":\"select path from docs where \\\"process\\\" in tags order by path limit 5\"} list membership, ordering; a truncated page's cursor continues it", "Full grammar, follow, ^docs joins, text()/semantic(): call query_syntax."),
|
|
478
577
|
inputSchema: {
|
|
479
578
|
query: z.string(),
|
|
480
579
|
limit: z.number().int().optional(),
|
|
481
580
|
cursor: z.string().nullable().optional(),
|
|
581
|
+
bare: z.boolean().optional(),
|
|
482
582
|
...REPO_ARG,
|
|
483
583
|
},
|
|
484
584
|
}, async (args) => {
|
|
@@ -491,6 +591,7 @@ export function buildServer(ctx) {
|
|
|
491
591
|
return ok(await oqxRunAsync(store, repoId, args.query, {
|
|
492
592
|
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
493
593
|
...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
|
|
594
|
+
...(args.bare !== undefined ? { bare: args.bare } : {}),
|
|
494
595
|
}, rankableEmbed(repoId)));
|
|
495
596
|
}
|
|
496
597
|
catch (e) {
|
|
@@ -534,7 +635,7 @@ export function buildServer(ctx) {
|
|
|
534
635
|
}
|
|
535
636
|
});
|
|
536
637
|
tool("blocks_resolve", {
|
|
537
|
-
description: "
|
|
638
|
+
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."),
|
|
538
639
|
inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
539
640
|
}, async (args) => {
|
|
540
641
|
try {
|
|
@@ -551,11 +652,12 @@ export function buildServer(ctx) {
|
|
|
551
652
|
}
|
|
552
653
|
});
|
|
553
654
|
tool("apply", {
|
|
554
|
-
description: "
|
|
655
|
+
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\":\"from blocks where 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."),
|
|
555
656
|
inputSchema: {
|
|
556
|
-
|
|
657
|
+
effects: z.array(effectSchema),
|
|
658
|
+
select: z.string().optional(),
|
|
659
|
+
expect: selectionExpectSchema.optional(),
|
|
557
660
|
reason: z.string().optional(),
|
|
558
|
-
dry_run: z.boolean().optional(),
|
|
559
661
|
...REPO_ARG,
|
|
560
662
|
},
|
|
561
663
|
}, async (args) => {
|
|
@@ -563,14 +665,53 @@ export function buildServer(ctx) {
|
|
|
563
665
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
564
666
|
if (!rootPath)
|
|
565
667
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
668
|
+
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
669
|
+
// Plan + execute under one lock and one transaction → the Receipt (spec/mutate §11.7).
|
|
670
|
+
return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR })));
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
return fail(e);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
// ---- the Effect layer's verbs (spec/mutate §11, spec/surface §4 — 3.1) ----
|
|
677
|
+
// `plan` reads only and returns the frozen Plan with its preview; `execute`
|
|
678
|
+
// commits exactly a plan (the plan form, or the digest form `plan_id` +
|
|
679
|
+
// `effects`); `apply { effects }` above is plan + execute in one call.
|
|
680
|
+
tool("plan", {
|
|
681
|
+
description: ladder("plan — Preview a write: the frozen Plan (summary, diffs, $new.n per minted id) burns no id; execute next.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = document", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} under a heading", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target = the hit", "{\"select\":\"from docs where 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."),
|
|
682
|
+
inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
|
|
683
|
+
}, async (args) => {
|
|
684
|
+
try {
|
|
685
|
+
const { repoId, rootPath } = repoScope(args.repo);
|
|
686
|
+
if (!rootPath)
|
|
687
|
+
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
688
|
+
return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR })));
|
|
689
|
+
}
|
|
690
|
+
catch (e) {
|
|
691
|
+
return fail(e);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
tool("execute", {
|
|
695
|
+
description: ladder("execute — Commit exactly a plan from plan: 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\":\"3ece126452106400210f07047029a961691b814252ba4437f5a5f1c3301629da\",\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} digest form: the plan_id plan returned for these effects", "Or pass the whole object: {\"plan\": the Plan as returned}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
|
|
696
|
+
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 },
|
|
697
|
+
}, async (args) => {
|
|
698
|
+
try {
|
|
699
|
+
const { repoId, rootPath } = repoScope(args.repo);
|
|
700
|
+
if (!rootPath)
|
|
701
|
+
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
702
|
+
const shape = registry.get("execute").shape;
|
|
703
|
+
const planForm = args.plan !== undefined;
|
|
704
|
+
const selector = args.select !== undefined || args.expect !== undefined;
|
|
705
|
+
const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
|
|
706
|
+
if (planForm === digestForm)
|
|
707
|
+
throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
|
|
708
|
+
if (digestForm && (args.plan_id === undefined || args.effects === undefined))
|
|
709
|
+
throw argumentError("execute", "the digest form needs both `plan_id` and `effects`", shape);
|
|
710
|
+
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
711
|
+
const receipt = planForm
|
|
712
|
+
? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
|
|
713
|
+
: executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
|
|
714
|
+
return okMutated(surfaceReceipt(receipt));
|
|
574
715
|
}
|
|
575
716
|
catch (e) {
|
|
576
717
|
return fail(e);
|
|
@@ -579,143 +720,132 @@ export function buildServer(ctx) {
|
|
|
579
720
|
// ---- block-level sugar (ref-accepting peers of the `apply` primitive) -------
|
|
580
721
|
// These mirror the CLI verbs `insert`/`update`/`move`/`rm`/`split`/`merge`
|
|
581
722
|
// 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
|
|
723
|
+
// resolves them + pins CAS server-side, builds exactly the effects the CLI
|
|
724
|
+
// would (the shared builders), and applies them. This is what lets
|
|
725
|
+
// `omg <verb> --server` feel identical to local: the resolution that used to
|
|
726
|
+
// live in the CLI now lives next to the store, so a remote client needs no
|
|
727
|
+
// local database.
|
|
586
728
|
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(),
|
|
729
|
+
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.",
|
|
730
|
+
inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
|
|
589
731
|
}, async (args) => {
|
|
590
732
|
try {
|
|
591
733
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
592
734
|
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);
|
|
735
|
+
// `insert into document|block at …`: a document parent is the top level
|
|
736
|
+
// (the planner pins the op to it). The caller's expect rides through as
|
|
737
|
+
// the destination-parent expectation (spec/mutate §1.2).
|
|
738
|
+
const into = parentTarget(resolveParentRef(repoId, args.to));
|
|
739
|
+
return sugar(repoId, root, insertEffects(into, resolveAt(repoId, args.at), args.markdown, args.expect));
|
|
600
740
|
}
|
|
601
741
|
catch (e) {
|
|
602
742
|
return fail(e);
|
|
603
743
|
}
|
|
604
744
|
});
|
|
605
745
|
tool("blocks_update", {
|
|
606
|
-
description: "Replace a block's markdown
|
|
607
|
-
inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(),
|
|
746
|
+
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`.",
|
|
747
|
+
inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
|
|
608
748
|
}, async (args) => {
|
|
609
749
|
try {
|
|
610
750
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
611
751
|
const root = requireRoot(rootPath);
|
|
612
752
|
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);
|
|
753
|
+
const built = updateBlockEffects(block, { markdown: args.markdown, checked: args.checked, expect: args.expect });
|
|
754
|
+
// Nothing to set is an argument error (4.0): there is no effect for "touch".
|
|
755
|
+
if (built.effects.length === 0)
|
|
756
|
+
throw argumentError("blocks_update", "pass `markdown` and/or `checked`", registry.get("blocks_update").shape);
|
|
757
|
+
return sugar(repoId, root, built);
|
|
626
758
|
}
|
|
627
759
|
catch (e) {
|
|
628
760
|
return fail(e);
|
|
629
761
|
}
|
|
630
762
|
});
|
|
631
763
|
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(),
|
|
764
|
+
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`.",
|
|
765
|
+
inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
|
|
634
766
|
}, async (args) => {
|
|
635
767
|
try {
|
|
636
768
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
637
769
|
const root = requireRoot(rootPath);
|
|
770
|
+
requireSome("blocks_move", "blocks", args.blocks);
|
|
638
771
|
const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
|
|
639
|
-
const
|
|
772
|
+
const parentRef = resolveParentRef(repoId, args.to);
|
|
640
773
|
// The kernel reads `{ doc: true }` + start/end as "the SOURCE doc's top
|
|
641
774
|
// 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) {
|
|
775
|
+
if (typeof parentRef.parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== parentRef.docId) {
|
|
643
776
|
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
777
|
}
|
|
645
|
-
|
|
646
|
-
const ops = [{ op: "move", blocks, to, ...(args.expect ? { expect: args.expect } : {}) }];
|
|
647
|
-
return applyOps(repoId, root, ops, "blocks_move", args.dry_run);
|
|
778
|
+
return sugar(repoId, root, moveBlocksEffects(blocks, parentTarget(parentRef), resolveAt(repoId, args.at), args.expect));
|
|
648
779
|
}
|
|
649
780
|
catch (e) {
|
|
650
781
|
return fail(e);
|
|
651
782
|
}
|
|
652
783
|
});
|
|
653
784
|
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()),
|
|
785
|
+
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.",
|
|
786
|
+
inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
|
|
656
787
|
}, async (args) => {
|
|
657
788
|
try {
|
|
658
789
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
659
790
|
const root = requireRoot(rootPath);
|
|
791
|
+
requireSome("blocks_remove", "blocks", args.blocks);
|
|
660
792
|
const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
|
|
661
|
-
|
|
662
|
-
return applyOps(repoId, root, ops, "blocks_remove", args.dry_run);
|
|
793
|
+
return sugar(repoId, root, removeBlocksEffects(blocks));
|
|
663
794
|
}
|
|
664
795
|
catch (e) {
|
|
665
796
|
return fail(e);
|
|
666
797
|
}
|
|
667
798
|
});
|
|
668
799
|
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()),
|
|
800
|
+
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`.",
|
|
801
|
+
inputSchema: { id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
|
|
671
802
|
}, async (args) => {
|
|
672
803
|
try {
|
|
673
804
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
674
805
|
const root = requireRoot(rootPath);
|
|
675
806
|
const block = resolveBlockRef(repoId, args.id);
|
|
676
|
-
|
|
677
|
-
return
|
|
807
|
+
// The planner pins the content CAS from the live row (the block is live: it resolved).
|
|
808
|
+
return sugar(repoId, root, splitEffects(block, args.at));
|
|
678
809
|
}
|
|
679
810
|
catch (e) {
|
|
680
811
|
return fail(e);
|
|
681
812
|
}
|
|
682
813
|
});
|
|
683
814
|
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(),
|
|
815
|
+
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`.",
|
|
816
|
+
inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
|
|
686
817
|
}, async (args) => {
|
|
687
818
|
try {
|
|
688
819
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
689
820
|
const root = requireRoot(rootPath);
|
|
821
|
+
requireSome("blocks_merge", "blocks", args.blocks);
|
|
690
822
|
const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
|
|
691
|
-
|
|
692
|
-
return applyOps(repoId, root, ops, "blocks_merge", args.dry_run);
|
|
823
|
+
return sugar(repoId, root, mergeEffects(blocks, args.separator));
|
|
693
824
|
}
|
|
694
825
|
catch (e) {
|
|
695
826
|
return fail(e);
|
|
696
827
|
}
|
|
697
828
|
});
|
|
698
829
|
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(),
|
|
830
|
+
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).",
|
|
831
|
+
inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
|
|
701
832
|
}, async (args) => {
|
|
702
833
|
try {
|
|
703
834
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
704
835
|
const root = requireRoot(rootPath);
|
|
836
|
+
requireSome("blocks_check", "ids", args.ids);
|
|
705
837
|
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);
|
|
838
|
+
// N × `set attrs.checked`, each CAS-pinned by the planner; a block named
|
|
839
|
+
// twice is one write (the planner drops the identical duplicate, 3.2).
|
|
840
|
+
return sugar(repoId, root, checkEffects(blocks, args.checked ?? true));
|
|
711
841
|
}
|
|
712
842
|
catch (e) {
|
|
713
843
|
return fail(e);
|
|
714
844
|
}
|
|
715
845
|
});
|
|
716
846
|
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(),
|
|
847
|
+
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).",
|
|
848
|
+
inputSchema: { id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
|
|
719
849
|
}, async (args) => {
|
|
720
850
|
try {
|
|
721
851
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -723,85 +853,84 @@ export function buildServer(ctx) {
|
|
|
723
853
|
const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.id, repoId);
|
|
724
854
|
if (!known)
|
|
725
855
|
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);
|
|
856
|
+
return sugar(repoId, root, nodeSetEffects(args.id, args.prop, args.value));
|
|
728
857
|
}
|
|
729
858
|
catch (e) {
|
|
730
859
|
return fail(e);
|
|
731
860
|
}
|
|
732
861
|
});
|
|
733
862
|
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(),
|
|
863
|
+
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.",
|
|
864
|
+
inputSchema: { id: z.string(), text: z.string(), ...REPO_ARG },
|
|
736
865
|
}, async (args) => {
|
|
737
866
|
try {
|
|
738
867
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
739
868
|
const root = requireRoot(rootPath);
|
|
740
869
|
const block = resolveBlockRef(repoId, args.id);
|
|
741
870
|
const row = store.db
|
|
742
|
-
.prepare("SELECT b.type AS type, d.format AS format, (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")
|
|
871
|
+
.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")
|
|
743
872
|
.get(block);
|
|
744
873
|
if (!row)
|
|
745
874
|
throw blockMissing(args.id);
|
|
746
|
-
let
|
|
875
|
+
let into;
|
|
747
876
|
if (row.type === "heading") {
|
|
748
|
-
|
|
877
|
+
into = { kind: "section", heading_id: block };
|
|
749
878
|
}
|
|
750
879
|
else if (isContainerBlock(row.format ?? "markdown", { type: row.type, children: new Array(row.children) })) {
|
|
751
|
-
|
|
880
|
+
into = blockTarget(block);
|
|
752
881
|
}
|
|
753
882
|
else {
|
|
883
|
+
// The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
|
|
754
884
|
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
|
-
data: { id: block, type: row.type },
|
|
885
|
+
data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
|
|
756
886
|
});
|
|
757
887
|
}
|
|
758
|
-
return
|
|
888
|
+
return sugar(repoId, root, appendEffects(into, args.text));
|
|
759
889
|
}
|
|
760
890
|
catch (e) {
|
|
761
891
|
return fail(e);
|
|
762
892
|
}
|
|
763
893
|
});
|
|
764
894
|
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(),
|
|
895
|
+
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.",
|
|
896
|
+
inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), ...REPO_ARG },
|
|
767
897
|
}, async (args) => {
|
|
768
898
|
try {
|
|
769
899
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
770
900
|
const root = requireRoot(rootPath);
|
|
771
901
|
// Resolve the locator to a live doc id FIRST — a missing doc is doc_missing
|
|
772
902
|
// (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).
|
|
903
|
+
// insert-at-end effect (document top level, or the named section) and
|
|
904
|
+
// apply it (existing blocks keep their ids; only the appended blocks are minted).
|
|
775
905
|
const docId = locateDoc(repoId, "docs_append", registry.get("docs_append").shape, args);
|
|
776
|
-
let
|
|
906
|
+
let into;
|
|
777
907
|
if (args.section !== undefined) {
|
|
778
908
|
const info = findDoc(store, { docId });
|
|
779
|
-
|
|
909
|
+
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 }) };
|
|
780
910
|
}
|
|
781
911
|
else {
|
|
782
|
-
|
|
912
|
+
into = { kind: "document", id: docId };
|
|
783
913
|
}
|
|
784
|
-
return
|
|
914
|
+
return sugar(repoId, root, docsAppendEffects(into, args.text));
|
|
785
915
|
}
|
|
786
916
|
catch (e) {
|
|
787
917
|
return fail(e);
|
|
788
918
|
}
|
|
789
919
|
});
|
|
790
920
|
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(),
|
|
921
|
+
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`).",
|
|
922
|
+
inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), ...REPO_ARG },
|
|
793
923
|
}, async (args) => {
|
|
794
924
|
try {
|
|
795
925
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
796
926
|
if (!rootPath)
|
|
797
927
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
798
|
-
|
|
928
|
+
// `hits`/`pairs` come from the macro's plan; the write is the `retarget`
|
|
929
|
+
// effect, whose lowering runs the same macro (same ops, same CAS pins).
|
|
930
|
+
const { hits: rawHits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
|
|
799
931
|
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 });
|
|
932
|
+
const { receipt } = runSugar(repoId, rootPath, retargetEffects([{ from: args.from_target, to: args.to_target }], args.path_glob, "links_retarget"));
|
|
933
|
+
return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
|
|
805
934
|
}
|
|
806
935
|
catch (e) {
|
|
807
936
|
return fail(e);
|
|
@@ -825,13 +954,12 @@ export function buildServer(ctx) {
|
|
|
825
954
|
}
|
|
826
955
|
});
|
|
827
956
|
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.
|
|
957
|
+
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
958
|
inputSchema: {
|
|
830
959
|
repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
|
|
831
960
|
from_target: z.string().optional(),
|
|
832
961
|
to_target: z.string().optional(),
|
|
833
962
|
path_glob: z.string().optional(),
|
|
834
|
-
dry_run: z.boolean().optional(),
|
|
835
963
|
...REPO_ARG,
|
|
836
964
|
},
|
|
837
965
|
}, async (args) => {
|
|
@@ -847,97 +975,116 @@ export function buildServer(ctx) {
|
|
|
847
975
|
if (!repairs || repairs.length === 0) {
|
|
848
976
|
throw new EngineError("target_missing", "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair");
|
|
849
977
|
}
|
|
850
|
-
const {
|
|
978
|
+
const { hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
|
|
851
979
|
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 });
|
|
980
|
+
const { receipt } = runSugar(repoId, rootPath, retargetEffects(repairs, args.path_glob, "links_repair"));
|
|
981
|
+
return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
|
|
859
982
|
}
|
|
860
983
|
catch (e) {
|
|
861
984
|
return fail(e);
|
|
862
985
|
}
|
|
863
986
|
});
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
// (`
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
987
|
+
// ---- the document operations as effects (spec/surface §4 "The document operations", 3.3; 4.0) ----
|
|
988
|
+
// `docs_create` / `docs_delete` / `docs_move` / `docs_set_meta` are one document
|
|
989
|
+
// effect each — `create`, `remove { target: document }`, `move { target:
|
|
990
|
+
// document, to, retarget_inbound }`, one `set`/`unset frontmatter.<k>` per key —
|
|
991
|
+
// built by the shared builders (`mutate/sugar.ts`), planned and executed
|
|
992
|
+
// through the same seam as the block-level sugar under the `spec/mutate` §6
|
|
993
|
+
// reasons (`create <rel>`, `delete <path>`, `move <from> -> <to>`, `set_meta
|
|
994
|
+
// <path>`), each returning the Receipt (4.0). Each tool keeps its own
|
|
995
|
+
// pre-checks — `locateDoc`'s `doc_missing`, the create/move `path_taken` pair
|
|
996
|
+
// (no `data`, as pinned) — so the planner's own copies of them never fire and
|
|
997
|
+
// the error envelopes are unchanged. The workspace dir rides along (LOCK_DIR):
|
|
998
|
+
// the tool-boundary lock already holds the writer lock for the call, so the
|
|
999
|
+
// request's own lock re-enters it. MCP-originated writes are actor agent:mcp.
|
|
1000
|
+
/** The storage-form path of a document `locateDoc` just found. */
|
|
1001
|
+
const pathOfDoc = (docId) => findDoc(store, { docId }).path;
|
|
1002
|
+
/** `path_taken` exactly as the legacy document operations raised it: their message, no `data` (§9). */
|
|
1003
|
+
const requireAbsent = (repoId, rootPath, rel, takenMessage) => requireAbsentPath(store, repoId, { rootPath }, rel, takenMessage);
|
|
875
1004
|
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(),
|
|
1005
|
+
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`).",
|
|
1006
|
+
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
|
|
878
1007
|
}, async (args) => {
|
|
879
1008
|
try {
|
|
880
|
-
|
|
1009
|
+
const scope = repoScope(args.repo);
|
|
1010
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
1011
|
+
const rel = canonicalDocPath(args.path);
|
|
1012
|
+
requireAbsent(scope.repoId, rootPath, rel, `document already exists at ${rel}`);
|
|
1013
|
+
return sugar(scope.repoId, rootPath, createEffects(rel, args.markdown, args.frontmatter));
|
|
881
1014
|
}
|
|
882
1015
|
catch (e) {
|
|
883
1016
|
return fail(e);
|
|
884
1017
|
}
|
|
885
1018
|
});
|
|
886
1019
|
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(),
|
|
1020
|
+
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.",
|
|
1021
|
+
inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
|
|
889
1022
|
}, async (args) => {
|
|
890
1023
|
try {
|
|
891
1024
|
const scope = repoScope(args.repo);
|
|
892
1025
|
const docId = locateDoc(scope.repoId, "docs_move", registry.get("docs_move").shape, args);
|
|
893
|
-
|
|
1026
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
1027
|
+
const doc = { id: docId, path: pathOfDoc(docId) };
|
|
1028
|
+
const to = canonicalDocPath(args.to_path);
|
|
1029
|
+
requireAbsent(scope.repoId, rootPath, to, `a document already exists at ${to}`);
|
|
1030
|
+
const retargetInbound = args.retarget_inbound !== false;
|
|
1031
|
+
// Who links here BY PATH, as written — read before the path changes — and
|
|
1032
|
+
// the rewrite the move carries (`moveLinks` is pure over the current
|
|
1033
|
+
// blocks, so computed here, before the commit, it equals what the planner
|
|
1034
|
+
// lowered, `spec/mutate` §11.3) → `dangling`/`retargeted`.
|
|
1035
|
+
const links = moveLinks(store, scope.repoId, doc, to, retargetInbound);
|
|
1036
|
+
const { receipt } = runSugar(scope.repoId, rootPath, moveDocEffects(doc, to, retargetInbound));
|
|
1037
|
+
return okMutated({ ...surfaceReceipt(receipt), retargeted: links.retargeted, dangling: links.dangling.map(surfaceInboundLink) });
|
|
894
1038
|
}
|
|
895
1039
|
catch (e) {
|
|
896
1040
|
return fail(e);
|
|
897
1041
|
}
|
|
898
1042
|
});
|
|
899
1043
|
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,
|
|
1044
|
+
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 → \"\"`).",
|
|
1045
|
+
inputSchema: { ...DOC_ARGS, ...REPO_ARG },
|
|
902
1046
|
}, async (args) => {
|
|
903
1047
|
try {
|
|
904
1048
|
const scope = repoScope(args.repo);
|
|
905
1049
|
const docId = locateDoc(scope.repoId, "docs_delete", registry.get("docs_delete").shape, args);
|
|
906
|
-
|
|
1050
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
1051
|
+
return sugar(scope.repoId, rootPath, deleteDocEffects({ id: docId, path: pathOfDoc(docId) }));
|
|
907
1052
|
}
|
|
908
1053
|
catch (e) {
|
|
909
1054
|
return fail(e);
|
|
910
1055
|
}
|
|
911
1056
|
});
|
|
912
1057
|
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(),
|
|
1058
|
+
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).",
|
|
1059
|
+
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
|
|
915
1060
|
}, async (args) => {
|
|
916
1061
|
try {
|
|
917
1062
|
const scope = repoScope(args.repo);
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1063
|
+
const shape = registry.get("docs_set_meta").shape;
|
|
1064
|
+
const docId = locateDoc(scope.repoId, "docs_set_meta", shape, args);
|
|
1065
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
1066
|
+
// One scalar effect per key, `unset` winning over `set` for a shared key;
|
|
1067
|
+
// the planner drops a repeated `unset`. An empty patch is an argument
|
|
1068
|
+
// error (3.3, §9) — it used to rewrite the frontmatter through the emitter for nothing.
|
|
1069
|
+
const built = setMetaEffects({ id: docId, path: pathOfDoc(docId) }, args.set, args.unset);
|
|
1070
|
+
if (built.effects.length === 0)
|
|
1071
|
+
throw argumentError("docs_set_meta", "pass `set` and/or `unset`", shape);
|
|
1072
|
+
return sugar(scope.repoId, rootPath, built);
|
|
923
1073
|
}
|
|
924
1074
|
catch (e) {
|
|
925
1075
|
return fail(e);
|
|
926
1076
|
}
|
|
927
1077
|
});
|
|
928
1078
|
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(),
|
|
1079
|
+
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`).",
|
|
1080
|
+
inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), ...REPO_ARG },
|
|
931
1081
|
}, async (args) => {
|
|
932
1082
|
try {
|
|
933
1083
|
const scope = repoScope(args.repo);
|
|
934
1084
|
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);
|
|
1085
|
+
const rootPath = requireRoot(scope.rootPath);
|
|
1086
|
+
const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ id: docId }, args.content), args.reason);
|
|
1087
|
+
return okMutated(surfaceReceipt(receipt));
|
|
941
1088
|
}
|
|
942
1089
|
catch (e) {
|
|
943
1090
|
return fail(e);
|
|
@@ -1078,7 +1225,7 @@ export function buildServer(ctx) {
|
|
|
1078
1225
|
}
|
|
1079
1226
|
});
|
|
1080
1227
|
tool("changes_since", {
|
|
1081
|
-
description: "
|
|
1228
|
+
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)."),
|
|
1082
1229
|
inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
1083
1230
|
}, async (args) => {
|
|
1084
1231
|
try {
|
|
@@ -1140,12 +1287,11 @@ export function buildServer(ctx) {
|
|
|
1140
1287
|
// Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
|
|
1141
1288
|
// answer is about the binary and its database, not a repo.
|
|
1142
1289
|
tool("version", {
|
|
1143
|
-
description: "Which engine and which versions you are talking to
|
|
1144
|
-
"`version` is the serving binary's own; `components` every omgbase package it is built from; `specs` the spec versions it implements (compile-time constants); `schema` the database's `PRAGMA user_version`; `commit`/`built` the build's git revision and RFC 3339 time (null when unknown). No arguments, no repo scope.",
|
|
1290
|
+
description: ladder("version — Which engine and which versions you are talking to, and which tools profile it serves.", "{} engine, version, components, specs, schema, mcp, runtime, commit, built and profile (\"full\" or \"minimal\")", "Under the minimal profile ten tools are listed; calling an unlisted one is the JSON-RPC error unknown tool. Reads go through query (grammar: query_syntax)."),
|
|
1145
1291
|
inputSchema: {},
|
|
1146
1292
|
}, async () => {
|
|
1147
1293
|
try {
|
|
1148
|
-
return ok(versionInfo(store, ctx.host));
|
|
1294
|
+
return ok(versionInfo(store, ctx.host, profile));
|
|
1149
1295
|
}
|
|
1150
1296
|
catch (e) {
|
|
1151
1297
|
return fail(e);
|
|
@@ -1159,8 +1305,14 @@ export function buildServer(ctx) {
|
|
|
1159
1305
|
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1160
1306
|
const name = request.params.name;
|
|
1161
1307
|
const entry = registry.get(name);
|
|
1308
|
+
// An unregistered name — unknown, or hidden by the profile — is the JSON-RPC
|
|
1309
|
+
// `-32602` error with exactly the text both engines pin (spec/surface §4
|
|
1310
|
+
// "Profiles"): `unknown tool <name>`. The SDK copies a thrown error's
|
|
1311
|
+
// integer `code` and its `message` onto the wire verbatim; `McpError`
|
|
1312
|
+
// would prefix the message ("MCP error -32602: …"), so a plain Error
|
|
1313
|
+
// carries the code instead.
|
|
1162
1314
|
if (!entry)
|
|
1163
|
-
throw
|
|
1315
|
+
throw unknownTool(name);
|
|
1164
1316
|
let args;
|
|
1165
1317
|
try {
|
|
1166
1318
|
args = validateArguments(name, entry.shape, request.params.arguments);
|
|
@@ -1172,8 +1324,14 @@ export function buildServer(ctx) {
|
|
|
1172
1324
|
});
|
|
1173
1325
|
return server;
|
|
1174
1326
|
}
|
|
1327
|
+
/** The pinned text of a `tools/call` on a name the server does not serve (spec/surface §4 "Profiles"). */
|
|
1328
|
+
export const UNKNOWN_TOOL_CODE = ErrorCode.InvalidParams; // -32602
|
|
1329
|
+
export function unknownTool(name) {
|
|
1330
|
+
return Object.assign(new Error(`unknown tool ${name}`), { code: UNKNOWN_TOOL_CODE });
|
|
1331
|
+
}
|
|
1175
1332
|
/** The catalog's tool names in registration order with their descriptions —
|
|
1176
|
-
* what `spec/surface/tool-descriptions.json` pins for both engines
|
|
1333
|
+
* what `spec/surface/tool-descriptions.json` pins for both engines (over a
|
|
1334
|
+
* `full` server; a profiled server lists its subset in the same order). */
|
|
1177
1335
|
export async function toolDescriptions(server) {
|
|
1178
1336
|
const tools = server._registeredTools;
|
|
1179
1337
|
const out = {};
|
|
@@ -1181,4 +1339,8 @@ export async function toolDescriptions(server) {
|
|
|
1181
1339
|
out[name] = t.description ?? "";
|
|
1182
1340
|
return out;
|
|
1183
1341
|
}
|
|
1342
|
+
/** The tool names a server lists, in registration order (`spec/surface/cases/profiles.json` pins them per profile). */
|
|
1343
|
+
export async function toolNames(server) {
|
|
1344
|
+
return Object.keys(await toolDescriptions(server));
|
|
1345
|
+
}
|
|
1184
1346
|
//# sourceMappingURL=server.js.map
|