@omgbase/core 0.15.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/ingest.d.ts +2 -0
- package/dist/src/core/ingest.js +13 -0
- package/dist/src/core/read/document.d.ts +9 -8
- package/dist/src/core/read/document.js +0 -2
- package/dist/src/core/store/gc.d.ts +13 -1
- package/dist/src/core/store/gc.js +24 -5
- package/dist/src/core/store/size.d.ts +79 -0
- package/dist/src/core/store/size.js +232 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +5 -1
- package/dist/src/mcp/arguments.d.ts +10 -2
- package/dist/src/mcp/arguments.js +37 -3
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +63 -10
- package/dist/src/mcp/server.d.ts +34 -1
- package/dist/src/mcp/server.js +158 -46
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- package/dist/src/mutate/effects.d.ts +58 -8
- package/dist/src/mutate/effects.js +312 -43
- package/dist/src/mutate/known-ids.d.ts +19 -0
- package/dist/src/mutate/known-ids.js +47 -7
- package/dist/src/mutate/ops.d.ts +1 -0
- package/dist/src/mutate/ops.js +20 -1
- package/dist/src/mutate/plan.d.ts +26 -16
- package/dist/src/mutate/plan.js +152 -112
- package/dist/src/mutate/select.d.ts +78 -0
- package/dist/src/mutate/select.js +903 -0
- package/dist/src/mutate/tree.d.ts +2 -2
- package/dist/src/oqx-js/context.d.ts +9 -2
- package/dist/src/oqx-js/context.js +74 -28
- package/dist/src/oqx-js/names.d.ts +40 -0
- package/dist/src/oqx-js/names.js +347 -0
- package/dist/src/oqx-js/planner.d.ts +3 -3
- package/dist/src/oqx-js/planner.js +17 -9
- package/dist/src/oqx-js/root-row.d.ts +15 -4
- package/dist/src/oqx-js/root-row.js +18 -10
- package/dist/src/oqx-js/run.d.ts +45 -1
- package/dist/src/oqx-js/run.js +114 -32
- package/dist/src/oqx-js/sql/translate.js +1 -1
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +4 -4
- package/dist/src/sync/observe.js +9 -3
- package/dist/src/version.d.ts +7 -2
- package/dist/src/version.js +5 -2
- package/package.json +2 -2
package/dist/src/mcp/server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { CallToolRequestSchema, ErrorCode
|
|
2
|
+
import { CallToolRequestSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { docsOutline } from "../core/read/outline.js";
|
|
5
5
|
import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
|
|
6
6
|
import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
|
|
7
|
-
import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT
|
|
7
|
+
import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT } from "../core/read/reader.js";
|
|
8
8
|
import { resolveRef } from "../core/read/refs.js";
|
|
9
9
|
import { VERSION } from "../core/index.js";
|
|
10
10
|
import { normalizeText, normalizeVisibleText } from "../core/hash.js";
|
|
@@ -12,11 +12,12 @@ 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
|
+
import { argumentError, stringList, validateArguments } from "./arguments.js";
|
|
16
16
|
import { linksRetarget, linksRepair } from "../mutate/macros.js";
|
|
17
17
|
import { canonicalDocPath } from "../mutate/docs.js";
|
|
18
18
|
import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
|
|
19
19
|
import { appendEffects, applySugar, checkEffects, createEffects, deleteDocEffects, docsAppendEffects, insertEffects, mergeEffects, moveBlocksEffects, moveDocEffects, moveLinks, nodeSetEffects, removeBlocksEffects, requireAbsentPath, retargetEffects, setMetaEffects, splitEffects, updateBlockEffects, updateContentEffects, } from "../mutate/sugar.js";
|
|
20
|
+
import { fixTool } from "../mutate/canonical.js";
|
|
20
21
|
import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
|
|
21
22
|
import { linksStale, linksStaleSummary } from "../graph/link-health.js";
|
|
22
23
|
import { resolve as resolveThing } from "../search/resolve.js";
|
|
@@ -28,6 +29,29 @@ import { versionInfo } from "../version.js";
|
|
|
28
29
|
import { storagePath, referencePath } from "../core/paths.js";
|
|
29
30
|
import { isContainerBlock } from "../format/index.js";
|
|
30
31
|
import { surfaceInboundLink, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfacePlan, surfaceReceipt, } from "../surface-paths.js";
|
|
32
|
+
export const TOOLS_PROFILES = ["full", "minimal"];
|
|
33
|
+
/**
|
|
34
|
+
* The `minimal` profile (spec/surface §4 "Profiles", 4.3): the ten tools an
|
|
35
|
+
* agent works a knowledge base with — `query` for every read it covers,
|
|
36
|
+
* `query_syntax` (the grammar the ladders point at), `docs_read` (whole
|
|
37
|
+
* document, `include_ids` for ids/hashes/parents), `docs_tree` (orientation),
|
|
38
|
+
* `blocks_resolve` (the one ranked hybrid lookup), `changes_since`
|
|
39
|
+
* (re-orientation), `plan` / `execute` / `apply` (the one writer and its
|
|
40
|
+
* preview) and `version` (which reports the profile). The same list as the Rust
|
|
41
|
+
* catalog's `MINIMAL_TOOLS` (`crates/omgbase-surface/src/catalog.rs`).
|
|
42
|
+
*/
|
|
43
|
+
export const MINIMAL_TOOLS = [
|
|
44
|
+
"query", "query_syntax", "docs_read", "docs_tree", "blocks_resolve", "changes_since", "plan", "execute", "apply", "version",
|
|
45
|
+
];
|
|
46
|
+
/** Each profile's membership test: `full` keeps every tool, `minimal` the ten of `MINIMAL_TOOLS`. */
|
|
47
|
+
export const PROFILES = {
|
|
48
|
+
full: () => true,
|
|
49
|
+
minimal: (tool) => MINIMAL_TOOLS.includes(tool),
|
|
50
|
+
};
|
|
51
|
+
/** Parse a `--tools` value / the `mcp.tools` setting; null for anything but the two names. */
|
|
52
|
+
export function parseToolsProfile(value) {
|
|
53
|
+
return value === "full" || value === "minimal" ? value : null;
|
|
54
|
+
}
|
|
31
55
|
// The placement and guard schemas the block-level sugar shares (spec/mutate
|
|
32
56
|
// §1.1/§1.2; the effect vocabulary spells them the same way). blocks_insert /
|
|
33
57
|
// blocks_move carry the op-level expectation of §1.2: only
|
|
@@ -54,7 +78,31 @@ const expectSchema = z.object({
|
|
|
54
78
|
// checks every key, target kind and field spelling and reports the failure as
|
|
55
79
|
// the same `filter_invalid { hint: "arguments" }` envelope, naming `effects[i]`
|
|
56
80
|
// and the offending key — a message both engines compose from the spec.
|
|
57
|
-
|
|
81
|
+
/** `expect: { max }` of a query-directed request (spec/mutate §11.8, 4.1): the bound on the selection. */
|
|
82
|
+
const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
|
|
83
|
+
/** The selector half of `plan` / `apply` / `execute` arguments, as given (absent keys stay absent so the planner tells "no expect" apart). */
|
|
84
|
+
function selectorArgs(args) {
|
|
85
|
+
return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
|
|
86
|
+
}
|
|
87
|
+
const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target: document, field: \"frontmatter.<key>\", expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }. Target: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"attrs.checked\"; a document's \"frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
|
|
88
|
+
// An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
|
|
89
|
+
// each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
|
|
90
|
+
// a rung is a line beginning `{` with the call's arguments as JSON, three
|
|
91
|
+
// spaces, a comment; a line holding `…` or `<…>` is a schema line — and a last
|
|
92
|
+
// line pointing at `query_syntax`. The rungs are extracted mechanically
|
|
93
|
+
// (`mcp/examples.test.ts`) into `spec/surface/cases/examples.json` and run
|
|
94
|
+
// against the alchemy corpus, so every example stays true. Ids and paths are
|
|
95
|
+
// the alchemy fixture's (`d_14` is substances/salt.md, `b_153` the `Tasks`
|
|
96
|
+
// heading of processes/calcination.md). Since 4.4 the rungs are written in
|
|
97
|
+
// the `spec/mutate` 3.2 sugar — a bare path, id or `$new.n` as a target, the
|
|
98
|
+
// `-> expr` leader for an expression — and each trailing comment teaches one
|
|
99
|
+
// thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
|
|
100
|
+
// `mcp/examples.test.ts`).
|
|
101
|
+
const ladder = (...lines) => lines.join("\n");
|
|
102
|
+
// The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
|
|
103
|
+
// the spec minter gives `/processes/calcination.md`'s ingest revision over the
|
|
104
|
+
// alchemy corpus (`examples.json` runs the rung before any write).
|
|
105
|
+
const REVISION_RUNG = "{\"path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: from revisions where path == \"/processes/calcination.md\")";
|
|
58
106
|
function ok(payload) {
|
|
59
107
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
60
108
|
}
|
|
@@ -100,8 +148,18 @@ export function buildServer(ctx) {
|
|
|
100
148
|
// `WriterLockTimeout`, rendered through `fail` like every other failure — a
|
|
101
149
|
// plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
|
|
102
150
|
// (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
|
|
151
|
+
//
|
|
152
|
+
// The profile (`ctx.tools`, spec/surface §4 "Profiles") decides which
|
|
153
|
+
// registrations happen at all: a tool outside it is neither listed nor
|
|
154
|
+
// callable — `tools/call` on its name is the same `unknown tool <name>` a
|
|
155
|
+
// name that never existed gets — and a tool inside it is registered exactly
|
|
156
|
+
// as under `full` (a profile filters listing and calling, never semantics).
|
|
157
|
+
const profile = ctx.tools ?? "full";
|
|
158
|
+
const inProfile = PROFILES[profile];
|
|
103
159
|
const registry = new Map();
|
|
104
160
|
function tool(name, def, handler) {
|
|
161
|
+
if (!inProfile(name))
|
|
162
|
+
return;
|
|
105
163
|
const lockDir = ctx.omgbaseDir;
|
|
106
164
|
const run = WRITE_TOOLS.has(name) && lockDir !== undefined
|
|
107
165
|
? async (args) => {
|
|
@@ -182,6 +240,15 @@ export function buildServer(ctx) {
|
|
|
182
240
|
}
|
|
183
241
|
return (byId ?? byPath).docId;
|
|
184
242
|
}
|
|
243
|
+
// `docs_read { at }` and its sugar `docs_read_at` (spec/surface §2, 4.7): the
|
|
244
|
+
// document AS OF a revision, `spec/store` §6.2 reconstruct; a revision that is
|
|
245
|
+
// not the document's is `target_missing`.
|
|
246
|
+
function docsReadAt(docId, rev) {
|
|
247
|
+
const res = readDocumentAtRevision(store, docId, rev);
|
|
248
|
+
if (!res)
|
|
249
|
+
throw new EngineError("target_missing", `no revision ${JSON.stringify(rev)} for document ${docId}`, { data: { doc: docId, rev } });
|
|
250
|
+
return res;
|
|
251
|
+
}
|
|
185
252
|
// The owning document of a block id (`blocks_read`): the block must be live.
|
|
186
253
|
function docOfBlock(blockId) {
|
|
187
254
|
const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
|
|
@@ -211,7 +278,12 @@ export function buildServer(ctx) {
|
|
|
211
278
|
// several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
|
|
212
279
|
// the heading's id to `blocks_append` instead). Never an id lookup — the
|
|
213
280
|
// id form is `blocks_append`'s (§9).
|
|
214
|
-
|
|
281
|
+
//
|
|
282
|
+
// Each miss carries a `fix` (spec/surface §9, 4.4): a literal corrected call.
|
|
283
|
+
// Several matches → `blocks_append` with the first candidate's id; none → the
|
|
284
|
+
// one heading whose text matches up to ASCII case, as `blocks_append`, else
|
|
285
|
+
// the same `docs_append` without `section` (the document's end).
|
|
286
|
+
function resolveSectionHeading(docId, docPath, section, call) {
|
|
215
287
|
const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
|
|
216
288
|
const rows = store.db
|
|
217
289
|
.prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
|
|
@@ -219,11 +291,16 @@ export function buildServer(ctx) {
|
|
|
219
291
|
const matches = rows.filter((r) => normalizeText(r.text) === needle);
|
|
220
292
|
const shown = referencePath(docPath);
|
|
221
293
|
if (matches.length === 0) {
|
|
222
|
-
|
|
294
|
+
const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
295
|
+
const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
|
|
296
|
+
const fix = near.length === 1
|
|
297
|
+
? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
|
|
298
|
+
: fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
|
|
299
|
+
throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
|
|
223
300
|
}
|
|
224
301
|
if (matches.length > 1) {
|
|
225
302
|
throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(section)} matches ${matches.length} headings in ${shown}; pass the heading's id to blocks_append`, {
|
|
226
|
-
data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })) },
|
|
303
|
+
data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })), fix: fixTool("blocks_append", { id: matches[0].block_id, text: call.text }) },
|
|
227
304
|
});
|
|
228
305
|
}
|
|
229
306
|
return matches[0].block_id;
|
|
@@ -351,16 +428,26 @@ export function buildServer(ctx) {
|
|
|
351
428
|
}
|
|
352
429
|
});
|
|
353
430
|
tool("docs_read", {
|
|
354
|
-
description: "
|
|
431
|
+
description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
|
|
355
432
|
inputSchema: {
|
|
356
433
|
...DOC_ARGS,
|
|
357
434
|
include_ids: z.boolean().optional(),
|
|
435
|
+
at: z.string().optional(),
|
|
358
436
|
...REPO_ARG,
|
|
359
437
|
},
|
|
360
438
|
}, async (args) => {
|
|
361
439
|
try {
|
|
362
440
|
const { repoId } = repoScope(args.repo);
|
|
363
|
-
const
|
|
441
|
+
const shape = registry.get("docs_read").shape;
|
|
442
|
+
const docId = locateDoc(repoId, "docs_read", shape, args);
|
|
443
|
+
if (args.at !== undefined) {
|
|
444
|
+
// `at` (4.7): the document AS OF that revision — `docs_read_at`'s
|
|
445
|
+
// code path, so the two return the same bytes. Block ids at a past
|
|
446
|
+
// revision are a later question: `include_ids` is refused with `at`.
|
|
447
|
+
if (args.include_ids)
|
|
448
|
+
throw argumentError("docs_read", "`include_ids` is not available with `at`", shape);
|
|
449
|
+
return ok(docsReadAt(docId, args.at));
|
|
450
|
+
}
|
|
364
451
|
const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
|
|
365
452
|
if (!res)
|
|
366
453
|
throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
|
|
@@ -373,7 +460,7 @@ export function buildServer(ctx) {
|
|
|
373
460
|
tool("docs_read_many", {
|
|
374
461
|
description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`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).",
|
|
375
462
|
inputSchema: {
|
|
376
|
-
docs:
|
|
463
|
+
docs: stringList(),
|
|
377
464
|
include_ids: z.boolean().optional(),
|
|
378
465
|
budget_tokens: z.number().int().optional(),
|
|
379
466
|
...REPO_ARG,
|
|
@@ -438,7 +525,7 @@ export function buildServer(ctx) {
|
|
|
438
525
|
tool("blocks_read_many", {
|
|
439
526
|
description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `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).",
|
|
440
527
|
inputSchema: {
|
|
441
|
-
ids:
|
|
528
|
+
ids: stringList(),
|
|
442
529
|
resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
|
|
443
530
|
budget_tokens: z.number().int().optional(),
|
|
444
531
|
...REPO_ARG,
|
|
@@ -457,7 +544,7 @@ export function buildServer(ctx) {
|
|
|
457
544
|
}
|
|
458
545
|
});
|
|
459
546
|
tool("docs_tree", {
|
|
460
|
-
description: "
|
|
547
|
+
description: ladder("docs_tree — Orientation: the shape of a repo in one small call, like tree -L depth with document and block counts per entry.", "{} the root at depth 1: one entry per directory (path ends in /) or shallow document, plus total", "{\"path\":\"/processes/\",\"depth\":1} descend into a directory entry", "{\"depth\":2,\"limit\":5} collapse deeper; limit/cursor page the entries, total always covers everything under path", "Ranked or attribute-based discovery: blocks_resolve, or query (select path from docs where ...). Grammar: query_syntax."),
|
|
461
548
|
inputSchema: {
|
|
462
549
|
path: z.string().optional(),
|
|
463
550
|
depth: z.number().int().optional(),
|
|
@@ -505,15 +592,16 @@ export function buildServer(ctx) {
|
|
|
505
592
|
}
|
|
506
593
|
});
|
|
507
594
|
tool("query_syntax", {
|
|
508
|
-
description: "
|
|
595
|
+
description: ladder("query_syntax — The full OQX reference: clause order, targets and fields, scalar semantics, nested queries and the root row, follow, paging, worked examples.", "{} no arguments; returns { syntax }", "Call it before writing a non-trivial query; every other tool's examples end here, at query_syntax."),
|
|
509
596
|
inputSchema: {},
|
|
510
597
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
511
598
|
tool("query", {
|
|
512
|
-
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`).",
|
|
599
|
+
description: ladder("query — rows (id, path, projection) from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where \\\"process\\\" in tags order by path limit 5\"} frontmatter; membership; order, limit", "{\"query\":\"select type, text from blocks where path == \\\"/processes/calcination.md\\\"\"} a document's blocks in order", "{\"query\":\"select block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id", "{\"query\":\"select path, value from nodes where kind == \\\"md:task\\\" and not checked\"} open tasks", "{\"query\":\"select path, block_id from links where dst_path == \\\"/processes/calcination.md\\\"\"} inbound links (path = source)", "{\"query\":\"select path from docs where title ~* \\\"salt\\\"\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
|
|
513
600
|
inputSchema: {
|
|
514
601
|
query: z.string(),
|
|
515
602
|
limit: z.number().int().optional(),
|
|
516
603
|
cursor: z.string().nullable().optional(),
|
|
604
|
+
bare: z.boolean().optional(),
|
|
517
605
|
...REPO_ARG,
|
|
518
606
|
},
|
|
519
607
|
}, async (args) => {
|
|
@@ -526,6 +614,7 @@ export function buildServer(ctx) {
|
|
|
526
614
|
return ok(await oqxRunAsync(store, repoId, args.query, {
|
|
527
615
|
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
528
616
|
...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
|
|
617
|
+
...(args.bare !== undefined ? { bare: args.bare } : {}),
|
|
529
618
|
}, rankableEmbed(repoId)));
|
|
530
619
|
}
|
|
531
620
|
catch (e) {
|
|
@@ -535,11 +624,11 @@ export function buildServer(ctx) {
|
|
|
535
624
|
tool("graph", {
|
|
536
625
|
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).",
|
|
537
626
|
inputSchema: {
|
|
538
|
-
roots:
|
|
627
|
+
roots: stringList({ min: 1 }),
|
|
539
628
|
degrees: z.number().int().optional(),
|
|
540
629
|
direction: z.enum(["in", "out", "both"]).optional(),
|
|
541
630
|
predicate: z.string().optional(),
|
|
542
|
-
select:
|
|
631
|
+
select: stringList().optional(),
|
|
543
632
|
max_documents: z.number().int().optional(),
|
|
544
633
|
...REPO_ARG,
|
|
545
634
|
},
|
|
@@ -569,7 +658,7 @@ export function buildServer(ctx) {
|
|
|
569
658
|
}
|
|
570
659
|
});
|
|
571
660
|
tool("blocks_resolve", {
|
|
572
|
-
description: "
|
|
661
|
+
description: ladder("blocks_resolve — \"the id of the thing I mean\": a title or term, ranked to blocks { id, locator, preview, evidence }.", "{\"query\":\"calcination\"} best on short, entity-shaped input", "{\"query\":\"Emerald Tablet\",\"limit\":3} use the returned id in the next call (docs_read, a plan target)", "Open questions or passages: query with text(\"...\") or order by semantic(\"...\") desc. Grammar: query_syntax."),
|
|
573
662
|
inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
574
663
|
}, async (args) => {
|
|
575
664
|
try {
|
|
@@ -586,9 +675,11 @@ export function buildServer(ctx) {
|
|
|
586
675
|
}
|
|
587
676
|
});
|
|
588
677
|
tool("apply", {
|
|
589
|
-
description: "
|
|
678
|
+
description: ladder("apply — plan and commit in one call; returns the Receipt { plan_id, commit, revisions, new_ids, committed, summary }.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/substances/salt.md\",\"field\":\"frontmatter.verified\",\"value\":false}],\"reason\":\"re-check salt\"} one scalar; a bare path is a document target", "{\"effects\":[{\"op\":\"insert\",\"into\":\"/lab/2026-02-notes.md\",\"at\":\"end\",\"markdown\":\"## Follow-ups\\n\\n- [ ] re-run cycle 4\"}]} append at a document's end", "{\"select\":\"from blocks where type == \\\"task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"attrs.checked\",\"value\":true}],\"expect\":{\"max\":10}} bulk check-off; expect.max is REQUIRED with select", "Plan first for anything you cannot undo by hand; every error's data.fix is a corrected call to copy. Grammar: query_syntax."),
|
|
590
679
|
inputSchema: {
|
|
591
680
|
effects: z.array(effectSchema),
|
|
681
|
+
select: z.string().optional(),
|
|
682
|
+
expect: selectionExpectSchema.optional(),
|
|
592
683
|
reason: z.string().optional(),
|
|
593
684
|
...REPO_ARG,
|
|
594
685
|
},
|
|
@@ -599,7 +690,7 @@ export function buildServer(ctx) {
|
|
|
599
690
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
600
691
|
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
601
692
|
// Plan + execute under one lock and one transaction → the Receipt (spec/mutate §11.7).
|
|
602
|
-
return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, origin, ...LOCK_DIR })));
|
|
693
|
+
return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR })));
|
|
603
694
|
}
|
|
604
695
|
catch (e) {
|
|
605
696
|
return fail(e);
|
|
@@ -610,22 +701,29 @@ export function buildServer(ctx) {
|
|
|
610
701
|
// commits exactly a plan (the plan form, or the digest form `plan_id` +
|
|
611
702
|
// `effects`); `apply { effects }` above is plan + execute in one call.
|
|
612
703
|
tool("plan", {
|
|
613
|
-
description: "
|
|
614
|
-
inputSchema: { effects: z.array(effectSchema), ...REPO_ARG },
|
|
704
|
+
description: ladder("plan — Preview a write: the Plan (summary, $new.n ids) burns no id; execute next. diffs: true for file bodies.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target: the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget; grammar: query_syntax."),
|
|
705
|
+
inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
|
|
615
706
|
}, async (args) => {
|
|
616
707
|
try {
|
|
617
708
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
618
709
|
if (!rootPath)
|
|
619
710
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
620
|
-
|
|
711
|
+
const plan = surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR }));
|
|
712
|
+
// (4.5) `preview.diffs` — whole before/after bodies of every touched
|
|
713
|
+
// document — only on request: `diffs: true`. The digest is over the
|
|
714
|
+
// core, never the preview, so `plan_id` is the same either way.
|
|
715
|
+
if (args.diffs === true || plan.preview === undefined)
|
|
716
|
+
return ok(plan);
|
|
717
|
+
const { diffs: _diffs, ...preview } = plan.preview;
|
|
718
|
+
return ok({ ...plan, preview });
|
|
621
719
|
}
|
|
622
720
|
catch (e) {
|
|
623
721
|
return fail(e);
|
|
624
722
|
}
|
|
625
723
|
});
|
|
626
724
|
tool("execute", {
|
|
627
|
-
description: "Commit exactly a plan
|
|
628
|
-
inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), reason: z.string().optional(), ...REPO_ARG },
|
|
725
|
+
description: ladder("execute — Commit exactly a plan from plan: the plan form (the whole Plan object) or the digest form (plan_id plus the same effects, re-planned and committed only if the digest still matches).", "{\"plan_id\":\"3ece126452106400210f07047029a961691b814252ba4437f5a5f1c3301629da\",\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} digest form: the plan_id plan returned for these effects", "Or pass the whole object: {\"plan\": the Plan as returned}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
|
|
726
|
+
inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), select: z.string().optional(), expect: selectionExpectSchema.optional(), reason: z.string().optional(), ...REPO_ARG },
|
|
629
727
|
}, async (args) => {
|
|
630
728
|
try {
|
|
631
729
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -633,7 +731,8 @@ export function buildServer(ctx) {
|
|
|
633
731
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
634
732
|
const shape = registry.get("execute").shape;
|
|
635
733
|
const planForm = args.plan !== undefined;
|
|
636
|
-
const
|
|
734
|
+
const selector = args.select !== undefined || args.expect !== undefined;
|
|
735
|
+
const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
|
|
637
736
|
if (planForm === digestForm)
|
|
638
737
|
throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
|
|
639
738
|
if (digestForm && (args.plan_id === undefined || args.effects === undefined))
|
|
@@ -641,7 +740,7 @@ export function buildServer(ctx) {
|
|
|
641
740
|
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
642
741
|
const receipt = planForm
|
|
643
742
|
? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
|
|
644
|
-
: executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, origin, ...LOCK_DIR });
|
|
743
|
+
: executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
|
|
645
744
|
return okMutated(surfaceReceipt(receipt));
|
|
646
745
|
}
|
|
647
746
|
catch (e) {
|
|
@@ -693,7 +792,7 @@ export function buildServer(ctx) {
|
|
|
693
792
|
});
|
|
694
793
|
tool("blocks_move", {
|
|
695
794
|
description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
|
|
696
|
-
inputSchema: { blocks:
|
|
795
|
+
inputSchema: { blocks: stringList(), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
|
|
697
796
|
}, async (args) => {
|
|
698
797
|
try {
|
|
699
798
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -714,7 +813,7 @@ export function buildServer(ctx) {
|
|
|
714
813
|
});
|
|
715
814
|
tool("blocks_remove", {
|
|
716
815
|
description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
|
|
717
|
-
inputSchema: { blocks:
|
|
816
|
+
inputSchema: { blocks: stringList(), ...REPO_ARG },
|
|
718
817
|
}, async (args) => {
|
|
719
818
|
try {
|
|
720
819
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -744,7 +843,7 @@ export function buildServer(ctx) {
|
|
|
744
843
|
});
|
|
745
844
|
tool("blocks_merge", {
|
|
746
845
|
description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
|
|
747
|
-
inputSchema: { blocks:
|
|
846
|
+
inputSchema: { blocks: stringList(), separator: z.string().optional(), ...REPO_ARG },
|
|
748
847
|
}, async (args) => {
|
|
749
848
|
try {
|
|
750
849
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -759,7 +858,7 @@ export function buildServer(ctx) {
|
|
|
759
858
|
});
|
|
760
859
|
tool("blocks_check", {
|
|
761
860
|
description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
|
|
762
|
-
inputSchema: { ids:
|
|
861
|
+
inputSchema: { ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
|
|
763
862
|
}, async (args) => {
|
|
764
863
|
try {
|
|
765
864
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -799,7 +898,7 @@ export function buildServer(ctx) {
|
|
|
799
898
|
const root = requireRoot(rootPath);
|
|
800
899
|
const block = resolveBlockRef(repoId, args.id);
|
|
801
900
|
const row = store.db
|
|
802
|
-
.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")
|
|
901
|
+
.prepare("SELECT b.type AS type, d.format AS format, b.parent_block AS parent, d.path AS path, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
|
|
803
902
|
.get(block);
|
|
804
903
|
if (!row)
|
|
805
904
|
throw blockMissing(args.id);
|
|
@@ -811,8 +910,9 @@ export function buildServer(ctx) {
|
|
|
811
910
|
into = blockTarget(block);
|
|
812
911
|
}
|
|
813
912
|
else {
|
|
913
|
+
// The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
|
|
814
914
|
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)} } }`, {
|
|
815
|
-
data: { id: block, type: row.type },
|
|
915
|
+
data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
|
|
816
916
|
});
|
|
817
917
|
}
|
|
818
918
|
return sugar(repoId, root, appendEffects(into, args.text));
|
|
@@ -836,7 +936,7 @@ export function buildServer(ctx) {
|
|
|
836
936
|
let into;
|
|
837
937
|
if (args.section !== undefined) {
|
|
838
938
|
const info = findDoc(store, { docId });
|
|
839
|
-
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section) };
|
|
939
|
+
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section, { ...(args.id !== undefined ? { id: args.id } : {}), ...(args.path !== undefined ? { path: args.path } : {}), text: args.text }) };
|
|
840
940
|
}
|
|
841
941
|
else {
|
|
842
942
|
into = { kind: "document", id: docId };
|
|
@@ -986,7 +1086,7 @@ export function buildServer(ctx) {
|
|
|
986
1086
|
});
|
|
987
1087
|
tool("docs_set_meta", {
|
|
988
1088
|
description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
|
|
989
|
-
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset:
|
|
1089
|
+
inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
|
|
990
1090
|
}, async (args) => {
|
|
991
1091
|
try {
|
|
992
1092
|
const scope = repoScope(args.repo);
|
|
@@ -1057,7 +1157,7 @@ export function buildServer(ctx) {
|
|
|
1057
1157
|
}
|
|
1058
1158
|
});
|
|
1059
1159
|
tool("blocks_history", {
|
|
1060
|
-
description: "A block's biography: the commits that
|
|
1160
|
+
description: "A block's biography: the commits that CHANGED the block `id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
|
|
1061
1161
|
inputSchema: { id: z.string(), limit: z.number().int().optional() },
|
|
1062
1162
|
}, async (args) => {
|
|
1063
1163
|
try {
|
|
@@ -1102,16 +1202,13 @@ export function buildServer(ctx) {
|
|
|
1102
1202
|
}
|
|
1103
1203
|
});
|
|
1104
1204
|
tool("docs_read_at", {
|
|
1105
|
-
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
|
|
1205
|
+
description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Sugar for `docs_read { id | path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`from revisions where path == … order by seq`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `id` or `path` plus a `rev` id.",
|
|
1106
1206
|
inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
|
|
1107
1207
|
}, async (args) => {
|
|
1108
1208
|
try {
|
|
1109
1209
|
const { repoId } = repoScope(args.repo);
|
|
1110
1210
|
const docId = locateDoc(repoId, "docs_read_at", registry.get("docs_read_at").shape, args);
|
|
1111
|
-
|
|
1112
|
-
if (!res)
|
|
1113
|
-
throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
|
|
1114
|
-
return ok(res);
|
|
1211
|
+
return ok(docsReadAt(docId, args.rev));
|
|
1115
1212
|
}
|
|
1116
1213
|
catch (e) {
|
|
1117
1214
|
return fail(e);
|
|
@@ -1155,7 +1252,7 @@ export function buildServer(ctx) {
|
|
|
1155
1252
|
}
|
|
1156
1253
|
});
|
|
1157
1254
|
tool("changes_since", {
|
|
1158
|
-
description: "
|
|
1255
|
+
description: ladder("changes_since — Re-orient after time away: the commit feed after a cursor, each digest with its revisions [{ id, path, hash }].", "{} everything from the start; head is the current max seq", "{\"cursor\":20,\"limit\":5} commits after seq 20, five at a time (cursor pages while truncated)", "{\"origin\":\"api\"} only the writes made through the engine (observed = edits on disk; import)", "Poll with your last cursor: an empty page with cursor == head means nothing new. Read a changed document with docs_read, its blocks with query (grammar: query_syntax)."),
|
|
1159
1256
|
inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
1160
1257
|
}, async (args) => {
|
|
1161
1258
|
try {
|
|
@@ -1217,12 +1314,11 @@ export function buildServer(ctx) {
|
|
|
1217
1314
|
// Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
|
|
1218
1315
|
// answer is about the binary and its database, not a repo.
|
|
1219
1316
|
tool("version", {
|
|
1220
|
-
description: "Which engine and which versions you are talking to
|
|
1221
|
-
"`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.",
|
|
1317
|
+
description: ladder("version — Which engine and which versions you are talking to, and which tools profile it serves.", "{} engine, version, components, specs, schema, mcp, runtime, commit, built and profile (\"full\" or \"minimal\")", "Under the minimal profile ten tools are listed; calling an unlisted one is the JSON-RPC error unknown tool. Reads go through query (grammar: query_syntax)."),
|
|
1222
1318
|
inputSchema: {},
|
|
1223
1319
|
}, async () => {
|
|
1224
1320
|
try {
|
|
1225
|
-
return ok(versionInfo(store, ctx.host));
|
|
1321
|
+
return ok(versionInfo(store, ctx.host, profile));
|
|
1226
1322
|
}
|
|
1227
1323
|
catch (e) {
|
|
1228
1324
|
return fail(e);
|
|
@@ -1236,8 +1332,14 @@ export function buildServer(ctx) {
|
|
|
1236
1332
|
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1237
1333
|
const name = request.params.name;
|
|
1238
1334
|
const entry = registry.get(name);
|
|
1335
|
+
// An unregistered name — unknown, or hidden by the profile — is the JSON-RPC
|
|
1336
|
+
// `-32602` error with exactly the text both engines pin (spec/surface §4
|
|
1337
|
+
// "Profiles"): `unknown tool <name>`. The SDK copies a thrown error's
|
|
1338
|
+
// integer `code` and its `message` onto the wire verbatim; `McpError`
|
|
1339
|
+
// would prefix the message ("MCP error -32602: …"), so a plain Error
|
|
1340
|
+
// carries the code instead.
|
|
1239
1341
|
if (!entry)
|
|
1240
|
-
throw
|
|
1342
|
+
throw unknownTool(name);
|
|
1241
1343
|
let args;
|
|
1242
1344
|
try {
|
|
1243
1345
|
args = validateArguments(name, entry.shape, request.params.arguments);
|
|
@@ -1249,8 +1351,14 @@ export function buildServer(ctx) {
|
|
|
1249
1351
|
});
|
|
1250
1352
|
return server;
|
|
1251
1353
|
}
|
|
1354
|
+
/** The pinned text of a `tools/call` on a name the server does not serve (spec/surface §4 "Profiles"). */
|
|
1355
|
+
export const UNKNOWN_TOOL_CODE = ErrorCode.InvalidParams; // -32602
|
|
1356
|
+
export function unknownTool(name) {
|
|
1357
|
+
return Object.assign(new Error(`unknown tool ${name}`), { code: UNKNOWN_TOOL_CODE });
|
|
1358
|
+
}
|
|
1252
1359
|
/** The catalog's tool names in registration order with their descriptions —
|
|
1253
|
-
* what `spec/surface/tool-descriptions.json` pins for both engines
|
|
1360
|
+
* what `spec/surface/tool-descriptions.json` pins for both engines (over a
|
|
1361
|
+
* `full` server; a profiled server lists its subset in the same order). */
|
|
1254
1362
|
export async function toolDescriptions(server) {
|
|
1255
1363
|
const tools = server._registeredTools;
|
|
1256
1364
|
const out = {};
|
|
@@ -1258,4 +1366,8 @@ export async function toolDescriptions(server) {
|
|
|
1258
1366
|
out[name] = t.description ?? "";
|
|
1259
1367
|
return out;
|
|
1260
1368
|
}
|
|
1369
|
+
/** The tool names a server lists, in registration order (`spec/surface/cases/profiles.json` pins them per profile). */
|
|
1370
|
+
export async function toolNames(server) {
|
|
1371
|
+
return Object.keys(await toolDescriptions(server));
|
|
1372
|
+
}
|
|
1261
1373
|
//# sourceMappingURL=server.js.map
|