@omgbase/core 0.15.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/store/size.d.ts +76 -0
- package/dist/src/core/store/size.js +223 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +5 -1
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +27 -6
- package/dist/src/mcp/server.d.ts +34 -1
- package/dist/src/mcp/server.js +113 -28
- package/dist/src/mutate/canonical.d.ts +27 -0
- package/dist/src/mutate/canonical.js +138 -0
- package/dist/src/mutate/effects.d.ts +35 -3
- package/dist/src/mutate/effects.js +202 -22
- package/dist/src/mutate/plan.d.ts +26 -16
- package/dist/src/mutate/plan.js +152 -112
- package/dist/src/mutate/select.d.ts +89 -0
- package/dist/src/mutate/select.js +879 -0
- package/dist/src/mutate/tree.d.ts +2 -2
- package/dist/src/oqx-js/planner.js +4 -0
- package/dist/src/oqx-js/run.d.ts +28 -1
- package/dist/src/oqx-js/run.js +85 -22
- package/dist/src/spec-versions.d.ts +5 -5
- package/dist/src/spec-versions.js +4 -4
- 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";
|
|
@@ -17,6 +17,7 @@ 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,27 @@ 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.
|
|
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
|
+
}
|
|
57
87
|
const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; any id may be $new.<n> for an identity an earlier effect creates");
|
|
88
|
+
// An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
|
|
89
|
+
// each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
|
|
90
|
+
// a rung is a line beginning `{` with the call's arguments as JSON, three
|
|
91
|
+
// spaces, a comment; a line holding `…` or `<…>` is a schema line — and a last
|
|
92
|
+
// line pointing at `query_syntax`. The rungs are extracted mechanically
|
|
93
|
+
// (`mcp/examples.test.ts`) into `spec/surface/cases/examples.json` and run
|
|
94
|
+
// against the alchemy corpus, so every example stays true. Ids and paths are
|
|
95
|
+
// the alchemy fixture's (`d_14` is substances/salt.md, `b_153` the `Tasks`
|
|
96
|
+
// heading of processes/calcination.md). Since 4.4 the rungs are written in
|
|
97
|
+
// the `spec/mutate` 3.2 sugar — a bare path, id or `$new.n` as a target, the
|
|
98
|
+
// `-> expr` leader for an expression — and each trailing comment teaches one
|
|
99
|
+
// thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
|
|
100
|
+
// `mcp/examples.test.ts`).
|
|
101
|
+
const ladder = (...lines) => lines.join("\n");
|
|
58
102
|
function ok(payload) {
|
|
59
103
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
60
104
|
}
|
|
@@ -100,8 +144,18 @@ export function buildServer(ctx) {
|
|
|
100
144
|
// `WriterLockTimeout`, rendered through `fail` like every other failure — a
|
|
101
145
|
// plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
|
|
102
146
|
// (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
|
|
147
|
+
//
|
|
148
|
+
// The profile (`ctx.tools`, spec/surface §4 "Profiles") decides which
|
|
149
|
+
// registrations happen at all: a tool outside it is neither listed nor
|
|
150
|
+
// callable — `tools/call` on its name is the same `unknown tool <name>` a
|
|
151
|
+
// name that never existed gets — and a tool inside it is registered exactly
|
|
152
|
+
// as under `full` (a profile filters listing and calling, never semantics).
|
|
153
|
+
const profile = ctx.tools ?? "full";
|
|
154
|
+
const inProfile = PROFILES[profile];
|
|
103
155
|
const registry = new Map();
|
|
104
156
|
function tool(name, def, handler) {
|
|
157
|
+
if (!inProfile(name))
|
|
158
|
+
return;
|
|
105
159
|
const lockDir = ctx.omgbaseDir;
|
|
106
160
|
const run = WRITE_TOOLS.has(name) && lockDir !== undefined
|
|
107
161
|
? async (args) => {
|
|
@@ -211,7 +265,12 @@ export function buildServer(ctx) {
|
|
|
211
265
|
// several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
|
|
212
266
|
// the heading's id to `blocks_append` instead). Never an id lookup — the
|
|
213
267
|
// id form is `blocks_append`'s (§9).
|
|
214
|
-
|
|
268
|
+
//
|
|
269
|
+
// Each miss carries a `fix` (spec/surface §9, 4.4): a literal corrected call.
|
|
270
|
+
// Several matches → `blocks_append` with the first candidate's id; none → the
|
|
271
|
+
// one heading whose text matches up to ASCII case, as `blocks_append`, else
|
|
272
|
+
// the same `docs_append` without `section` (the document's end).
|
|
273
|
+
function resolveSectionHeading(docId, docPath, section, call) {
|
|
215
274
|
const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
|
|
216
275
|
const rows = store.db
|
|
217
276
|
.prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
|
|
@@ -219,11 +278,16 @@ export function buildServer(ctx) {
|
|
|
219
278
|
const matches = rows.filter((r) => normalizeText(r.text) === needle);
|
|
220
279
|
const shown = referencePath(docPath);
|
|
221
280
|
if (matches.length === 0) {
|
|
222
|
-
|
|
281
|
+
const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
282
|
+
const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
|
|
283
|
+
const fix = near.length === 1
|
|
284
|
+
? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
|
|
285
|
+
: fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
|
|
286
|
+
throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
|
|
223
287
|
}
|
|
224
288
|
if (matches.length > 1) {
|
|
225
289
|
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 })) },
|
|
290
|
+
data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })), fix: fixTool("blocks_append", { id: matches[0].block_id, text: call.text }) },
|
|
227
291
|
});
|
|
228
292
|
}
|
|
229
293
|
return matches[0].block_id;
|
|
@@ -351,7 +415,7 @@ export function buildServer(ctx) {
|
|
|
351
415
|
}
|
|
352
416
|
});
|
|
353
417
|
tool("docs_read", {
|
|
354
|
-
description: "
|
|
418
|
+
description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
|
|
355
419
|
inputSchema: {
|
|
356
420
|
...DOC_ARGS,
|
|
357
421
|
include_ids: z.boolean().optional(),
|
|
@@ -457,7 +521,7 @@ export function buildServer(ctx) {
|
|
|
457
521
|
}
|
|
458
522
|
});
|
|
459
523
|
tool("docs_tree", {
|
|
460
|
-
description: "
|
|
524
|
+
description: ladder("docs_tree — Orientation: the shape of a repo in one small call, like tree -L depth with document and block counts per entry.", "{} the root at depth 1: one entry per directory (path ends in /) or shallow document, plus total", "{\"path\":\"/processes/\",\"depth\":1} descend into a directory entry", "{\"depth\":2,\"limit\":5} collapse deeper; limit/cursor page the entries, total always covers everything under path", "Ranked or attribute-based discovery: blocks_resolve, or query (select path from docs where ...). Grammar: query_syntax."),
|
|
461
525
|
inputSchema: {
|
|
462
526
|
path: z.string().optional(),
|
|
463
527
|
depth: z.number().int().optional(),
|
|
@@ -505,15 +569,16 @@ export function buildServer(ctx) {
|
|
|
505
569
|
}
|
|
506
570
|
});
|
|
507
571
|
tool("query_syntax", {
|
|
508
|
-
description: "
|
|
572
|
+
description: ladder("query_syntax — The full OQX reference: clause order, targets and fields, scalar semantics, nested queries and the root row, follow, paging, worked examples.", "{} no arguments; returns { syntax }", "Call it before writing a non-trivial query; every other tool's examples end here, at query_syntax."),
|
|
509
573
|
inputSchema: {},
|
|
510
574
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
511
575
|
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`).",
|
|
576
|
+
description: ladder("query — Read anything: rows with id and path plus your projection, from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where type == \\\"process\\\"\"} frontmatter fields by name", "{\"query\":\"select b: block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id by its text (the heading_id an insert into a section takes)", "{\"query\":\"select value from nodes where kind == \\\"md:task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\"} open tasks in one document (a task node's text is its value)", "{\"query\":\"docs count { where layer == \\\"canon\\\" }\"} a scalar", "{\"query\":\"select path from docs where \\\"process\\\" in tags order by path limit 5\"} list membership, ordering; a truncated page's cursor continues it", "Full grammar, follow, ^docs joins, text()/semantic(): call query_syntax."),
|
|
513
577
|
inputSchema: {
|
|
514
578
|
query: z.string(),
|
|
515
579
|
limit: z.number().int().optional(),
|
|
516
580
|
cursor: z.string().nullable().optional(),
|
|
581
|
+
bare: z.boolean().optional(),
|
|
517
582
|
...REPO_ARG,
|
|
518
583
|
},
|
|
519
584
|
}, async (args) => {
|
|
@@ -526,6 +591,7 @@ export function buildServer(ctx) {
|
|
|
526
591
|
return ok(await oqxRunAsync(store, repoId, args.query, {
|
|
527
592
|
...(args.limit !== undefined ? { limit: args.limit } : {}),
|
|
528
593
|
...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
|
|
594
|
+
...(args.bare !== undefined ? { bare: args.bare } : {}),
|
|
529
595
|
}, rankableEmbed(repoId)));
|
|
530
596
|
}
|
|
531
597
|
catch (e) {
|
|
@@ -569,7 +635,7 @@ export function buildServer(ctx) {
|
|
|
569
635
|
}
|
|
570
636
|
});
|
|
571
637
|
tool("blocks_resolve", {
|
|
572
|
-
description: "
|
|
638
|
+
description: ladder("blocks_resolve — \"the id of the thing I mean\": a title or term, ranked to blocks { id, locator, preview, evidence }.", "{\"query\":\"calcination\"} best on short, entity-shaped input", "{\"query\":\"Emerald Tablet\",\"limit\":3} use the returned id in the next call (docs_read, a plan target)", "Open questions or passages: query with text(\"...\") or order by semantic(\"...\") desc. Grammar: query_syntax."),
|
|
573
639
|
inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
574
640
|
}, async (args) => {
|
|
575
641
|
try {
|
|
@@ -586,9 +652,11 @@ export function buildServer(ctx) {
|
|
|
586
652
|
}
|
|
587
653
|
});
|
|
588
654
|
tool("apply", {
|
|
589
|
-
description: "
|
|
655
|
+
description: ladder("apply — plan and commit in one call; returns the Receipt { plan_id, commit, revisions, new_ids, committed, summary }.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/substances/salt.md\",\"field\":\"frontmatter.verified\",\"value\":false}],\"reason\":\"re-check salt\"} one scalar; a bare path is a document target", "{\"effects\":[{\"op\":\"insert\",\"into\":\"/lab/2026-02-notes.md\",\"at\":\"end\",\"markdown\":\"## Follow-ups\\n\\n- [ ] re-run cycle 4\"}]} append at a document's end", "{\"select\":\"from blocks where type == \\\"task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"attrs.checked\",\"value\":true}],\"expect\":{\"max\":10}} bulk check-off; expect.max is REQUIRED with select", "Plan first for anything you cannot undo by hand; every error's data.fix is a corrected call to copy. Grammar: query_syntax."),
|
|
590
656
|
inputSchema: {
|
|
591
657
|
effects: z.array(effectSchema),
|
|
658
|
+
select: z.string().optional(),
|
|
659
|
+
expect: selectionExpectSchema.optional(),
|
|
592
660
|
reason: z.string().optional(),
|
|
593
661
|
...REPO_ARG,
|
|
594
662
|
},
|
|
@@ -599,7 +667,7 @@ export function buildServer(ctx) {
|
|
|
599
667
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
600
668
|
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
601
669
|
// 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 })));
|
|
670
|
+
return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR })));
|
|
603
671
|
}
|
|
604
672
|
catch (e) {
|
|
605
673
|
return fail(e);
|
|
@@ -610,22 +678,22 @@ export function buildServer(ctx) {
|
|
|
610
678
|
// commits exactly a plan (the plan form, or the digest form `plan_id` +
|
|
611
679
|
// `effects`); `apply { effects }` above is plan + execute in one call.
|
|
612
680
|
tool("plan", {
|
|
613
|
-
description: "
|
|
614
|
-
inputSchema: { effects: z.array(effectSchema), ...REPO_ARG },
|
|
681
|
+
description: ladder("plan — Preview a write: the frozen Plan (summary, diffs, $new.n per minted id) burns no id; execute next.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = document", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} under a heading", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target = the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget. Grammar: query_syntax."),
|
|
682
|
+
inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
|
|
615
683
|
}, async (args) => {
|
|
616
684
|
try {
|
|
617
685
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
618
686
|
if (!rootPath)
|
|
619
687
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
620
|
-
return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...LOCK_DIR })));
|
|
688
|
+
return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR })));
|
|
621
689
|
}
|
|
622
690
|
catch (e) {
|
|
623
691
|
return fail(e);
|
|
624
692
|
}
|
|
625
693
|
});
|
|
626
694
|
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 },
|
|
695
|
+
description: ladder("execute — Commit exactly a plan from plan: the plan form (the whole Plan object) or the digest form (plan_id plus the same effects, re-planned and committed only if the digest still matches).", "{\"plan_id\":\"3ece126452106400210f07047029a961691b814252ba4437f5a5f1c3301629da\",\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} digest form: the plan_id plan returned for these effects", "Or pass the whole object: {\"plan\": the Plan as returned}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
|
|
696
|
+
inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), select: z.string().optional(), expect: selectionExpectSchema.optional(), reason: z.string().optional(), ...REPO_ARG },
|
|
629
697
|
}, async (args) => {
|
|
630
698
|
try {
|
|
631
699
|
const { repoId, rootPath } = repoScope(args.repo);
|
|
@@ -633,7 +701,8 @@ export function buildServer(ctx) {
|
|
|
633
701
|
throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
|
|
634
702
|
const shape = registry.get("execute").shape;
|
|
635
703
|
const planForm = args.plan !== undefined;
|
|
636
|
-
const
|
|
704
|
+
const selector = args.select !== undefined || args.expect !== undefined;
|
|
705
|
+
const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
|
|
637
706
|
if (planForm === digestForm)
|
|
638
707
|
throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
|
|
639
708
|
if (digestForm && (args.plan_id === undefined || args.effects === undefined))
|
|
@@ -641,7 +710,7 @@ export function buildServer(ctx) {
|
|
|
641
710
|
const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
|
|
642
711
|
const receipt = planForm
|
|
643
712
|
? 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 });
|
|
713
|
+
: executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
|
|
645
714
|
return okMutated(surfaceReceipt(receipt));
|
|
646
715
|
}
|
|
647
716
|
catch (e) {
|
|
@@ -799,7 +868,7 @@ export function buildServer(ctx) {
|
|
|
799
868
|
const root = requireRoot(rootPath);
|
|
800
869
|
const block = resolveBlockRef(repoId, args.id);
|
|
801
870
|
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")
|
|
871
|
+
.prepare("SELECT b.type AS type, d.format AS format, b.parent_block AS parent, d.path AS path, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
|
|
803
872
|
.get(block);
|
|
804
873
|
if (!row)
|
|
805
874
|
throw blockMissing(args.id);
|
|
@@ -811,8 +880,9 @@ export function buildServer(ctx) {
|
|
|
811
880
|
into = blockTarget(block);
|
|
812
881
|
}
|
|
813
882
|
else {
|
|
883
|
+
// The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
|
|
814
884
|
throw new EngineError("block_not_container", `block ${block} is a ${row.type}, not a heading or a container; append means into — insert after it with blocks_insert { to: <its parent>, at: { after: ${JSON.stringify(block)} } }`, {
|
|
815
|
-
data: { id: block, type: row.type },
|
|
885
|
+
data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
|
|
816
886
|
});
|
|
817
887
|
}
|
|
818
888
|
return sugar(repoId, root, appendEffects(into, args.text));
|
|
@@ -836,7 +906,7 @@ export function buildServer(ctx) {
|
|
|
836
906
|
let into;
|
|
837
907
|
if (args.section !== undefined) {
|
|
838
908
|
const info = findDoc(store, { docId });
|
|
839
|
-
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section) };
|
|
909
|
+
into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section, { ...(args.id !== undefined ? { id: args.id } : {}), ...(args.path !== undefined ? { path: args.path } : {}), text: args.text }) };
|
|
840
910
|
}
|
|
841
911
|
else {
|
|
842
912
|
into = { kind: "document", id: docId };
|
|
@@ -1155,7 +1225,7 @@ export function buildServer(ctx) {
|
|
|
1155
1225
|
}
|
|
1156
1226
|
});
|
|
1157
1227
|
tool("changes_since", {
|
|
1158
|
-
description: "
|
|
1228
|
+
description: ladder("changes_since — Re-orient after time away: the commit feed after a cursor, each digest with its revisions [{ id, path, hash }].", "{} everything from the start; head is the current max seq", "{\"cursor\":20,\"limit\":5} commits after seq 20, five at a time (cursor pages while truncated)", "{\"origin\":\"api\"} only the writes made through the engine (observed = edits on disk; import)", "Poll with your last cursor: an empty page with cursor == head means nothing new. Read a changed document with docs_read, its blocks with query (grammar: query_syntax)."),
|
|
1159
1229
|
inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
|
|
1160
1230
|
}, async (args) => {
|
|
1161
1231
|
try {
|
|
@@ -1217,12 +1287,11 @@ export function buildServer(ctx) {
|
|
|
1217
1287
|
// Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
|
|
1218
1288
|
// answer is about the binary and its database, not a repo.
|
|
1219
1289
|
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.",
|
|
1290
|
+
description: ladder("version — Which engine and which versions you are talking to, and which tools profile it serves.", "{} engine, version, components, specs, schema, mcp, runtime, commit, built and profile (\"full\" or \"minimal\")", "Under the minimal profile ten tools are listed; calling an unlisted one is the JSON-RPC error unknown tool. Reads go through query (grammar: query_syntax)."),
|
|
1222
1291
|
inputSchema: {},
|
|
1223
1292
|
}, async () => {
|
|
1224
1293
|
try {
|
|
1225
|
-
return ok(versionInfo(store, ctx.host));
|
|
1294
|
+
return ok(versionInfo(store, ctx.host, profile));
|
|
1226
1295
|
}
|
|
1227
1296
|
catch (e) {
|
|
1228
1297
|
return fail(e);
|
|
@@ -1236,8 +1305,14 @@ export function buildServer(ctx) {
|
|
|
1236
1305
|
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1237
1306
|
const name = request.params.name;
|
|
1238
1307
|
const entry = registry.get(name);
|
|
1308
|
+
// An unregistered name — unknown, or hidden by the profile — is the JSON-RPC
|
|
1309
|
+
// `-32602` error with exactly the text both engines pin (spec/surface §4
|
|
1310
|
+
// "Profiles"): `unknown tool <name>`. The SDK copies a thrown error's
|
|
1311
|
+
// integer `code` and its `message` onto the wire verbatim; `McpError`
|
|
1312
|
+
// would prefix the message ("MCP error -32602: …"), so a plain Error
|
|
1313
|
+
// carries the code instead.
|
|
1239
1314
|
if (!entry)
|
|
1240
|
-
throw
|
|
1315
|
+
throw unknownTool(name);
|
|
1241
1316
|
let args;
|
|
1242
1317
|
try {
|
|
1243
1318
|
args = validateArguments(name, entry.shape, request.params.arguments);
|
|
@@ -1249,8 +1324,14 @@ export function buildServer(ctx) {
|
|
|
1249
1324
|
});
|
|
1250
1325
|
return server;
|
|
1251
1326
|
}
|
|
1327
|
+
/** The pinned text of a `tools/call` on a name the server does not serve (spec/surface §4 "Profiles"). */
|
|
1328
|
+
export const UNKNOWN_TOOL_CODE = ErrorCode.InvalidParams; // -32602
|
|
1329
|
+
export function unknownTool(name) {
|
|
1330
|
+
return Object.assign(new Error(`unknown tool ${name}`), { code: UNKNOWN_TOOL_CODE });
|
|
1331
|
+
}
|
|
1252
1332
|
/** The catalog's tool names in registration order with their descriptions —
|
|
1253
|
-
* what `spec/surface/tool-descriptions.json` pins for both engines
|
|
1333
|
+
* what `spec/surface/tool-descriptions.json` pins for both engines (over a
|
|
1334
|
+
* `full` server; a profiled server lists its subset in the same order). */
|
|
1254
1335
|
export async function toolDescriptions(server) {
|
|
1255
1336
|
const tools = server._registeredTools;
|
|
1256
1337
|
const out = {};
|
|
@@ -1258,4 +1339,8 @@ export async function toolDescriptions(server) {
|
|
|
1258
1339
|
out[name] = t.description ?? "";
|
|
1259
1340
|
return out;
|
|
1260
1341
|
}
|
|
1342
|
+
/** The tool names a server lists, in registration order (`spec/surface/cases/profiles.json` pins them per profile). */
|
|
1343
|
+
export async function toolNames(server) {
|
|
1344
|
+
return Object.keys(await toolDescriptions(server));
|
|
1345
|
+
}
|
|
1261
1346
|
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
|
|
3
|
+
* bytes; members whose value is `undefined` omitted (an optional key absent is
|
|
4
|
+
* absent, never `null`; a JSON `null` value stays `null`); strings escaped only
|
|
5
|
+
* as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
|
|
6
|
+
* `\u00xx` lower-case) — everything else literal UTF-8; a number that is
|
|
7
|
+
* integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
|
|
8
|
+
* finite number by ECMAScript `Number::toString` (shortest round-trip digits);
|
|
9
|
+
* `true`/`false`/`null`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function canonicalJson(v: unknown): string;
|
|
12
|
+
/** A `plan` / `apply` / `execute` request as the caller gave it: `select`, `effects`, `expect` (each only when present). */
|
|
13
|
+
export interface FixRequest {
|
|
14
|
+
select?: unknown;
|
|
15
|
+
effects: unknown;
|
|
16
|
+
expect?: unknown;
|
|
17
|
+
}
|
|
18
|
+
/** The `fix` for a request to the SAME tool: the corrected arguments, canonical JSON. */
|
|
19
|
+
export declare function fixRequest(req: FixRequest): string;
|
|
20
|
+
/** The `fix` naming another tool: `<tool> <canonical args>`. */
|
|
21
|
+
export declare function fixTool(tool: string, args: unknown): string;
|
|
22
|
+
/** The selector that names exactly one row: the request narrowed to `from <root> where id == "<id>"` with `expect.max: 1` (the fix of a cross-hit collision or an ambiguous target under a selector). */
|
|
23
|
+
export declare function narrowedTo(root: "docs" | "blocks" | "nodes", id: string): string;
|
|
24
|
+
/** The root a row id belongs to, by its prefix (`d_` / `b_` / `n_`). */
|
|
25
|
+
export declare function rootOfId(id: string): "docs" | "blocks" | "nodes";
|
|
26
|
+
/** `value` with the member at `path` (keys and array indices) replaced; a copy, the input untouched. */
|
|
27
|
+
export declare function withMember(value: unknown, path: readonly string[], replacement: unknown): unknown;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { MutationError } from "./tree.js";
|
|
2
|
+
/** The argument-shape error of the surface (`effects.ts` `effectInvalid`), spelled here to avoid the module cycle. */
|
|
3
|
+
function effectInvalid(message) {
|
|
4
|
+
return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
|
|
5
|
+
}
|
|
6
|
+
// Canonical JSON (spec/mutate §11.5): the text a plan's digest is taken over,
|
|
7
|
+
// and since 3.3 the text every error `fix` and every `summary` value is spelled
|
|
8
|
+
// in — one serialization both engines produce byte for byte. Kept apart from
|
|
9
|
+
// `plan.ts` so the compile (`select.ts`) and the lowering (`effects.ts`) can use
|
|
10
|
+
// it without a module cycle.
|
|
11
|
+
function cmpUtf8(a, b) {
|
|
12
|
+
return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
// A lone surrogate has no UTF-8 encoding: it cannot be digested (nor sent as JSON text).
|
|
15
|
+
function isWellFormed(s) {
|
|
16
|
+
for (let i = 0; i < s.length; i++) {
|
|
17
|
+
const c = s.charCodeAt(i);
|
|
18
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
19
|
+
const n = s.charCodeAt(i + 1);
|
|
20
|
+
if (!(n >= 0xdc00 && n <= 0xdfff))
|
|
21
|
+
return false;
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
else if (c >= 0xdc00 && c <= 0xdfff)
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
function canonicalString(s) {
|
|
30
|
+
if (!isWellFormed(s))
|
|
31
|
+
throw effectInvalid("strings must be well-formed Unicode (a lone surrogate cannot be digested)");
|
|
32
|
+
let out = '"';
|
|
33
|
+
for (let i = 0; i < s.length; i++) {
|
|
34
|
+
const c = s.charCodeAt(i);
|
|
35
|
+
switch (c) {
|
|
36
|
+
case 0x22:
|
|
37
|
+
out += '\\"';
|
|
38
|
+
break;
|
|
39
|
+
case 0x5c:
|
|
40
|
+
out += "\\\\";
|
|
41
|
+
break;
|
|
42
|
+
case 0x08:
|
|
43
|
+
out += "\\b";
|
|
44
|
+
break;
|
|
45
|
+
case 0x0c:
|
|
46
|
+
out += "\\f";
|
|
47
|
+
break;
|
|
48
|
+
case 0x0a:
|
|
49
|
+
out += "\\n";
|
|
50
|
+
break;
|
|
51
|
+
case 0x0d:
|
|
52
|
+
out += "\\r";
|
|
53
|
+
break;
|
|
54
|
+
case 0x09:
|
|
55
|
+
out += "\\t";
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
out += c < 0x20 ? `\\u${c.toString(16).padStart(4, "0")}` : s[i];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out + '"';
|
|
62
|
+
}
|
|
63
|
+
function canonicalNumber(n) {
|
|
64
|
+
if (!Number.isFinite(n))
|
|
65
|
+
throw effectInvalid("a number in a plan must be finite");
|
|
66
|
+
if (Number.isInteger(n) && Math.abs(n) < 2 ** 53)
|
|
67
|
+
return String(n === 0 ? 0 : n);
|
|
68
|
+
return String(n);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
|
|
72
|
+
* bytes; members whose value is `undefined` omitted (an optional key absent is
|
|
73
|
+
* absent, never `null`; a JSON `null` value stays `null`); strings escaped only
|
|
74
|
+
* as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
|
|
75
|
+
* `\u00xx` lower-case) — everything else literal UTF-8; a number that is
|
|
76
|
+
* integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
|
|
77
|
+
* finite number by ECMAScript `Number::toString` (shortest round-trip digits);
|
|
78
|
+
* `true`/`false`/`null`.
|
|
79
|
+
*/
|
|
80
|
+
export function canonicalJson(v) {
|
|
81
|
+
if (v === null)
|
|
82
|
+
return "null";
|
|
83
|
+
switch (typeof v) {
|
|
84
|
+
case "boolean":
|
|
85
|
+
return v ? "true" : "false";
|
|
86
|
+
case "number":
|
|
87
|
+
return canonicalNumber(v);
|
|
88
|
+
case "string":
|
|
89
|
+
return canonicalString(v);
|
|
90
|
+
case "object": {
|
|
91
|
+
if (Array.isArray(v))
|
|
92
|
+
return `[${v.map((x) => canonicalJson(x === undefined ? null : x)).join(",")}]`;
|
|
93
|
+
const o = v;
|
|
94
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort(cmpUtf8);
|
|
95
|
+
return `{${keys.map((k) => `${canonicalString(k)}:${canonicalJson(o[k])}`).join(",")}}`;
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
throw effectInvalid(`a ${typeof v} cannot be digested`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The `fix` for a request to the SAME tool: the corrected arguments, canonical JSON. */
|
|
102
|
+
export function fixRequest(req) {
|
|
103
|
+
return canonicalJson({
|
|
104
|
+
...(req.select !== undefined ? { select: req.select } : {}),
|
|
105
|
+
effects: req.effects,
|
|
106
|
+
...(req.expect !== undefined ? { expect: req.expect } : {}),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** The `fix` naming another tool: `<tool> <canonical args>`. */
|
|
110
|
+
export function fixTool(tool, args) {
|
|
111
|
+
return `${tool} ${canonicalJson(args)}`;
|
|
112
|
+
}
|
|
113
|
+
/** The selector that names exactly one row: the request narrowed to `from <root> where id == "<id>"` with `expect.max: 1` (the fix of a cross-hit collision or an ambiguous target under a selector). */
|
|
114
|
+
export function narrowedTo(root, id) {
|
|
115
|
+
return `from ${root} where id == ${JSON.stringify(id)}`;
|
|
116
|
+
}
|
|
117
|
+
/** The root a row id belongs to, by its prefix (`d_` / `b_` / `n_`). */
|
|
118
|
+
export function rootOfId(id) {
|
|
119
|
+
if (id.startsWith("d_"))
|
|
120
|
+
return "docs";
|
|
121
|
+
if (id.startsWith("n_"))
|
|
122
|
+
return "nodes";
|
|
123
|
+
return "blocks";
|
|
124
|
+
}
|
|
125
|
+
/** `value` with the member at `path` (keys and array indices) replaced; a copy, the input untouched. */
|
|
126
|
+
export function withMember(value, path, replacement) {
|
|
127
|
+
if (path.length === 0)
|
|
128
|
+
return replacement;
|
|
129
|
+
const [head, ...rest] = path;
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
const copy = [...value];
|
|
132
|
+
copy[Number(head)] = withMember(copy[Number(head)], rest, replacement);
|
|
133
|
+
return copy;
|
|
134
|
+
}
|
|
135
|
+
const o = (typeof value === "object" && value !== null ? value : {});
|
|
136
|
+
return { ...o, [head]: withMember(o[head], rest, replacement) };
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=canonical.js.map
|