@omgbase/core 0.10.1 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/dist/src/core/read/document.d.ts +18 -5
- package/dist/src/core/read/document.js +21 -7
- package/dist/src/core/read/nodes.d.ts +7 -7
- package/dist/src/core/read/nodes.js +5 -3
- package/dist/src/core/read/reader.d.ts +11 -7
- package/dist/src/core/read/reader.js +8 -10
- package/dist/src/format/adapter.d.ts +1 -1
- package/dist/src/graph/history.d.ts +20 -14
- package/dist/src/graph/history.js +12 -12
- package/dist/src/graph/inbound-links.d.ts +4 -4
- package/dist/src/graph/inbound-links.js +4 -4
- package/dist/src/graph/link-health.d.ts +8 -8
- package/dist/src/graph/link-health.js +6 -6
- package/dist/src/graph/links.d.ts +3 -3
- package/dist/src/graph/links.js +4 -4
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/mcp/arguments.d.ts +27 -0
- package/dist/src/mcp/arguments.js +147 -0
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/graph.d.ts +16 -9
- package/dist/src/mcp/graph.js +58 -35
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +200 -111
- package/dist/src/mcp/server.d.ts +9 -1
- package/dist/src/mcp/server.js +359 -268
- package/dist/src/mutate/apply.d.ts +11 -4
- package/dist/src/mutate/apply.js +3 -2
- package/dist/src/mutate/docs.js +7 -7
- package/dist/src/mutate/macros.d.ts +4 -1
- package/dist/src/mutate/macros.js +1 -1
- package/dist/src/mutate/ops.js +2 -2
- package/dist/src/mutate/opset.d.ts +8 -5
- package/dist/src/mutate/plan-update.js +9 -8
- package/dist/src/oqx-js/context.d.ts +17 -4
- package/dist/src/oqx-js/context.js +268 -131
- package/dist/src/oqx-js/planner.d.ts +3 -3
- package/dist/src/oqx-js/planner.js +14 -15
- package/dist/src/oqx-js/root-row.d.ts +10 -3
- package/dist/src/oqx-js/root-row.js +18 -8
- package/dist/src/oqx-js/run.d.ts +6 -0
- package/dist/src/oqx-js/run.js +72 -25
- package/dist/src/oqx-js/sql/scan.js +7 -7
- package/dist/src/oqx-js/sql/translate.d.ts +12 -9
- package/dist/src/oqx-js/sql/translate.js +183 -75
- package/dist/src/oqx-js/store-index.d.ts +1 -1
- package/dist/src/oqx-js/store-index.js +28 -26
- package/dist/src/search/cel/ast.d.ts +1 -1
- package/dist/src/search/cel/compile.d.ts +1 -1
- package/dist/src/search/cel/compile.js +43 -70
- package/dist/src/search/cel/parser.js +1 -1
- package/dist/src/search/resolve.d.ts +2 -0
- package/dist/src/search/rrf.js +2 -2
- package/dist/src/search/text.d.ts +2 -2
- package/dist/src/search/text.js +1 -1
- package/dist/src/spec-versions.d.ts +6 -6
- package/dist/src/spec-versions.js +5 -5
- package/dist/src/surface-paths.d.ts +41 -8
- package/dist/src/surface-paths.js +34 -18
- package/dist/src/sync/admin.d.ts +2 -1
- package/dist/src/sync/admin.js +1 -1
- package/package.json +2 -2
package/dist/src/mcp/server.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { docsOutline } from "../core/read/outline.js";
|
|
4
5
|
import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
|
|
5
6
|
import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
|
|
6
|
-
import { findDoc,
|
|
7
|
+
import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
|
|
7
8
|
import { resolveRef } from "../core/read/refs.js";
|
|
8
|
-
import { isValidId } from "../core/ids.js";
|
|
9
9
|
import { VERSION } from "../core/index.js";
|
|
10
10
|
import { normalizeText, normalizeVisibleText } from "../core/hash.js";
|
|
11
11
|
import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
|
|
12
12
|
import { graphNeighborhood } from "./graph.js";
|
|
13
13
|
import { textSearch } from "../search/text.js";
|
|
14
14
|
import { EngineError, errorBody } from "./errors.js";
|
|
15
|
+
import { argumentError, validateArguments } from "./arguments.js";
|
|
15
16
|
import { apply } from "../mutate/apply.js";
|
|
16
17
|
import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair, nodeSet } from "../mutate/macros.js";
|
|
17
18
|
import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
|
|
18
|
-
import {
|
|
19
|
+
import { docsUpdate } from "../mutate/plan-update.js";
|
|
19
20
|
import { renderOpsetPlan } from "../mutate/opset.js";
|
|
20
21
|
import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
|
|
21
22
|
import { linksStale, linksStaleSummary } from "../graph/link-health.js";
|
|
@@ -24,13 +25,16 @@ import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
|
|
|
24
25
|
import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
|
|
25
26
|
import { QUERY_SYNTAX } from "./reference.js";
|
|
26
27
|
import { versionInfo } from "../version.js";
|
|
27
|
-
import { storagePath } from "../core/paths.js";
|
|
28
|
-
import {
|
|
28
|
+
import { storagePath, referencePath } from "../core/paths.js";
|
|
29
|
+
import { isContainerBlock } from "../format/index.js";
|
|
30
|
+
import { surfaceApplyResult, surfaceDocOpResult, surfaceDocMoveResult, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfaceDocsUpdate, } from "../surface-paths.js";
|
|
29
31
|
// Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
|
|
30
32
|
// each op a real schema (rather than z.any()) makes the required fields
|
|
31
33
|
// discoverable in the tool's JSON schema and turns a malformed op — e.g. an
|
|
32
34
|
// `update` missing its `block` id — into a boundary validation error naming the
|
|
33
|
-
// field, instead of a downstream `block_missing` for "block undefined".
|
|
35
|
+
// field, instead of a downstream `block_missing` for "block undefined". The
|
|
36
|
+
// grammar (`block`, `blocks`, `to`, `at`, `expect.content_hash`, `op`) is
|
|
37
|
+
// spec/mutate's and is not renamed by the surface's argument rule.
|
|
34
38
|
const atSchema = z.union([
|
|
35
39
|
z.literal("start"),
|
|
36
40
|
z.literal("end"),
|
|
@@ -74,9 +78,21 @@ function fail(err) {
|
|
|
74
78
|
// same body, so a code can never differ between `omg` and `omg mcp`.
|
|
75
79
|
return { content: [{ type: "text", text: JSON.stringify(errorBody(err)) }], isError: true };
|
|
76
80
|
}
|
|
81
|
+
/** The message every tool uses for a `semantic(...)` call the server cannot rank. */
|
|
82
|
+
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)";
|
|
77
83
|
export function buildServer(ctx) {
|
|
78
84
|
const server = new McpServer({ name: "omgbase", version: VERSION });
|
|
79
85
|
const { store } = ctx;
|
|
86
|
+
// ---- registration: listing through the SDK, validation + dispatch ours ----
|
|
87
|
+
// `server.registerTool` publishes the name, description and JSON schema on
|
|
88
|
+
// `tools/list`; the `tools/call` handler is replaced below so the arguments
|
|
89
|
+
// are validated by `validateArguments` (spec/surface §4 "Arguments") and a
|
|
90
|
+
// failure is the engine's own `filter_invalid`, not the SDK's text.
|
|
91
|
+
const registry = new Map();
|
|
92
|
+
function tool(name, def, handler) {
|
|
93
|
+
server.registerTool(name, def, handler);
|
|
94
|
+
registry.set(name, { shape: def.inputSchema, handler: handler });
|
|
95
|
+
}
|
|
80
96
|
// Multi-repo (ADR-014): every tool accepts an optional `repo` slug. Resolve it
|
|
81
97
|
// to a repo id + its (derived) working-tree root per call; omitting it uses the
|
|
82
98
|
// server's bound default repo, so single-repo clients are unchanged. rootPath
|
|
@@ -106,6 +122,9 @@ export function buildServer(ctx) {
|
|
|
106
122
|
// `const { repoId, rootPath } = repoScope(args.repo);` — omitting `repo` uses
|
|
107
123
|
// the bound default repo (single-repo clients unchanged).
|
|
108
124
|
const REPO_ARG = { repo: z.string().optional() };
|
|
125
|
+
// The document locator every `docs_*` tool takes (§4 "Arguments"): the row's
|
|
126
|
+
// own attributes, at least one of them.
|
|
127
|
+
const DOC_ARGS = { id: z.string().optional(), path: z.string().optional() };
|
|
109
128
|
// Wrap a successful write's result: notify the host so it can schedule a
|
|
110
129
|
// background embed drain. onMutation must be cheap/non-blocking (see
|
|
111
130
|
// ServerContext); we never await it, keeping it off the response path.
|
|
@@ -113,64 +132,75 @@ export function buildServer(ctx) {
|
|
|
113
132
|
ctx.onMutation?.();
|
|
114
133
|
return ok(payload);
|
|
115
134
|
}
|
|
116
|
-
//
|
|
117
|
-
// is
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// `
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (row)
|
|
143
|
-
return row.doc_id;
|
|
144
|
-
}
|
|
145
|
-
throw new EngineError("doc_missing", `no document for ${JSON.stringify(ref)}`, { data: ref });
|
|
135
|
+
// ---- the document locator (§4 "Arguments") ---------------------------------
|
|
136
|
+
// `id` is the document's id, `path` its path (either form, §1 "Paths"); at
|
|
137
|
+
// least one is required, and when both are given they must name the same
|
|
138
|
+
// document. A miss is `doc_missing` "no document with id or path "<value>""
|
|
139
|
+
// with the arguments as given in `data`;
|
|
140
|
+
// neither given is `filter_invalid` "pass id or path"; a disagreement is
|
|
141
|
+
// `filter_invalid` "id "…" and path "…" name different documents". `findDoc`
|
|
142
|
+
// never falls through from an id to a path.
|
|
143
|
+
function locateDoc(repoId, toolName, shape, args) {
|
|
144
|
+
const [idKey, pathKey] = ["id", "path"];
|
|
145
|
+
const id = args[idKey];
|
|
146
|
+
const path = args[pathKey];
|
|
147
|
+
if (id === undefined && path === undefined)
|
|
148
|
+
throw argumentError(toolName, `pass ${idKey} or ${pathKey}`, shape);
|
|
149
|
+
const given = { ...(id !== undefined ? { [idKey]: id } : {}), ...(path !== undefined ? { [pathKey]: path } : {}) };
|
|
150
|
+
const missing = (value) => new EngineError("doc_missing", `no document with ${idKey} or ${pathKey} ${JSON.stringify(value)}`, { data: given });
|
|
151
|
+
const byId = id !== undefined ? findDoc(store, { docId: id }) : null;
|
|
152
|
+
if (id !== undefined && (!byId || byId.repoId !== repoId))
|
|
153
|
+
throw missing(id);
|
|
154
|
+
const byPath = path !== undefined ? findDoc(store, { repoId, path }) : null;
|
|
155
|
+
if (path !== undefined && !byPath)
|
|
156
|
+
throw missing(path);
|
|
157
|
+
if (byId && byPath && byId.docId !== byPath.docId) {
|
|
158
|
+
throw argumentError(toolName, `${idKey} ${JSON.stringify(id)} and ${pathKey} ${JSON.stringify(path)} name different documents`, shape);
|
|
159
|
+
}
|
|
160
|
+
return (byId ?? byPath).docId;
|
|
146
161
|
}
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
// The owning document of a block id (`blocks_read`): the block must be live.
|
|
163
|
+
function docOfBlock(blockId) {
|
|
164
|
+
const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
|
|
165
|
+
if (!row)
|
|
166
|
+
throw blockMissing(blockId);
|
|
167
|
+
return row.doc_id;
|
|
168
|
+
}
|
|
169
|
+
function blockMissing(value, hint) {
|
|
170
|
+
return new EngineError("block_missing", `no block with id ${JSON.stringify(value)}${hint ? ` (${hint})` : ""}`, { data: { id: value } });
|
|
171
|
+
}
|
|
172
|
+
// `nodes_block_read`: the block a node was projected from — the node must
|
|
173
|
+
// exist and name a live block (a block-less node, e.g. one derived from the
|
|
174
|
+
// frontmatter, has none). Both misses are `block_missing`, the message naming
|
|
175
|
+
// the node.
|
|
176
|
+
function blockOfNode(nodeId) {
|
|
177
|
+
const node = store.db.prepare("SELECT doc_id, block_id FROM nodes WHERE node_id = ?").get(nodeId);
|
|
178
|
+
if (!node)
|
|
179
|
+
throw new EngineError("block_missing", `no node with id ${JSON.stringify(nodeId)}`, { data: { id: nodeId } });
|
|
180
|
+
const live = node.block_id ? store.db.prepare("SELECT 1 FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(node.block_id) : undefined;
|
|
181
|
+
if (!node.block_id || !live)
|
|
182
|
+
throw new EngineError("block_missing", `node ${JSON.stringify(nodeId)} has no block`, { data: { id: nodeId, doc_id: node.doc_id } });
|
|
183
|
+
return { docId: node.doc_id, blockId: node.block_id };
|
|
184
|
+
}
|
|
185
|
+
// `docs_append`'s `section`: a heading's TEXT, matched against the normalized
|
|
186
|
+
// heading text of the named document's live headings (an ATX-looking value,
|
|
187
|
+
// "## Title", loses its hashes). Zero → `parent_missing` naming the heading;
|
|
188
|
+
// several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
|
|
189
|
+
// the heading's id to `blocks_append` instead). Never an id lookup — the
|
|
190
|
+
// id form is `blocks_append`'s (§9).
|
|
191
|
+
function resolveSectionHeading(docId, docPath, section) {
|
|
192
|
+
const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
|
|
193
|
+
const rows = store.db
|
|
194
|
+
.prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
|
|
195
|
+
.all(docId);
|
|
168
196
|
const matches = rows.filter((r) => normalizeText(r.text) === needle);
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
const shown = referencePath(docPath);
|
|
198
|
+
if (matches.length === 0) {
|
|
199
|
+
throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown } });
|
|
200
|
+
}
|
|
171
201
|
if (matches.length > 1) {
|
|
172
|
-
throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(
|
|
173
|
-
data: {
|
|
202
|
+
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 })) },
|
|
174
204
|
});
|
|
175
205
|
}
|
|
176
206
|
return matches[0].block_id;
|
|
@@ -183,7 +213,7 @@ export function buildServer(ctx) {
|
|
|
183
213
|
function resolveBlockRef(repoId, ref) {
|
|
184
214
|
const r = resolveRef(store, repoId, ref);
|
|
185
215
|
if (!r || r.kind !== "block" || !r.blockId)
|
|
186
|
-
throw
|
|
216
|
+
throw blockMissing(ref);
|
|
187
217
|
return r.blockId;
|
|
188
218
|
}
|
|
189
219
|
// Resolve a PARENT ref for insert/move: a block ref names that block as the
|
|
@@ -194,7 +224,7 @@ export function buildServer(ctx) {
|
|
|
194
224
|
function resolveParentRef(repoId, ref) {
|
|
195
225
|
const r = resolveRef(store, repoId, ref);
|
|
196
226
|
if (!r)
|
|
197
|
-
throw new EngineError("block_missing", `
|
|
227
|
+
throw new EngineError("block_missing", `no block or document with id or path ${JSON.stringify(ref)}`, { data: { ref } });
|
|
198
228
|
if (r.kind === "document")
|
|
199
229
|
return { parent: { doc: true }, docId: r.docId };
|
|
200
230
|
return { parent: r.blockId, docId: r.docId };
|
|
@@ -230,11 +260,41 @@ export function buildServer(ctx) {
|
|
|
230
260
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
231
261
|
return rootPath;
|
|
232
262
|
}
|
|
233
|
-
|
|
234
|
-
|
|
263
|
+
// ---- semantic(): loud when it cannot rank (§4, 3.0) ---------------------------
|
|
264
|
+
// No provider → `provider_unavailable` before anything runs (`data.provider`
|
|
265
|
+
// null). A provider that answers but whose model has embedded NOTHING in this
|
|
266
|
+
// repository would score every row null and hand back path-ordered rows with
|
|
267
|
+
// no score — so the first phrase embedded checks the index and fails the same
|
|
268
|
+
// code, naming the provider and model and what to run.
|
|
269
|
+
function hasEmbeddings(repoId, model) {
|
|
270
|
+
const row = store.db
|
|
271
|
+
.prepare(`SELECT 1 WHERE EXISTS (SELECT 1 FROM doc_embeddings de JOIN docs d ON d.doc_id = de.doc_id WHERE d.repo_id = ? AND de.model = ?)
|
|
272
|
+
OR EXISTS (SELECT 1 FROM embeddings WHERE model = ?)`)
|
|
273
|
+
.get(repoId, model, model);
|
|
274
|
+
return row !== undefined;
|
|
275
|
+
}
|
|
276
|
+
function providerUnavailable() {
|
|
277
|
+
return new EngineError("provider_unavailable", PROVIDER_UNAVAILABLE_MESSAGE, { data: { provider: null } });
|
|
278
|
+
}
|
|
279
|
+
function rankableEmbed(repoId) {
|
|
280
|
+
const embed = ctx.embedQuery;
|
|
281
|
+
if (!embed)
|
|
282
|
+
return undefined;
|
|
283
|
+
return async (text) => {
|
|
284
|
+
const r = await embed(text);
|
|
285
|
+
if (!hasEmbeddings(repoId, r.model)) {
|
|
286
|
+
const who = ctx.embedProvider ? `provider ${JSON.stringify(ctx.embedProvider)} (model ${r.model})` : `model ${r.model}`;
|
|
287
|
+
throw new EngineError("provider_unavailable", `semantic(...) cannot rank: ${who} is configured but nothing in this repository is embedded under it — run omg embed drain`, {
|
|
288
|
+
data: { provider: ctx.embedProvider ?? null, model: r.model },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return r;
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
tool("docs_outline", {
|
|
295
|
+
description: "Orientation call. Returns a document's compact indented outline (alias/type/label per line, § marks section headings). IDs are stable; prefer them in follow-ups. Address the document by `id` or `path` (at least one; a `path` in either form). For the whole document body in one call, use docs_read.",
|
|
235
296
|
inputSchema: {
|
|
236
|
-
|
|
237
|
-
path: z.string().optional(),
|
|
297
|
+
...DOC_ARGS,
|
|
238
298
|
resolution: z.enum(["skeleton", "outline"]).optional(),
|
|
239
299
|
depth: z.number().int().optional(),
|
|
240
300
|
budget_tokens: z.number().int().optional(),
|
|
@@ -243,7 +303,7 @@ export function buildServer(ctx) {
|
|
|
243
303
|
}, async (args) => {
|
|
244
304
|
try {
|
|
245
305
|
const { repoId } = repoScope(args.repo);
|
|
246
|
-
const docId =
|
|
306
|
+
const docId = locateDoc(repoId, "docs_outline", registry.get("docs_outline").shape, args);
|
|
247
307
|
const res = docsOutline(store, docId, {
|
|
248
308
|
...(args.resolution ? { resolution: args.resolution } : {}),
|
|
249
309
|
...(args.depth !== undefined ? { depth: args.depth } : {}),
|
|
@@ -255,29 +315,28 @@ export function buildServer(ctx) {
|
|
|
255
315
|
return fail(e);
|
|
256
316
|
}
|
|
257
317
|
});
|
|
258
|
-
|
|
259
|
-
description: "Read a whole document in one call: `
|
|
318
|
+
tool("docs_read", {
|
|
319
|
+
description: "Read a whole document in one call: `raw` is the complete file bytes verbatim (fences/tables/list markers preserved), `properties` is the document's property bag grouped by source — `{ frontmatter, inline, computed }` — plus `id`/`path`/`rev` (the document's row attributes, spelled as the query language spells them). What `properties.frontmatter` holds is format-dependent: for markdown it's the parsed frontmatter fence; for YAML/JSON it's the parsed object the file represents; other adapters extract per their format. `inline` holds dataview-style `key:: value` fields from the body; `computed` holds engine intrinsics (`title`, `tags`). The cold-start 'read the guide before doing anything' call. Address the document by `id` or `path` (at least one; a query hit's `id`/`path` are the values to pass). Pass include_ids:true to also get the document's block ids in order (`ids`) AND `hashes` — a {block `id` → block `hash`} map giving the exact `expect.content_hash` value the raw `apply` kernel needs, so one read yields both the ids and the CAS tokens to mutate them (no follow-up blocks_read_many hydration) — AND `parents`, a {block id → parent block id | null} map (null = top level), because `ids` is a flat pre-order walk and a list is otherwise indistinguishable from its items; filter to `parents[id] === null` for the top-level blocks. For structure-only orientation use docs_outline; to hydrate a single block use blocks_read.",
|
|
260
320
|
inputSchema: {
|
|
261
|
-
|
|
262
|
-
path: z.string().optional(),
|
|
321
|
+
...DOC_ARGS,
|
|
263
322
|
include_ids: z.boolean().optional(),
|
|
264
323
|
...REPO_ARG,
|
|
265
324
|
},
|
|
266
325
|
}, async (args) => {
|
|
267
326
|
try {
|
|
268
327
|
const { repoId } = repoScope(args.repo);
|
|
269
|
-
const docId =
|
|
328
|
+
const docId = locateDoc(repoId, "docs_read", registry.get("docs_read").shape, args);
|
|
270
329
|
const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
|
|
271
330
|
if (!res)
|
|
272
|
-
throw new EngineError("doc_missing", `no document
|
|
331
|
+
throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
|
|
273
332
|
return ok(res);
|
|
274
333
|
}
|
|
275
334
|
catch (e) {
|
|
276
335
|
return fail(e);
|
|
277
336
|
}
|
|
278
337
|
});
|
|
279
|
-
|
|
280
|
-
description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a
|
|
338
|
+
tool("docs_read_many", {
|
|
339
|
+
description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`raw` = complete file bytes verbatim, `properties` grouped by source, plus `id`/`path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `id` → block `hash`} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/blocks_search/blocks_resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline (docs_get_many before surface 3.0).",
|
|
281
340
|
inputSchema: {
|
|
282
341
|
docs: z.array(z.string()),
|
|
283
342
|
include_ids: z.boolean().optional(),
|
|
@@ -297,84 +356,73 @@ export function buildServer(ctx) {
|
|
|
297
356
|
return fail(e);
|
|
298
357
|
}
|
|
299
358
|
});
|
|
300
|
-
|
|
301
|
-
description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full).
|
|
359
|
+
tool("blocks_read", {
|
|
360
|
+
description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full). `id` is the block's id (`b_…`) and its document is inferred from it; an `n_` node id is not a block id — nodes_block_read reads the block a node was projected from (nodes_get before surface 3.0: blocks used to be called nodes here, and `nodes` now names the extracted-entity rows). Every resolution carries the block's `id` and `type`; raw and full add `raw` (its source bytes) and `hash` (its own raw hash) — the value update/split need in expect.content_hash, so you can fetch it before editing rather than reading it back from a conflict; full adds `text`, `attrs`, `ordinal`, `depth` and `parent_id` (null at the top level). Use this to expand the lean ids returned by query/blocks_resolve. To read a whole document in one call, use docs_read (blocks_read on a heading's id returns only that block, not its section).",
|
|
302
361
|
inputSchema: {
|
|
303
|
-
doc: z.string().optional(),
|
|
304
|
-
path: z.string().optional(),
|
|
305
362
|
id: z.string(),
|
|
306
363
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
307
364
|
...REPO_ARG,
|
|
308
365
|
},
|
|
309
366
|
}, async (args) => {
|
|
310
367
|
try {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
368
|
+
repoScope(args.repo);
|
|
369
|
+
// `id` is a block id, unambiguously the `blocks_` kind's (§4 "Names and
|
|
370
|
+
// arguments"): an `n_` id names a NODE and is `block_missing` pointing at
|
|
371
|
+
// `nodes_block_read` (the name pass had dereferenced it here, §9).
|
|
372
|
+
if (args.id.startsWith("n_"))
|
|
373
|
+
throw blockMissing(args.id, "an n_ id names a node — nodes_block_read reads its block");
|
|
374
|
+
const node = nodesGet(store, docOfBlock(args.id), args.id, args.resolution ? { resolution: args.resolution } : {});
|
|
314
375
|
if (!node)
|
|
315
|
-
throw
|
|
376
|
+
throw blockMissing(args.id);
|
|
316
377
|
return ok(node);
|
|
317
378
|
}
|
|
318
379
|
catch (e) {
|
|
319
380
|
return fail(e);
|
|
320
381
|
}
|
|
321
382
|
});
|
|
322
|
-
|
|
323
|
-
description: "
|
|
383
|
+
tool("nodes_block_read", {
|
|
384
|
+
description: "Read the block a NODE was projected from: `id` is the node's id (`n_…`, from `query` over `from nodes`) and the result is exactly what blocks_read returns for that block — the same resolutions (skeleton|outline|text|raw|full, default full), the same `id`/`type`/`raw`/`hash`/`text`/`attrs`/`ordinal`/`depth`/`parent_id`. The hop from an occurrence (an `md:link`, `md:task`, `md:section`, … node) to the editable block that holds it, in one call; blocks_read itself takes a block id only. An unknown node, or a node that names no live block (a block-less node, such as one derived from the frontmatter), is block_missing.",
|
|
324
385
|
inputSchema: {
|
|
325
|
-
|
|
326
|
-
path: z.string().optional(),
|
|
327
|
-
ids: z.array(z.string()),
|
|
386
|
+
id: z.string(),
|
|
328
387
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
329
|
-
budget_tokens: z.number().int().optional(),
|
|
330
388
|
...REPO_ARG,
|
|
331
389
|
},
|
|
332
390
|
}, async (args) => {
|
|
333
391
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
|
|
341
|
-
});
|
|
342
|
-
return ok(res);
|
|
392
|
+
repoScope(args.repo);
|
|
393
|
+
const { docId, blockId } = blockOfNode(args.id);
|
|
394
|
+
const node = nodesGet(store, docId, blockId, args.resolution ? { resolution: args.resolution } : {});
|
|
395
|
+
if (!node)
|
|
396
|
+
throw new EngineError("block_missing", `node ${JSON.stringify(args.id)} has no block`, { data: { id: args.id, doc_id: docId } });
|
|
397
|
+
return ok(node);
|
|
343
398
|
}
|
|
344
399
|
catch (e) {
|
|
345
400
|
return fail(e);
|
|
346
401
|
}
|
|
347
402
|
});
|
|
348
|
-
|
|
349
|
-
description: "
|
|
403
|
+
tool("blocks_read_many", {
|
|
404
|
+
description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
|
|
350
405
|
inputSchema: {
|
|
351
|
-
|
|
406
|
+
ids: z.array(z.string()),
|
|
352
407
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
408
|
+
budget_tokens: z.number().int().optional(),
|
|
353
409
|
...REPO_ARG,
|
|
354
410
|
},
|
|
355
411
|
}, async (args) => {
|
|
356
412
|
try {
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (!res)
|
|
364
|
-
throw new EngineError("doc_missing", `no document for ${JSON.stringify(args.ref)}`);
|
|
365
|
-
return ok({ kind: "document", ...res });
|
|
366
|
-
}
|
|
367
|
-
const node = nodesGet(store, resolved.docId, resolved.blockId, { resolution: args.resolution ?? "raw" });
|
|
368
|
-
if (!node)
|
|
369
|
-
throw new EngineError("block_missing", `no block ${resolved.blockId}`);
|
|
370
|
-
return ok({ kind: "block", ...node });
|
|
413
|
+
repoScope(args.repo);
|
|
414
|
+
const res = nodesGetMany(store, null, args.ids, {
|
|
415
|
+
...(args.resolution ? { resolution: args.resolution } : {}),
|
|
416
|
+
...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
|
|
417
|
+
});
|
|
418
|
+
return ok(res);
|
|
371
419
|
}
|
|
372
420
|
catch (e) {
|
|
373
421
|
return fail(e);
|
|
374
422
|
}
|
|
375
423
|
});
|
|
376
|
-
|
|
377
|
-
description: "ORIENTATION: the path-separator-aware shape of a repo in ONE small call — start here to answer 'what does this repo contain?', NOT with docs_list. Like `tree -L <depth>` crossed with `du`: every live document under `path` (a directory prefix; omit for the repo root) is collapsed at `depth` path segments (default 1 = immediate children), giving one entry per directory and one per shallow document. Returns `{ prefix, depth, total: { docs, blocks }, entries: [{ path, kind: \"dir\"|\"doc\", docs, blocks,
|
|
424
|
+
tool("docs_tree", {
|
|
425
|
+
description: "ORIENTATION: the path-separator-aware shape of a repo in ONE small call — start here to answer 'what does this repo contain?', NOT with docs_list. Like `tree -L <depth>` crossed with `du`: every live document under `path` (a directory prefix; omit for the repo root) is collapsed at `depth` path segments (default 1 = immediate children), giving one entry per directory and one per shallow document. Returns `{ prefix, depth, total: { docs, blocks }, entries: [{ id?, path, kind: \"dir\"|\"doc\", docs, blocks, updated_at }], truncated, cursor }`: a `dir` entry's `path` ends in `/` (no `id`) and its `docs`/`blocks`/`updated_at` are the totals + latest last-commit timestamp UNDER it; a `doc` entry is a single document (docs = 1) with its `id`. `total` always covers everything under `prefix` regardless of paging, so you see the true size of what you're looking at. On an 800-document repo `depth:1` at the root is ~10 rows where docs_list would be ~800; descend by calling again with `path` set to a dir entry (or raise `depth`). Entries are ordered by path and paged: `limit` (default " + DOCS_TREE_DEFAULT_LIMIT + ") / `cursor` / `budget_tokens` with an honest `truncated`. For ranked or attribute-based discovery use `blocks_resolve`/`query`; to enumerate the documents themselves once you know where to look, use docs_list with a `path_glob`.",
|
|
378
426
|
inputSchema: {
|
|
379
427
|
path: z.string().optional(),
|
|
380
428
|
depth: z.number().int().optional(),
|
|
@@ -398,8 +446,8 @@ export function buildServer(ctx) {
|
|
|
398
446
|
return fail(e);
|
|
399
447
|
}
|
|
400
448
|
});
|
|
401
|
-
|
|
402
|
-
description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ path,
|
|
449
|
+
tool("docs_list", {
|
|
450
|
+
description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ id, path, updated_at, blocks }], truncated, cursor }` — the document's id, its path (`/`-rooted, as every path the surface returns), last-commit timestamp (ISO, or null) and live block count — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `blocks_resolve`.",
|
|
403
451
|
inputSchema: {
|
|
404
452
|
path_glob: z.string().optional(),
|
|
405
453
|
limit: z.number().int().optional(),
|
|
@@ -421,12 +469,12 @@ export function buildServer(ctx) {
|
|
|
421
469
|
return fail(e);
|
|
422
470
|
}
|
|
423
471
|
});
|
|
424
|
-
|
|
425
|
-
description: "Reference: the full `query` (OQX) syntax — the clause order, the sugar (a block without a consumer is collect, where-first bodies, brackets, postfix `!`, `is`/`is not`, `and`/`or`/`not`), `/`-rooted paths, targets and field namespaces, scalar semantics and absence, domain predicates incl. `refs()`, nested queries and the root row (`^docs`,
|
|
472
|
+
tool("query_syntax", {
|
|
473
|
+
description: "Reference: the full `query` (OQX) syntax — the clause order, the sugar (a block without a consumer is collect, where-first bodies, brackets, postfix `!`, `is`/`is not`, `and`/`or`/`not`), `/`-rooted paths, targets and field namespaces, scalar semantics and absence, domain predicates incl. `refs()`, nested queries and the root row (`^docs`, `^id`, `0^docs`), `select`/`order by`/paging, `follow`, and worked examples. Call this before writing a non-trivial query. No arguments.",
|
|
426
474
|
inputSchema: {},
|
|
427
475
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
428
|
-
|
|
429
|
-
description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); 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. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. 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 … follow … order by … limit N offset N`. 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\" }` — not after `follow`, where `{ … }` is the options block); 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 (a 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 (kind/name/value; type/text) or `$`-intrinsic 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 `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `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` / `^edges` (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`, `$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 (else semantic_unavailable) 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; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` 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 the follow-local successor `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), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). 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`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); 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_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any row — `^docs` / `^nodes` / `^blocks` / `^edges` (the root row's collections; `0^docs` from any depth). Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
|
|
476
|
+
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`).",
|
|
430
478
|
inputSchema: {
|
|
431
479
|
query: z.string(),
|
|
432
480
|
limit: z.number().int().optional(),
|
|
@@ -436,22 +484,21 @@ export function buildServer(ctx) {
|
|
|
436
484
|
}, async (args) => {
|
|
437
485
|
try {
|
|
438
486
|
const { repoId } = repoScope(args.repo);
|
|
439
|
-
// A `semantic(...)` query with no provider is
|
|
440
|
-
// generic filter error) — surface that specific code, mirroring
|
|
441
|
-
if (!ctx.embedQuery && collectSemanticPhrases(args.query).length > 0)
|
|
442
|
-
throw
|
|
443
|
-
}
|
|
487
|
+
// A `semantic(...)` query with no provider is provider_unavailable (not a
|
|
488
|
+
// generic filter error) — surface that specific code, mirroring graph.
|
|
489
|
+
if (!ctx.embedQuery && collectSemanticPhrases(args.query).length > 0)
|
|
490
|
+
throw providerUnavailable();
|
|
444
491
|
return ok(await oqxRunAsync(store, repoId, args.query, {
|
|
445
492
|
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
446
493
|
...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
|
|
447
|
-
},
|
|
494
|
+
}, rankableEmbed(repoId)));
|
|
448
495
|
}
|
|
449
496
|
catch (e) {
|
|
450
497
|
return fail(e);
|
|
451
498
|
}
|
|
452
499
|
});
|
|
453
|
-
|
|
454
|
-
description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents,
|
|
500
|
+
tool("graph", {
|
|
501
|
+
description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow out_links collect { from dst }` / `follow in_links collect { from doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"path\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `id`/`path`/`degree` = min hops from a root/`frontier` bool + your projections — a bare-name selection such as \"title\" is its own key, `id`/`path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `id`, `doc_id`/`block_id` (the source), `via_id`, `dst_id`, `dst_path`, `uri`, `dst_kind`, `predicate`, `provenance`, `anchor`, `field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ id, path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
|
|
455
502
|
inputSchema: {
|
|
456
503
|
roots: z.array(z.string()).min(1),
|
|
457
504
|
degrees: z.number().int().optional(),
|
|
@@ -466,17 +513,16 @@ export function buildServer(ctx) {
|
|
|
466
513
|
const { repoId } = repoScope(args.repo);
|
|
467
514
|
// A `select` using semantic(...) needs a provider — surface the specific
|
|
468
515
|
// code, mirroring the `query` tool.
|
|
469
|
-
if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`from docs select x: ${s}`).length > 0))
|
|
470
|
-
throw
|
|
471
|
-
|
|
472
|
-
return ok(await graphNeighborhood(store, repoId, args, ctx.embedQuery));
|
|
516
|
+
if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`from docs select x: ${s}`).length > 0))
|
|
517
|
+
throw providerUnavailable();
|
|
518
|
+
return ok(await graphNeighborhood(store, repoId, args, rankableEmbed(repoId)));
|
|
473
519
|
}
|
|
474
520
|
catch (e) {
|
|
475
521
|
return fail(e);
|
|
476
522
|
}
|
|
477
523
|
});
|
|
478
|
-
|
|
479
|
-
description: "Full-text (FTS5, bm25-ranked) keyword search over block text
|
|
524
|
+
tool("blocks_search", {
|
|
525
|
+
description: "Full-text (FTS5, bm25-ranked) keyword search over block text: `{ hits: [{ id (the block), doc_id, path, type, text, score }], truncated }`. Input is treated as a search box — plain words are ANDed, \"quoted phrases\" match adjacency, punctuation like / is safe (no query DSL). For structured filtering or frontmatter projection use `query` instead (see query_syntax). (text_search before surface 3.0.)",
|
|
480
526
|
inputSchema: { q: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
481
527
|
}, async (args) => {
|
|
482
528
|
try {
|
|
@@ -487,8 +533,8 @@ export function buildServer(ctx) {
|
|
|
487
533
|
return fail(e);
|
|
488
534
|
}
|
|
489
535
|
});
|
|
490
|
-
|
|
491
|
-
description: "Resolve a name/title/concept to the concrete blocks it refers to — 'the id of the thing I mean'. Hybrid ranker (FTS + semantic when a provider is configured); best on short, entity-shaped inputs (a title, term, or phrase). Returns ranked {id, locator, preview, evidence}; prefer the returned ids in follow-up calls. For open natural-language questions or passage retrieval, use `query` with `semantic` instead.",
|
|
536
|
+
tool("blocks_resolve", {
|
|
537
|
+
description: "Resolve a name/title/concept to the concrete blocks it refers to — 'the id of the thing I mean'. Hybrid ranker (FTS + semantic when a provider is configured); best on short, entity-shaped inputs (a title, term, or phrase). Returns ranked `{ id, locator, preview, evidence }` (the block's id, its `path#type[ordinal]` locator); prefer the returned ids in follow-up calls. For open natural-language questions or passage retrieval, use `query` with `semantic` instead. (resolve before surface 3.0.)",
|
|
492
538
|
inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
493
539
|
}, async (args) => {
|
|
494
540
|
try {
|
|
@@ -504,8 +550,8 @@ export function buildServer(ctx) {
|
|
|
504
550
|
return fail(e);
|
|
505
551
|
}
|
|
506
552
|
});
|
|
507
|
-
|
|
508
|
-
description: "The only real writer. Applies a changeset of kernel ops (insert/update/move/remove/split/merge) atomically — all apply or none. Each op is a tagged object keyed by \"op\"; the block an op targets is named by its `block` (update/split) or `blocks` (move/remove/merge) field, never `id`/`target
|
|
553
|
+
tool("apply", {
|
|
554
|
+
description: "The only real writer. Applies a changeset of kernel ops (insert/update/move/remove/split/merge) atomically — all apply or none. Each op is a tagged object keyed by \"op\"; the block an op targets is named by its `block` (update/split) or `blocks` (move/remove/merge) field, never `id`/`target` (the op grammar is spec/mutate's, unchanged by the surface's `id`/`path` argument rule). Ops apply in order; later ops see earlier effects; minted ids are referenceable via \"$n.ids[i]\". Pass dry_run:true first for multi-doc changes to preview diffs. The result is `{ results: [{ ids, … }], revisions: [{ id, path, hash }], diffs?, committed }` — one revision per touched document with its hash after the change (the same shape a changes_since revision has). update/split/merge/remove take a CAS `expect.content_hash` — get it from docs_read include_ids (its `hashes` map) in the same read that gave you the ids, or use the ref-accepting block_* sugar (blocks_update/blocks_split/…) which pins it server-side. Conflicts carry current truth (`data.current.hash` + `markdown`) — including a missing/omitted expect.content_hash, whose error returns the current hash — so retry from the error, don't re-read.",
|
|
509
555
|
inputSchema: {
|
|
510
556
|
ops: z.array(opSchema),
|
|
511
557
|
reason: z.string().optional(),
|
|
@@ -537,8 +583,8 @@ export function buildServer(ctx) {
|
|
|
537
583
|
// and applies it. This is what lets `omg <verb> --server` feel identical to
|
|
538
584
|
// local: the resolution that used to live in the CLI now lives next to the
|
|
539
585
|
// store, so a remote client needs no local database.
|
|
540
|
-
|
|
541
|
-
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
|
|
586
|
+
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. Expands to one insert op through the kernel writer.",
|
|
542
588
|
inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
543
589
|
}, async (args) => {
|
|
544
590
|
try {
|
|
@@ -556,14 +602,14 @@ export function buildServer(ctx) {
|
|
|
556
602
|
return fail(e);
|
|
557
603
|
}
|
|
558
604
|
});
|
|
559
|
-
|
|
560
|
-
description: "Replace a block's markdown (and/or set attrs) with compare-and-swap. `
|
|
561
|
-
inputSchema: {
|
|
605
|
+
tool("blocks_update", {
|
|
606
|
+
description: "Replace a block's markdown (and/or set attrs) 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). One update op through the kernel writer. `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 response carries `id` (the target) and `ids` (all resulting blocks, in order). 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`), folded into attrs and synced to the `[ ]`/`[x]` in the raw. The general `attrs` bag is still accepted; note every OTHER block attr (heading `level`, code `lang`, list `ordered`) is derived from the markdown, so set those by editing `markdown`, not attrs.",
|
|
607
|
+
inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), attrs: z.record(z.string(), z.unknown()).optional(), expect: expectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
562
608
|
}, async (args) => {
|
|
563
609
|
try {
|
|
564
610
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
565
611
|
const root = requireRoot(rootPath);
|
|
566
|
-
const block = resolveBlockRef(repoId, args.
|
|
612
|
+
const block = resolveBlockRef(repoId, args.id);
|
|
567
613
|
const expect = args.expect ?? (() => { const h = pinHash(block); return h ? { content_hash: h } : undefined; })();
|
|
568
614
|
// Flat `checked` sugar folds into the attrs bag (explicit attrs wins on
|
|
569
615
|
// collision), so a task reads and writes under the same bare name.
|
|
@@ -582,7 +628,7 @@ export function buildServer(ctx) {
|
|
|
582
628
|
return fail(e);
|
|
583
629
|
}
|
|
584
630
|
});
|
|
585
|
-
|
|
631
|
+
tool("blocks_move", {
|
|
586
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. One move op through the kernel writer.",
|
|
587
633
|
inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
588
634
|
}, async (args) => {
|
|
@@ -604,7 +650,7 @@ export function buildServer(ctx) {
|
|
|
604
650
|
return fail(e);
|
|
605
651
|
}
|
|
606
652
|
});
|
|
607
|
-
|
|
653
|
+
tool("blocks_remove", {
|
|
608
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 and `removed` lists everything that left. One remove op through the kernel writer. To delete a whole document use docs_delete.",
|
|
609
655
|
inputSchema: { blocks: z.array(z.string()), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
610
656
|
}, async (args) => {
|
|
@@ -619,14 +665,14 @@ export function buildServer(ctx) {
|
|
|
619
665
|
return fail(e);
|
|
620
666
|
}
|
|
621
667
|
});
|
|
622
|
-
|
|
623
|
-
description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `
|
|
624
|
-
inputSchema: {
|
|
668
|
+
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. One split op through the kernel writer.",
|
|
670
|
+
inputSchema: { id: z.string(), at: z.array(z.number().int()), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
625
671
|
}, async (args) => {
|
|
626
672
|
try {
|
|
627
673
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
628
674
|
const root = requireRoot(rootPath);
|
|
629
|
-
const block = resolveBlockRef(repoId, args.
|
|
675
|
+
const block = resolveBlockRef(repoId, args.id);
|
|
630
676
|
const ops = [{ op: "split", block, at: args.at, expect: { content_hash: pinHash(block) ?? "" } }];
|
|
631
677
|
return applyOps(repoId, root, ops, "blocks_split", args.dry_run);
|
|
632
678
|
}
|
|
@@ -634,7 +680,7 @@ export function buildServer(ctx) {
|
|
|
634
680
|
return fail(e);
|
|
635
681
|
}
|
|
636
682
|
});
|
|
637
|
-
|
|
683
|
+
tool("blocks_merge", {
|
|
638
684
|
description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). One merge op through the kernel writer.",
|
|
639
685
|
inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
640
686
|
}, async (args) => {
|
|
@@ -649,74 +695,99 @@ export function buildServer(ctx) {
|
|
|
649
695
|
return fail(e);
|
|
650
696
|
}
|
|
651
697
|
});
|
|
652
|
-
|
|
653
|
-
description: "Macro: check (or
|
|
654
|
-
inputSchema: {
|
|
698
|
+
tool("blocks_check", {
|
|
699
|
+
description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Expands to update(attrs:{checked}) per block, CAS-pinned, applied via the same changeset machinery (tasks_complete { blocks } before surface 3.0).",
|
|
700
|
+
inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
655
701
|
}, async (args) => {
|
|
656
702
|
try {
|
|
657
703
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
658
704
|
const root = requireRoot(rootPath);
|
|
659
|
-
const blocks = args.
|
|
705
|
+
const blocks = args.ids.map((b) => resolveBlockRef(repoId, b));
|
|
660
706
|
const checked = args.checked ?? true;
|
|
661
707
|
const ops = checked
|
|
662
708
|
? tasksComplete(store, blocks)
|
|
663
709
|
: blocks.map((block) => ({ op: "update", block, attrs: { checked: false }, ...(() => { const h = pinHash(block); return h ? { expect: { content_hash: h } } : {}; })() }));
|
|
664
|
-
return applyOps(repoId, root, ops, "
|
|
710
|
+
return applyOps(repoId, root, ops, "blocks_check", args.dry_run);
|
|
665
711
|
}
|
|
666
712
|
catch (e) {
|
|
667
713
|
return fail(e);
|
|
668
714
|
}
|
|
669
715
|
});
|
|
670
|
-
|
|
671
|
-
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`).
|
|
672
|
-
inputSchema: {
|
|
716
|
+
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, applying one update op. 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. (node_set before surface 3.0.)",
|
|
718
|
+
inputSchema: { id: z.string(), prop: z.string(), value: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
673
719
|
}, async (args) => {
|
|
674
720
|
try {
|
|
675
721
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
676
722
|
const root = requireRoot(rootPath);
|
|
677
|
-
const
|
|
678
|
-
|
|
723
|
+
const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.id, repoId);
|
|
724
|
+
if (!known)
|
|
725
|
+
throw new EngineError("block_missing", `no node with id ${JSON.stringify(args.id)}`, { data: { id: args.id } });
|
|
726
|
+
const ops = nodeSet(store, args.id, args.prop, args.value);
|
|
727
|
+
return applyOps(repoId, root, ops, "nodes_set", args.dry_run);
|
|
679
728
|
}
|
|
680
729
|
catch (e) {
|
|
681
730
|
return fail(e);
|
|
682
731
|
}
|
|
683
732
|
});
|
|
684
|
-
|
|
685
|
-
description: "Macro: append markdown
|
|
686
|
-
inputSchema: {
|
|
733
|
+
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) — the former sections_append. A CONTAINER (a list, list item, task, blockquote or table — anything the kernel accepts as a parent): inserted at the end of its children (`blocks_insert { to: <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> } }`. Expands to one insert op; to append at a whole document's end, or inside a section named by its heading TEXT, use docs_append.",
|
|
735
|
+
inputSchema: { id: z.string(), text: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
687
736
|
}, async (args) => {
|
|
688
737
|
try {
|
|
689
738
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
690
739
|
const root = requireRoot(rootPath);
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
|
|
740
|
+
const block = resolveBlockRef(repoId, args.id);
|
|
741
|
+
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")
|
|
743
|
+
.get(block);
|
|
744
|
+
if (!row)
|
|
745
|
+
throw blockMissing(args.id);
|
|
746
|
+
let ops;
|
|
747
|
+
if (row.type === "heading") {
|
|
748
|
+
ops = sectionsAppend(block, args.text);
|
|
749
|
+
}
|
|
750
|
+
else if (isContainerBlock(row.format ?? "markdown", { type: row.type, children: new Array(row.children) })) {
|
|
751
|
+
ops = [{ op: "insert", to: { parent: block, at: "end" }, markdown: args.text }];
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
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 },
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return applyOps(repoId, root, ops, "blocks_append", args.dry_run);
|
|
694
759
|
}
|
|
695
760
|
catch (e) {
|
|
696
761
|
return fail(e);
|
|
697
762
|
}
|
|
698
763
|
});
|
|
699
|
-
|
|
700
|
-
description: "Macro: append markdown
|
|
701
|
-
inputSchema: {
|
|
764
|
+
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). Expands to a single insert op; commits atomically through the same kernel path as `apply`, returning the new revision(s) and the inserted block ids. 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.",
|
|
766
|
+
inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
702
767
|
}, async (args) => {
|
|
703
768
|
try {
|
|
704
769
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
//
|
|
708
|
-
// (
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
770
|
+
const root = requireRoot(rootPath);
|
|
771
|
+
// Resolve the locator to a live doc id FIRST — a missing doc is doc_missing
|
|
772
|
+
// (locateDoc throws it), never an auto-create. Then expand to the single
|
|
773
|
+
// insert-at-end op (document top level, or the named section) and apply
|
|
774
|
+
// it (existing blocks keep their ids; only the appended blocks are minted).
|
|
775
|
+
const docId = locateDoc(repoId, "docs_append", registry.get("docs_append").shape, args);
|
|
776
|
+
let ops;
|
|
777
|
+
if (args.section !== undefined) {
|
|
778
|
+
const info = findDoc(store, { docId });
|
|
779
|
+
ops = sectionsAppend(resolveSectionHeading(docId, info.path, args.section), args.text);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
ops = docsAppend(docId, args.text);
|
|
783
|
+
}
|
|
784
|
+
return applyOps(repoId, root, ops, "docs_append", args.dry_run);
|
|
714
785
|
}
|
|
715
786
|
catch (e) {
|
|
716
787
|
return fail(e);
|
|
717
788
|
}
|
|
718
789
|
});
|
|
719
|
-
|
|
790
|
+
tool("links_retarget", {
|
|
720
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`. ALWAYS call with dry_run:true first to preview `hits` + `pairs` (the dry run plans through the kernel, so it fails exactly where the apply would), then dry_run:false to apply.",
|
|
721
792
|
inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
722
793
|
}, async (args) => {
|
|
@@ -736,8 +807,8 @@ export function buildServer(ctx) {
|
|
|
736
807
|
return fail(e);
|
|
737
808
|
}
|
|
738
809
|
});
|
|
739
|
-
|
|
740
|
-
description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each
|
|
810
|
+
tool("links_stale", {
|
|
811
|
+
description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each a link row under the `links` target's names: `doc_id`, `path` (the source document), `block_id` (null for a frontmatter edge), predicate, provenance, dst_kind, `target` = the canonical missing path, `/`-rooted like every path the surface returns (e.g. `/guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{path,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
|
|
741
812
|
inputSchema: { path_glob: z.string().optional(), limit: z.number().int().optional(), summary: z.boolean().optional(), ...REPO_ARG },
|
|
742
813
|
}, async (args) => {
|
|
743
814
|
try {
|
|
@@ -753,8 +824,8 @@ export function buildServer(ctx) {
|
|
|
753
824
|
return fail(e);
|
|
754
825
|
}
|
|
755
826
|
});
|
|
756
|
-
|
|
757
|
-
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. Returns `hits[{
|
|
827
|
+
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. Returns `hits[{id,path,oldRaw,newRaw}]` (the rewritten block and its document) and `pairs[{from,to,hits}]` (per-pair destination counts; 0 = nothing matched). ALWAYS call with dry_run:true first — the dry run plans through the kernel (returns `diffs`), so it fails exactly where the apply would — then dry_run:false to apply.",
|
|
758
829
|
inputSchema: {
|
|
759
830
|
repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
|
|
760
831
|
from_target: z.string().optional(),
|
|
@@ -801,8 +872,8 @@ export function buildServer(ctx) {
|
|
|
801
872
|
return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp", ...(dryRun ? { dryRun: true } : {}) };
|
|
802
873
|
};
|
|
803
874
|
const docResult = (res, dryRun) => (dryRun ? ok(res) : okMutated(res));
|
|
804
|
-
|
|
805
|
-
description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists. dry_run:true validates and returns the would-be file under `diffs` (committed:false) without writing.",
|
|
875
|
+
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`; returns `{ id, path, committed }`. Fails path_taken if it already exists. dry_run:true validates and returns the would-be file under `diffs` (committed:false) without writing.",
|
|
806
877
|
inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
807
878
|
}, async (args) => {
|
|
808
879
|
try {
|
|
@@ -812,34 +883,40 @@ export function buildServer(ctx) {
|
|
|
812
883
|
return fail(e);
|
|
813
884
|
}
|
|
814
885
|
});
|
|
815
|
-
|
|
816
|
-
description: "Rename a document to a new path (`/`-rooted or bare; the result reports the rooted form);
|
|
817
|
-
inputSchema: {
|
|
886
|
+
tool("docs_move", {
|
|
887
|
+
description: "Rename a document to a new path (`to_path`, `/`-rooted or bare; the result `{ id, path, committed, retargeted, dangling }` reports the rooted form); 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 changeset, so a move never leaves the graph broken. Fails path_taken if the destination exists. The result carries `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. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus the rewritten sources) — without renaming or committing.",
|
|
888
|
+
inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
818
889
|
}, async (args) => {
|
|
819
890
|
try {
|
|
820
|
-
|
|
891
|
+
const scope = repoScope(args.repo);
|
|
892
|
+
const docId = locateDoc(scope.repoId, "docs_move", registry.get("docs_move").shape, args);
|
|
893
|
+
return docResult(surfaceDocMoveResult(docsMove(store, docCtx(scope, args.dry_run), docId, args.to_path, { retargetInbound: args.retarget_inbound !== false })), args.dry_run);
|
|
821
894
|
}
|
|
822
895
|
catch (e) {
|
|
823
896
|
return fail(e);
|
|
824
897
|
}
|
|
825
898
|
});
|
|
826
|
-
|
|
827
|
-
description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file.
|
|
828
|
-
inputSchema: {
|
|
899
|
+
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`. dry_run:true validates and returns the file's removal under `diffs` without deleting.",
|
|
901
|
+
inputSchema: { ...DOC_ARGS, dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
829
902
|
}, async (args) => {
|
|
830
903
|
try {
|
|
831
|
-
|
|
904
|
+
const scope = repoScope(args.repo);
|
|
905
|
+
const docId = locateDoc(scope.repoId, "docs_delete", registry.get("docs_delete").shape, args);
|
|
906
|
+
return docResult(surfaceDocOpResult(docsDelete(store, docCtx(scope, args.dry_run), docId)), args.dry_run);
|
|
832
907
|
}
|
|
833
908
|
catch (e) {
|
|
834
909
|
return fail(e);
|
|
835
910
|
}
|
|
836
911
|
});
|
|
837
|
-
|
|
838
|
-
description: "Surgical frontmatter patch: set the given keys and/or unset named keys
|
|
839
|
-
inputSchema: {
|
|
912
|
+
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`, re-ingesting it. Other frontmatter is preserved. dry_run:true validates and returns the rewritten file under `diffs` without writing.",
|
|
914
|
+
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
840
915
|
}, async (args) => {
|
|
841
916
|
try {
|
|
842
|
-
|
|
917
|
+
const scope = repoScope(args.repo);
|
|
918
|
+
const docId = locateDoc(scope.repoId, "docs_set_meta", registry.get("docs_set_meta").shape, args);
|
|
919
|
+
return docResult(surfaceDocOpResult(docsSetMeta(store, docCtx(scope, args.dry_run), docId, {
|
|
843
920
|
...(args.set ? { set: args.set } : {}),
|
|
844
921
|
...(args.unset ? { unset: args.unset } : {}),
|
|
845
922
|
})), args.dry_run);
|
|
@@ -848,27 +925,14 @@ export function buildServer(ctx) {
|
|
|
848
925
|
return fail(e);
|
|
849
926
|
}
|
|
850
927
|
});
|
|
851
|
-
|
|
852
|
-
description: "
|
|
853
|
-
inputSchema: {
|
|
928
|
+
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 — then commits the derived opset through the kernel write path. Frontmatter changes are applied too. dry_run:true is the PLAN-ONLY call (docs_plan_update before surface 3.0): nothing is written and the result is `{ opset, plan, result: null }` — the exact kernel ops (insert/update/move/remove) it would run, each annotated with its identity consequence (disposition, confidence, reason), a summary (preserved/updated/moved/created/removed) and a human-readable plan; the opset carries `precondition: { id, path, baseRevision, hash }` (the document's revision and hash at plan time) and `converges`, verified by replaying the ops (a divergence names the first differing byte and the proposed block in `diagnostics[]`). Use the dry run to inspect identity effects before committing, then call again without it. Conflicts (the doc changed since planning) fail stale_plan — re-run. A plan whose replay cannot reproduce `content` 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`). This is the dry run's opset + apply(opset).",
|
|
930
|
+
inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
854
931
|
}, async (args) => {
|
|
855
932
|
try {
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
const opset = surfaceOpset(planUpdate(store, repoId, rootPath, args.doc, args.content));
|
|
860
|
-
return ok({ opset, plan: renderOpsetPlan(opset) });
|
|
861
|
-
}
|
|
862
|
-
catch (e) {
|
|
863
|
-
return fail(e);
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
|
-
server.registerTool("docs_update", {
|
|
867
|
-
description: "Whole-document update with smart identity preservation. Submit the complete proposed `content` for a doc (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 — then commits the derived opset through the kernel write path. Frontmatter changes are applied too. dry_run:true returns the opset + plan without writing (identical to docs_plan_update). Conflicts (the doc changed since planning) fail stale_plan — re-run. A plan whose replay cannot reproduce `content` 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`). This is docs_plan_update + apply(opset).",
|
|
868
|
-
inputSchema: { doc: z.string(), content: z.string(), reason: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
|
|
869
|
-
}, async (args) => {
|
|
870
|
-
try {
|
|
871
|
-
const { opset, result } = surfaceDocsUpdate(docsUpdate(store, docCtx(repoScope(args.repo)), args.doc, args.content, {
|
|
933
|
+
const scope = repoScope(args.repo);
|
|
934
|
+
const docId = locateDoc(scope.repoId, "docs_update", registry.get("docs_update").shape, args);
|
|
935
|
+
const { opset, result } = surfaceDocsUpdate(docsUpdate(store, docCtx(scope), docId, args.content, {
|
|
872
936
|
...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
|
|
873
937
|
...(args.reason !== undefined ? { reason: args.reason } : {}),
|
|
874
938
|
}));
|
|
@@ -879,8 +943,8 @@ export function buildServer(ctx) {
|
|
|
879
943
|
return fail(e);
|
|
880
944
|
}
|
|
881
945
|
});
|
|
882
|
-
|
|
883
|
-
description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns
|
|
946
|
+
tool("observe", {
|
|
947
|
+
description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns `{ id, path, rev, commitId, converged, echo, conflicted, dispositions }` — the document's row attributes plus a disposition summary (how identity threaded). `path` is a FILE event's path from the store's layer, not a row locator, so it keeps its name.",
|
|
884
948
|
inputSchema: { path: z.string(), content: z.string(), ...REPO_ARG },
|
|
885
949
|
}, async (args) => {
|
|
886
950
|
try {
|
|
@@ -891,7 +955,7 @@ export function buildServer(ctx) {
|
|
|
891
955
|
return fail(e);
|
|
892
956
|
}
|
|
893
957
|
});
|
|
894
|
-
|
|
958
|
+
tool("observe_many", {
|
|
895
959
|
description: "BATCH sync ingest (ADR-014): observe several files at once — the batch form of `observe`. Give `files` as an array of `{path, content}`; each is echo-gated and reconciled independently, all under one timestamp and a single resurrection-pool sweep (cheaper than N `observe` calls for an initial walk or a large checkpoint). Returns one result per input file (same shape as `observe`). Mirror deletions with `observe_delete`, one path at a time.",
|
|
896
960
|
inputSchema: { files: z.array(z.object({ path: z.string(), content: z.string() })), ...REPO_ARG },
|
|
897
961
|
}, async (args) => {
|
|
@@ -903,7 +967,7 @@ export function buildServer(ctx) {
|
|
|
903
967
|
return fail(e);
|
|
904
968
|
}
|
|
905
969
|
});
|
|
906
|
-
|
|
970
|
+
tool("observe_delete", {
|
|
907
971
|
description: "SYNC DELETE (ADR-014): record that `path` left the source scope — tombstone the live doc as an OBSERVED deletion (its blocks are pooled for resurrection if the path reappears; no file is removed, since it is already gone from the source). The deletion counterpart to `observe`, for a synchronizer mirroring an external delete. Idempotent: a path with no live doc is a no-op (`deleted:false`). Contrast `docs_delete` — an api-origin, intentional, non-pooled removal that also unlinks the working-tree file.",
|
|
908
972
|
inputSchema: { path: z.string(), ...REPO_ARG },
|
|
909
973
|
}, async (args) => {
|
|
@@ -915,8 +979,8 @@ export function buildServer(ctx) {
|
|
|
915
979
|
return fail(e);
|
|
916
980
|
}
|
|
917
981
|
});
|
|
918
|
-
|
|
919
|
-
description: "A block's biography: the commits that touched
|
|
982
|
+
tool("blocks_history", {
|
|
983
|
+
description: "A block's biography: the commits that touched the block `id` names, with disposition kind/confidence/reason, newest first (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
|
|
920
984
|
inputSchema: { id: z.string(), limit: z.number().int().optional() },
|
|
921
985
|
}, async (args) => {
|
|
922
986
|
try {
|
|
@@ -926,47 +990,47 @@ export function buildServer(ctx) {
|
|
|
926
990
|
return fail(e);
|
|
927
991
|
}
|
|
928
992
|
});
|
|
929
|
-
|
|
930
|
-
description: "Block-grain diff between two revisions of a document: added
|
|
931
|
-
inputSchema: {
|
|
993
|
+
tool("docs_diff", {
|
|
994
|
+
description: "Block-grain diff between two revisions of a document: `[{ kind: added|removed|changed, id (the block), before?, after? }]` in that order. Address the document by `id` or `path` (at least one); `from_rev`/`to_rev` are revision ids (from docs_history, changes_since or blocks_history). Contrast docs_diff_unified, the unified text diff. (diff before surface 3.0.)",
|
|
995
|
+
inputSchema: { ...DOC_ARGS, from_rev: z.string(), to_rev: z.string(), ...REPO_ARG },
|
|
932
996
|
}, async (args) => {
|
|
933
997
|
try {
|
|
934
998
|
const { repoId } = repoScope(args.repo);
|
|
935
|
-
// Resolve
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
const docId =
|
|
999
|
+
// Resolve the locator FIRST: diffBlocks keys its SQL on the doc id, so a
|
|
1000
|
+
// raw path here would silently return an empty diff (no error) — the
|
|
1001
|
+
// misleading-empty trap. locateDoc throws doc_missing when the ref
|
|
1002
|
+
// names no document.
|
|
1003
|
+
const docId = locateDoc(repoId, "docs_diff", registry.get("docs_diff").shape, args);
|
|
940
1004
|
return ok(diffBlocks(store, docId, args.from_rev, args.to_rev));
|
|
941
1005
|
}
|
|
942
1006
|
catch (e) {
|
|
943
1007
|
return fail(e);
|
|
944
1008
|
}
|
|
945
1009
|
});
|
|
946
|
-
|
|
947
|
-
description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{
|
|
948
|
-
inputSchema: {
|
|
1010
|
+
tool("docs_diff_unified", {
|
|
1011
|
+
description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{ id, path, from, to, diff }` where `diff` is a unified diff of the two revisions' file texts — for each revision exactly the bytes docs_read_at returns, so hunk line numbers are real file lines (Myers shortest edit script; `@@ -a,b +c,d @@` hunks with 3 lines of context; lines prefixed `-`, `+` or a space; no file header; identical texts → \"\"). Address the document by `id` or `path` (at least one). `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `docs_diff`, which returns block-grain added/removed/changed entries. (diff_unified before surface 3.0.)",
|
|
1012
|
+
inputSchema: { ...DOC_ARGS, from_rev: z.string().optional(), to_rev: z.string().optional(), ...REPO_ARG },
|
|
949
1013
|
}, async (args) => {
|
|
950
1014
|
try {
|
|
951
1015
|
const { repoId } = repoScope(args.repo);
|
|
952
|
-
const docId =
|
|
1016
|
+
const docId = locateDoc(repoId, "docs_diff_unified", registry.get("docs_diff_unified").shape, args);
|
|
953
1017
|
return ok(docDiffUnified(store, docId, {
|
|
954
1018
|
...(args.from_rev !== undefined ? { fromRev: args.from_rev } : {}),
|
|
955
1019
|
...(args.to_rev !== undefined ? { toRev: args.to_rev } : {}),
|
|
956
|
-
label: JSON.stringify(args.
|
|
1020
|
+
label: JSON.stringify(args.id ?? args.path),
|
|
957
1021
|
}));
|
|
958
1022
|
}
|
|
959
1023
|
catch (e) {
|
|
960
1024
|
return fail(e);
|
|
961
1025
|
}
|
|
962
1026
|
});
|
|
963
|
-
|
|
964
|
-
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `
|
|
965
|
-
inputSchema: {
|
|
1027
|
+
tool("docs_read_at", {
|
|
1028
|
+
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. `properties` are CURRENT values (propertiesAreCurrent:true), since property history is not stored. Address the document by `id` or `path` plus a `rev` id.",
|
|
1029
|
+
inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
|
|
966
1030
|
}, async (args) => {
|
|
967
1031
|
try {
|
|
968
1032
|
const { repoId } = repoScope(args.repo);
|
|
969
|
-
const docId =
|
|
1033
|
+
const docId = locateDoc(repoId, "docs_read_at", registry.get("docs_read_at").shape, args);
|
|
970
1034
|
const res = readDocumentAtRevision(store, docId, args.rev);
|
|
971
1035
|
if (!res)
|
|
972
1036
|
throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
|
|
@@ -976,11 +1040,11 @@ export function buildServer(ctx) {
|
|
|
976
1040
|
return fail(e);
|
|
977
1041
|
}
|
|
978
1042
|
});
|
|
979
|
-
|
|
980
|
-
description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc, its ordered revision list (oldest→newest) with `rev`/`seq`/`commit`/`
|
|
1043
|
+
tool("docs_history", {
|
|
1044
|
+
description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`id`, `path`, `deleted`, `currentRev`), its ordered revision list (oldest→newest) with `rev`/`seq`/`commit`/`updated_at`/`origin`/`actor`/`hash`/`isCurrent`. Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single document by `id` or `path`; at least one is required. `hash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `rev` into `docs_read_at` (whole doc AT that rev) or `docs_diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
|
|
981
1045
|
inputSchema: {
|
|
982
1046
|
path_glob: z.string().optional(),
|
|
983
|
-
|
|
1047
|
+
...DOC_ARGS,
|
|
984
1048
|
include_deleted: z.boolean().optional(),
|
|
985
1049
|
limit: z.number().int().optional(),
|
|
986
1050
|
...REPO_ARG,
|
|
@@ -988,24 +1052,23 @@ export function buildServer(ctx) {
|
|
|
988
1052
|
}, async (args) => {
|
|
989
1053
|
try {
|
|
990
1054
|
const { repoId } = repoScope(args.repo);
|
|
991
|
-
if (!args.path_glob &&
|
|
992
|
-
throw new EngineError("target_missing", "docs_history requires one of path_glob or
|
|
1055
|
+
if (!args.path_glob && args.id === undefined && args.path === undefined) {
|
|
1056
|
+
throw new EngineError("target_missing", "docs_history requires one of path_glob, id or path");
|
|
993
1057
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
: undefined
|
|
1003
|
-
|
|
1004
|
-
throw new EngineError("doc_missing", `no document for ${JSON.stringify(args.doc)}`);
|
|
1058
|
+
let doc;
|
|
1059
|
+
if (args.id !== undefined || args.path !== undefined) {
|
|
1060
|
+
// A live document resolves through the locator; with include_deleted a
|
|
1061
|
+
// tombstoned one is looked up by the same attribute.
|
|
1062
|
+
const tomb = args.include_deleted
|
|
1063
|
+
? (args.id !== undefined
|
|
1064
|
+
? store.db.prepare("SELECT doc_id FROM docs WHERE doc_id = ? AND repo_id = ?").get(args.id, repoId)
|
|
1065
|
+
: store.db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, storagePath(args.path)))
|
|
1066
|
+
: undefined;
|
|
1067
|
+
doc = tomb ? tomb.doc_id : locateDoc(repoId, "docs_history", registry.get("docs_history").shape, args);
|
|
1005
1068
|
}
|
|
1006
1069
|
return ok(docHistory(store, repoId, {
|
|
1007
1070
|
...(args.path_glob ? { pathGlob: args.path_glob } : {}),
|
|
1008
|
-
...(
|
|
1071
|
+
...(doc ? { doc } : {}),
|
|
1009
1072
|
...(args.include_deleted !== undefined ? { includeDeleted: args.include_deleted } : {}),
|
|
1010
1073
|
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
1011
1074
|
}));
|
|
@@ -1014,8 +1077,8 @@ export function buildServer(ctx) {
|
|
|
1014
1077
|
return fail(e);
|
|
1015
1078
|
}
|
|
1016
1079
|
});
|
|
1017
|
-
|
|
1018
|
-
description: "The change feed: commit digests after a cursor (repo commit seq) with one-line summaries. Each digest's `revisions[]` carries `{
|
|
1080
|
+
tool("changes_since", {
|
|
1081
|
+
description: "The change feed: commit digests after a cursor (repo commit seq) with one-line summaries. Each digest's `revisions[]` carries `{ id, path, hash }` (hash = hex of the revision's rendered file hash; the same shape an apply result's revisions have), so a synchronizer can decide 'changed vs echo' without a follow-up docs_read. Filter by `origin` (api/observed/import) to ignore commits a given writer produced. Poll with your last cursor to cheaply re-orient after time away. Paging: `cursor` is the last digest's `seq` (a dense per-REPO order — a cursor is only meaningful against the same `repo` it came from); pass it back while `truncated` is true. `head` is the repo's current max seq: an empty page with cursor == head means nothing new, cursor > head means the cursor belongs to a different repo/server.",
|
|
1019
1082
|
inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
1020
1083
|
}, async (args) => {
|
|
1021
1084
|
try {
|
|
@@ -1030,8 +1093,8 @@ export function buildServer(ctx) {
|
|
|
1030
1093
|
return fail(e);
|
|
1031
1094
|
}
|
|
1032
1095
|
});
|
|
1033
|
-
|
|
1034
|
-
description: "Repo counts: docs, blocks, commits, open edges, unconverged doc count, and on-disk drift. " +
|
|
1096
|
+
tool("repos_status", {
|
|
1097
|
+
description: "Repo counts (`{ id, slug, rootPath, docs, blocks, commits, openEdges, unconverged, disk }`): docs, blocks, commits, open edges, unconverged doc count, and on-disk drift. " +
|
|
1035
1098
|
"`disk` reports a READ-ONLY scan of the working tree vs the DB: `changed` (files edited on disk but not re-ingested), " +
|
|
1036
1099
|
"`deleted` (docs whose file is gone from disk), `untracked` (new *.md not yet ingested), and `checked` " +
|
|
1037
1100
|
"(false when the server has no working-tree path — disk agreement is then UNVERIFIED, not clean).",
|
|
@@ -1045,7 +1108,7 @@ export function buildServer(ctx) {
|
|
|
1045
1108
|
return fail(e);
|
|
1046
1109
|
}
|
|
1047
1110
|
});
|
|
1048
|
-
|
|
1111
|
+
tool("sync_status", {
|
|
1049
1112
|
description: "Watcher/sync state: last commit seq, last checkpoint, and whether the repo is convergent. " +
|
|
1050
1113
|
"`convergent` is true ONLY when the DB is internally converged AND a working-tree scan ran (`diskChecked`) AND found no drift " +
|
|
1051
1114
|
"(`disk.changed`/`deleted`/`untracked` all 0). If the server has no working-tree path, `diskChecked` is false and `convergent` is " +
|
|
@@ -1063,7 +1126,7 @@ export function buildServer(ctx) {
|
|
|
1063
1126
|
// List the repos in this workspace (multi-repo discovery for a client): slug +
|
|
1064
1127
|
// whether it has a filesystem source. The `repo` arg other tools take is a slug
|
|
1065
1128
|
// from here. (repos_status gives per-repo counts.)
|
|
1066
|
-
|
|
1129
|
+
tool("repos", {
|
|
1067
1130
|
description: "List the repos in this workspace: `{ repos: [{ slug, hasSource }] }`. A client addresses any of them by passing that `slug` as the `repo` argument to another tool (omit `repo` ⇒ the server's default repo). Use repos_status for per-repo counts/convergence.",
|
|
1068
1131
|
inputSchema: {},
|
|
1069
1132
|
}, async () => {
|
|
@@ -1076,7 +1139,7 @@ export function buildServer(ctx) {
|
|
|
1076
1139
|
});
|
|
1077
1140
|
// Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
|
|
1078
1141
|
// answer is about the binary and its database, not a repo.
|
|
1079
|
-
|
|
1142
|
+
tool("version", {
|
|
1080
1143
|
description: "Which engine and which versions you are talking to: `{ engine: \"typescript\"|\"rust\", version, components: { <package>: <version> }, specs: { oqx, format, reconcile, store, properties, graph, search, mutate, sync, surface, cli }, schema, mcp: { protocol, sdk? }, runtime, commit, built }`. " +
|
|
1081
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.",
|
|
1082
1145
|
inputSchema: {},
|
|
@@ -1088,6 +1151,34 @@ export function buildServer(ctx) {
|
|
|
1088
1151
|
return fail(e);
|
|
1089
1152
|
}
|
|
1090
1153
|
});
|
|
1154
|
+
// The `tools/call` handler: ours, so the argument shape is checked by
|
|
1155
|
+
// `validateArguments` (spec/surface §4 "Arguments") and a failure is the
|
|
1156
|
+
// engine's `filter_invalid` envelope — naming the expected shape and, for an
|
|
1157
|
+
// unknown name, the nearest valid one — rather than the SDK's validation
|
|
1158
|
+
// text. Listing (`tools/list`) stays the SDK's, from the same registrations.
|
|
1159
|
+
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1160
|
+
const name = request.params.name;
|
|
1161
|
+
const entry = registry.get(name);
|
|
1162
|
+
if (!entry)
|
|
1163
|
+
throw new McpError(ErrorCode.InvalidParams, `Tool ${name} not found`);
|
|
1164
|
+
let args;
|
|
1165
|
+
try {
|
|
1166
|
+
args = validateArguments(name, entry.shape, request.params.arguments);
|
|
1167
|
+
}
|
|
1168
|
+
catch (e) {
|
|
1169
|
+
return fail(e);
|
|
1170
|
+
}
|
|
1171
|
+
return entry.handler(args);
|
|
1172
|
+
});
|
|
1091
1173
|
return server;
|
|
1092
1174
|
}
|
|
1175
|
+
/** The catalog's tool names in registration order with their descriptions —
|
|
1176
|
+
* what `spec/surface/tool-descriptions.json` pins for both engines. */
|
|
1177
|
+
export async function toolDescriptions(server) {
|
|
1178
|
+
const tools = server._registeredTools;
|
|
1179
|
+
const out = {};
|
|
1180
|
+
for (const [name, t] of Object.entries(tools))
|
|
1181
|
+
out[name] = t.description ?? "";
|
|
1182
|
+
return out;
|
|
1183
|
+
}
|
|
1093
1184
|
//# sourceMappingURL=server.js.map
|