Skip to main content

omgbase_surface/
catalog.rs

1//! The MCP tool catalog (`spec/surface/README.md` §4) as a library: a table
2//! of [`ToolSpec`]s (name, JSON-schema input, description) and one dispatch
3//! ([`Surface::call`]) that runs a tool against the store and returns its
4//! JSON result or the error envelope. Transport-agnostic — a server wraps
5//! each outcome in one text content item. Port of
6//! `packages/core/src/mcp/server.ts`.
7
8use std::path::Path;
9
10use omgbase_format::BlockKind;
11use omgbase_format::text::{normalize_text, normalize_visible_text};
12use omgbase_reconcile::Config;
13use omgbase_search::EmbeddingProvider;
14use omgbase_store::mutate_kernel::{At, Op, Parent, To};
15use omgbase_store::{
16    ApplyOrigin, ApplyRequest, ApplyResult, DocOpContext, DocStore, Expect, FsDocStore, Opset,
17    QueryVector, Store,
18};
19use omgbase_sync::{RealFileSystem, RepoRow};
20use rusqlite::{OptionalExtension, params};
21use serde_json::{Map, Value as Json, json};
22
23use crate::error::{Result, SurfaceError};
24use crate::graph::{GraphArgs, graph_neighborhood};
25use crate::history;
26use crate::links;
27use crate::query::{QueryOptions, query};
28use crate::read::{self, Resolution, ResolvedRef};
29use crate::reference::QUERY_SYNTAX;
30
31/// The actor every write from this surface records.
32pub const ACTOR: &str = "agent:mcp";
33
34/// One tool of the catalog.
35#[derive(Clone, Debug, PartialEq)]
36pub struct ToolSpec {
37    pub name: &'static str,
38    pub description: &'static str,
39    /// The JSON schema of the arguments object.
40    pub input_schema: Json,
41}
42
43/// What a tool call produced: its JSON result, or the error envelope with
44/// `is_error` set.
45#[derive(Clone, Debug, PartialEq)]
46pub struct ToolOutcome {
47    pub body: Json,
48    pub is_error: bool,
49}
50
51/// Where a mutating tool writes files.
52enum WriteTarget {
53    /// Derive the repo's root from its `fs` source and write through the
54    /// filesystem (a sourceless repo cannot mutate).
55    Derived,
56    /// Write through this store whatever the repo's sources say (a runner's
57    /// in-memory store).
58    Fixed(Box<dyn DocStore>),
59}
60
61/// The catalog bound to a store, a default repo and optional providers.
62pub struct Surface {
63    store: Store,
64    default_repo: String,
65    provider: Option<Box<dyn EmbeddingProvider>>,
66    on_mutation: Option<Box<dyn FnMut()>>,
67    clock: Box<dyn FnMut() -> String>,
68    writes: WriteTarget,
69    config: Config,
70}
71
72// ---- argument helpers ----------------------------------------------------------------
73
74fn bad_args(msg: impl Into<String>) -> SurfaceError {
75    SurfaceError::filter_invalid(msg, "arguments")
76}
77
78fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
79    args.get(key).and_then(Json::as_str)
80}
81
82fn arg_string(args: &Json, key: &str) -> Result<String> {
83    arg_str(args, key)
84        .map(str::to_owned)
85        .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
86}
87
88fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
89    match args.get(key) {
90        None | Some(Json::Null) => Ok(None),
91        Some(v) => v
92            .as_f64()
93            .filter(|n| n.fract() == 0.0)
94            .map(|n| Some(n as i64))
95            .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
96    }
97}
98
99fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
100    Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
101}
102
103fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
104    match args.get(key) {
105        None | Some(Json::Null) => Ok(None),
106        Some(Json::Bool(b)) => Ok(Some(*b)),
107        Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
108    }
109}
110
111fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
112    let Some(arr) = args.get(key).and_then(Json::as_array) else {
113        return Err(bad_args(format!("`{key}` must be an array of strings")));
114    };
115    arr.iter()
116        .map(|v| {
117            v.as_str()
118                .map(str::to_owned)
119                .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
120        })
121        .collect()
122}
123
124fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
125    match args.get(key) {
126        None | Some(Json::Null) => Ok(None),
127        Some(Json::Object(o)) => Ok(Some(o)),
128        Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
129    }
130}
131
132fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
133    e.get(k).and_then(Json::as_str).map(str::to_owned)
134}
135
136/// A block tool's `expect { content_hash?, parent_children_hash? }` (the
137/// reference's `expectSchema`, both keys optional), or `None` when absent.
138fn arg_expect(args: &Json) -> Result<Option<Expect>> {
139    Ok(arg_object(args, "expect")?.map(|e| Expect {
140        content_hash: hex_field(e, "content_hash"),
141        parent_children_hash: hex_field(e, "parent_children_hash"),
142    }))
143}
144
145/// `blocks_insert` / `blocks_move`'s `expect { parent_children_hash? }` — the
146/// destination-parent CAS of `spec/mutate` §1.2 (1.2). A `content_hash` has
147/// no meaning on these ops and is dropped, not an error (the reference's zod
148/// schema strips it).
149fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
150    Ok(arg_object(args, "expect")?.map(|e| Expect {
151        content_hash: None,
152        parent_children_hash: hex_field(e, "parent_children_hash"),
153    }))
154}
155
156fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
157    match arg_str(args, "resolution") {
158        None => Ok(default),
159        Some(s) => Resolution::parse(s).ok_or_else(|| {
160            bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
161        }),
162    }
163}
164
165/// `{ ...a, ...b }` (b wins).
166fn merge(mut a: Map<String, Json>, b: Json) -> Json {
167    if let Json::Object(o) = b {
168        for (k, v) in o {
169            a.insert(k, v);
170        }
171    }
172    Json::Object(a)
173}
174
175fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
176    let mut p = Map::new();
177    for (k, v) in props {
178        p.insert((*k).to_owned(), v.clone());
179    }
180    if repo {
181        p.insert("repo".to_owned(), json!({ "type": "string" }));
182    }
183    json!({ "type": "object", "properties": p, "required": required })
184}
185
186fn s() -> Json {
187    json!({ "type": "string" })
188}
189fn i() -> Json {
190    json!({ "type": "integer" })
191}
192fn b() -> Json {
193    json!({ "type": "boolean" })
194}
195fn strings() -> Json {
196    json!({ "type": "array", "items": { "type": "string" } })
197}
198fn obj() -> Json {
199    json!({ "type": "object" })
200}
201fn nullable_string() -> Json {
202    json!({ "type": ["string", "null"] })
203}
204fn resolution_schema() -> Json {
205    json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
206}
207fn at_schema() -> Json {
208    json!({ "oneOf": [
209        { "const": "start" }, { "const": "end" },
210        { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
211        { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
212    ] })
213}
214fn expect_schema() -> Json {
215    json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
216}
217/// `blocks_insert` / `blocks_move` carry the op-level expectation of
218/// `spec/mutate` §1.2: only `parent_children_hash` has a meaning there (no
219/// target block whose content could be checked), so the schema names that
220/// key alone, as the reference's `parentExpectSchema`.
221fn parent_expect_schema() -> Json {
222    json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
223}
224
225/// The catalog (§4 table), in the reference's registration order.
226#[must_use]
227pub fn tools() -> Vec<ToolSpec> {
228    let dp = |extra: &[(&str, Json)], required: &[&str]| {
229        let mut props = vec![("doc", s()), ("path", s())];
230        props.extend(extra.iter().cloned());
231        schema(&props, required, true)
232    };
233    vec![
234        ToolSpec {
235            name: "docs_outline",
236            description: "A document's compact indented outline (id, type, label per line; § marks headings). Args take a doc id or path.",
237            input_schema: dp(
238                &[
239                    (
240                        "resolution",
241                        json!({ "type": "string", "enum": ["skeleton", "outline"] }),
242                    ),
243                    ("depth", i()),
244                    ("budget_tokens", i()),
245                ],
246                &[],
247            ),
248        },
249        ToolSpec {
250            name: "docs_read",
251            description: "Read a whole document: verbatim `content`, `properties` grouped by source, `path`/`docId`/`rev`; include_ids adds `ids`, `hashes` (CAS tokens) and `parents`.",
252            input_schema: dp(&[("include_ids", b())], &[]),
253        },
254        ToolSpec {
255            name: "docs_get_many",
256            description: "Batch docs_read over `docs` (ids or paths): `{ items, errors, truncated }`, capped at 100 refs, optional token budget.",
257            input_schema: schema(
258                &[
259                    ("docs", strings()),
260                    ("include_ids", b()),
261                    ("budget_tokens", i()),
262                ],
263                &["docs"],
264                true,
265            ),
266        },
267        ToolSpec {
268            name: "nodes_get",
269            description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full); the owning doc is inferred from `id` when `doc`/`path` are omitted.",
270            input_schema: dp(&[("id", s()), ("resolution", resolution_schema())], &["id"]),
271        },
272        ToolSpec {
273            name: "nodes_get_many",
274            description: "Fetch up to 100 blocks by id in request order with budget truncation; `doc`/`path` is an optional scope. Returns `nodes`, `truncated`, `unresolved`.",
275            input_schema: dp(
276                &[
277                    ("ids", strings()),
278                    ("resolution", resolution_schema()),
279                    ("budget_tokens", i()),
280                ],
281                &["ids"],
282            ),
283        },
284        ToolSpec {
285            name: "read_ref",
286            description: "Read any ref — a document (id or path) or a block (`b_` id, or an `n_` node id) — classified as `{ kind: \"document\", … }` or `{ kind: \"block\", … }` (block `resolution` defaults to raw).",
287            input_schema: schema(
288                &[("ref", s()), ("resolution", resolution_schema())],
289                &["ref"],
290                true,
291            ),
292        },
293        ToolSpec {
294            name: "docs_tree",
295            description: "The directory-aware shape of a repo: live docs under `path` collapsed at `depth` segments into dir/doc entries with totals, ordered by path and paged.",
296            input_schema: schema(
297                &[
298                    ("path", s()),
299                    ("depth", i()),
300                    ("limit", i()),
301                    ("cursor", nullable_string()),
302                    ("budget_tokens", i()),
303                ],
304                &[],
305                true,
306            ),
307        },
308        ToolSpec {
309            name: "docs_list",
310            description: "Enumerate live documents as a page `{ items: [{ path, blocks, ts }], truncated, cursor }`, ordered by path; `path_glob` is a LIKE match (`*` matches across `/`).",
311            input_schema: schema(
312                &[
313                    ("path_glob", s()),
314                    ("limit", i()),
315                    ("cursor", nullable_string()),
316                    ("budget_tokens", i()),
317                ],
318                &[],
319                true,
320            ),
321        },
322        ToolSpec {
323            name: "query_syntax",
324            description: "The OQX syntax reference for the `query` tool.",
325            input_schema: schema(&[], &[], false),
326        },
327        ToolSpec {
328            name: "query",
329            description: "Run one OQX query (`select … from docs|blocks|nodes|edges where … follow … order by … limit N`). Returns lean hits `{ id, path, …projections }` with `truncated` + `cursor`, or a `count`/`exists`/`none` scalar, or `values`. See query_syntax.",
330            input_schema: schema(
331                &[
332                    ("query", s()),
333                    ("limit", i()),
334                    ("cursor", nullable_string()),
335                ],
336                &["query"],
337                true,
338            ),
339        },
340        ToolSpec {
341            name: "graph",
342            description: "The bounded neighborhood around root documents in one call — `{ documents, edges, frontier }` — compiled to an OQX `follow doc.out`/`doc.in` walk.",
343            input_schema: schema(
344                &[
345                    ("roots", strings()),
346                    ("degrees", i()),
347                    (
348                        "direction",
349                        json!({ "type": "string", "enum": ["in", "out", "both"] }),
350                    ),
351                    ("predicate", s()),
352                    ("select", strings()),
353                    ("max_documents", i()),
354                ],
355                &["roots"],
356                true,
357            ),
358        },
359        ToolSpec {
360            name: "text_search",
361            description: "Full-text (FTS5, bm25-ranked) search over block text.",
362            input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
363        },
364        ToolSpec {
365            name: "resolve",
366            description: "Resolve a name/title/phrase to the blocks it refers to: ranked `{ id, locator, preview, evidence }` (FTS, fused with the vector ranking when a provider exists).",
367            input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
368        },
369        ToolSpec {
370            name: "apply",
371            description: "Apply a changeset of kernel ops (insert/update/move/remove/split/merge) atomically; `dry_run` previews diffs.",
372            input_schema: schema(
373                &[
374                    (
375                        "ops",
376                        json!({ "type": "array", "items": { "type": "object" } }),
377                    ),
378                    ("reason", s()),
379                    ("dry_run", b()),
380                ],
381                &["ops"],
382                true,
383            ),
384        },
385        ToolSpec {
386            name: "blocks_insert",
387            description: "Insert blocks parsed from `markdown` under `to` (a block ref, or a document ref for its top level) at `at` (end|start|{before|after}). Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by `,` and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted.",
388            input_schema: schema(
389                &[
390                    ("to", s()),
391                    ("markdown", s()),
392                    ("at", at_schema()),
393                    ("expect", parent_expect_schema()),
394                    ("dry_run", b()),
395                ],
396                &["to", "markdown"],
397                true,
398            ),
399        },
400        ToolSpec {
401            name: "blocks_update",
402            description: "Replace a block's markdown and/or set attrs (`checked` folds into attrs) with CAS pinned server-side when `expect` is omitted. Returns `id`, `ids` and the apply result.",
403            input_schema: schema(
404                &[
405                    ("block", s()),
406                    ("markdown", s()),
407                    ("checked", b()),
408                    ("attrs", obj()),
409                    ("expect", expect_schema()),
410                    ("dry_run", b()),
411                ],
412                &["block"],
413                true,
414            ),
415        },
416        ToolSpec {
417            name: "blocks_move",
418            description: "Move blocks under a new parent at a position; `to` is a block ref or the blocks' own document. 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.",
419            input_schema: schema(
420                &[
421                    ("blocks", strings()),
422                    ("to", s()),
423                    ("at", at_schema()),
424                    ("expect", parent_expect_schema()),
425                    ("dry_run", b()),
426                ],
427                &["blocks", "to"],
428                true,
429            ),
430        },
431        ToolSpec {
432            name: "blocks_remove",
433            description: "Remove blocks (and their subtrees).",
434            input_schema: schema(
435                &[("blocks", strings()), ("dry_run", b())],
436                &["blocks"],
437                true,
438            ),
439        },
440        ToolSpec {
441            name: "blocks_split",
442            description: "Split a block at UTF-8 byte offsets; CAS pinned server-side.",
443            input_schema: schema(
444                &[
445                    ("block", s()),
446                    (
447                        "at",
448                        json!({ "type": "array", "items": { "type": "integer" } }),
449                    ),
450                    ("dry_run", b()),
451                ],
452                &["block", "at"],
453                true,
454            ),
455        },
456        ToolSpec {
457            name: "blocks_merge",
458            description: "Merge adjacent blocks into the first, joined by `separator`.",
459            input_schema: schema(
460                &[("blocks", strings()), ("separator", s()), ("dry_run", b())],
461                &["blocks"],
462                true,
463            ),
464        },
465        ToolSpec {
466            name: "tasks_complete",
467            description: "Check (or uncheck with checked:false) task blocks.",
468            input_schema: schema(
469                &[("blocks", strings()), ("checked", b()), ("dry_run", b())],
470                &["blocks"],
471                true,
472            ),
473        },
474        ToolSpec {
475            name: "node_set",
476            description: "Set one editable property of a projected node (a link's name/value, a task's checked).",
477            input_schema: schema(
478                &[
479                    ("node", s()),
480                    ("prop", s()),
481                    ("value", s()),
482                    ("dry_run", b()),
483                ],
484                &["node", "prop", "value"],
485                true,
486            ),
487        },
488        ToolSpec {
489            name: "sections_append",
490            description: "Append markdown at the end of a heading's section; `heading` is a heading block id or its text (scoped by `doc`/`path`).",
491            input_schema: schema(
492                &[
493                    ("heading", s()),
494                    ("markdown", s()),
495                    ("doc", s()),
496                    ("path", s()),
497                    ("dry_run", b()),
498                ],
499                &["heading", "markdown"],
500                true,
501            ),
502        },
503        ToolSpec {
504            name: "docs_append",
505            description: "Append markdown at the end of a document as new top-level blocks (existing ids preserved).",
506            input_schema: schema(
507                &[("doc", s()), ("path", s()), ("text", s())],
508                &["text"],
509                true,
510            ),
511        },
512        ToolSpec {
513            name: "links_retarget",
514            description: "Rewrite one link destination everywhere it is linked (dry run by default).",
515            input_schema: schema(
516                &[
517                    ("from_target", s()),
518                    ("to_target", s()),
519                    ("path_glob", s()),
520                    ("dry_run", b()),
521                ],
522                &["from_target", "to_target"],
523                true,
524            ),
525        },
526        ToolSpec {
527            name: "links_stale",
528            description: "Dangling internal links (open edges to a `phantom:` target) plus external and total counts; `summary:true` returns counts only.",
529            input_schema: schema(
530                &[("path_glob", s()), ("limit", i()), ("summary", b())],
531                &[],
532                true,
533            ),
534        },
535        ToolSpec {
536            name: "links_repair",
537            description: "Bulk link repair: `repairs` ([{from,to}]) or one `from_target`/`to_target` pair, in one changeset (dry run by default).",
538            input_schema: schema(
539                &[
540                    (
541                        "repairs",
542                        json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
543                    ),
544                    ("from_target", s()),
545                    ("to_target", s()),
546                    ("path_glob", s()),
547                    ("dry_run", b()),
548                ],
549                &[],
550                true,
551            ),
552        },
553        ToolSpec {
554            name: "docs_create",
555            description: "Create a document at `path` from `markdown` with optional `frontmatter`.",
556            input_schema: schema(
557                &[("path", s()), ("markdown", s()), ("frontmatter", obj())],
558                &["path", "markdown"],
559                true,
560            ),
561        },
562        ToolSpec {
563            name: "docs_move",
564            description: "Rename a document to `to_path`, identity preserved; `retarget_inbound` rewrites inbound links.",
565            input_schema: schema(
566                &[("doc", s()), ("to_path", s()), ("retarget_inbound", b())],
567                &["doc", "to_path"],
568                true,
569            ),
570        },
571        ToolSpec {
572            name: "docs_delete",
573            description: "Delete a document: tombstone it and remove the file.",
574            input_schema: schema(&[("doc", s())], &["doc"], true),
575        },
576        ToolSpec {
577            name: "docs_set_meta",
578            description: "Set and/or unset frontmatter keys, re-ingesting the document.",
579            input_schema: schema(
580                &[("doc", s()), ("set", obj()), ("unset", strings())],
581                &["doc"],
582                true,
583            ),
584        },
585        ToolSpec {
586            name: "docs_plan_update",
587            description: "Plan a whole-document update without applying: the opset and a one-line-per-op plan.",
588            input_schema: schema(&[("doc", s()), ("content", s())], &["doc", "content"], true),
589        },
590        ToolSpec {
591            name: "docs_update",
592            description: "Whole-document update with identity preservation: plan then apply (`dry_run` returns the plan only).",
593            input_schema: schema(
594                &[
595                    ("doc", s()),
596                    ("content", s()),
597                    ("reason", s()),
598                    ("dry_run", b()),
599                ],
600                &["doc", "content"],
601                true,
602            ),
603        },
604        ToolSpec {
605            name: "observe",
606            description: "Record `content` as the authoritative bytes at `path` (an observed-origin commit; an echo when unchanged).",
607            input_schema: schema(
608                &[("path", s()), ("content", s())],
609                &["path", "content"],
610                true,
611            ),
612        },
613        ToolSpec {
614            name: "observe_many",
615            description: "Observe several files under one timestamp and one pool sweep.",
616            input_schema: schema(
617                &[(
618                    "files",
619                    json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
620                )],
621                &["files"],
622                true,
623            ),
624        },
625        ToolSpec {
626            name: "observe_delete",
627            description: "Record that `path` left the source: an observed, pooled tombstone.",
628            input_schema: schema(&[("path", s())], &["path"], true),
629        },
630        ToolSpec {
631            name: "history_node",
632            description: "A block's biography: the commits that touched it, newest first.",
633            input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
634        },
635        ToolSpec {
636            name: "diff",
637            description: "Block-grain diff between two revisions of a document.",
638            input_schema: schema(
639                &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
640                &["doc", "from_rev", "to_rev"],
641                true,
642            ),
643        },
644        ToolSpec {
645            name: "diff_unified",
646            description: "Unified diff (Myers, 3 lines of context) between two revisions (default: the previous and current).",
647            input_schema: schema(
648                &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
649                &["doc"],
650                true,
651            ),
652        },
653        ToolSpec {
654            name: "docs_read_at",
655            description: "The whole document as of a past revision.",
656            input_schema: dp(&[("rev", s())], &["rev"]),
657        },
658        ToolSpec {
659            name: "docs_history",
660            description: "Version history of the docs matching `path_glob` or `doc`, grouped by document.",
661            input_schema: schema(
662                &[
663                    ("path_glob", s()),
664                    ("doc", s()),
665                    ("include_deleted", b()),
666                    ("limit", i()),
667                ],
668                &[],
669                true,
670            ),
671        },
672        ToolSpec {
673            name: "changes_since",
674            description: "The change feed: commit digests after `cursor` (a repo commit seq).",
675            input_schema: schema(
676                &[
677                    ("cursor", i()),
678                    (
679                        "origin",
680                        json!({ "type": "string", "enum": ["api", "observed", "import"] }),
681                    ),
682                    ("limit", i()),
683                ],
684                &[],
685                true,
686            ),
687        },
688        ToolSpec {
689            name: "repos_status",
690            description: "Repo counts, unconverged docs and on-disk drift.",
691            input_schema: schema(&[], &[], true),
692        },
693        ToolSpec {
694            name: "sync_status",
695            description: "Sync state: last commit seq, last checkpoint, convergence.",
696            input_schema: schema(&[], &[], true),
697        },
698        ToolSpec {
699            name: "repos",
700            description: "The repos in this workspace: `{ repos: [{ slug, hasSource }] }`.",
701            input_schema: schema(&[], &[], false),
702        },
703    ]
704}
705
706impl Surface {
707    /// A surface over `store` whose default repo is `default_repo` (an id).
708    /// Mutations derive each repo's root from its `fs` source.
709    #[must_use]
710    pub fn new(
711        store: Store,
712        default_repo: &str,
713        provider: Option<Box<dyn EmbeddingProvider>>,
714    ) -> Self {
715        Self {
716            store,
717            default_repo: default_repo.to_owned(),
718            provider,
719            on_mutation: None,
720            clock: Box::new(omgbase_sync::now_ts),
721            writes: WriteTarget::Derived,
722            config: Config::default(),
723        }
724    }
725
726    /// Route every mutation's file writes through `doc_store` regardless of
727    /// the repo's sources (a runner's in-memory store).
728    #[must_use]
729    pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
730        self.writes = WriteTarget::Fixed(doc_store);
731        self
732    }
733
734    /// Stamp commits with `clock()` instead of the wall clock.
735    #[must_use]
736    pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
737        self.clock = Box::new(clock);
738        self
739    }
740
741    /// Called after every successful write (never after a dry run).
742    #[must_use]
743    pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
744        self.on_mutation = Some(Box::new(hook));
745        self
746    }
747
748    /// The matcher thresholds the observe tools pass through.
749    #[must_use]
750    pub fn with_config(mut self, config: Config) -> Self {
751        self.config = config;
752        self
753    }
754
755    #[must_use]
756    pub fn store(&self) -> &Store {
757        &self.store
758    }
759
760    pub fn store_mut(&mut self) -> &mut Store {
761        &mut self.store
762    }
763
764    #[must_use]
765    pub fn default_repo(&self) -> &str {
766        &self.default_repo
767    }
768
769    /// The catalog.
770    #[must_use]
771    pub fn tools(&self) -> Vec<ToolSpec> {
772        tools()
773    }
774
775    /// Whether `name` is a tool that can commit (a host serializes these
776    /// under the writer lock of `spec/sync` §7; `dry_run` calls still count
777    /// here — only the mutation hook knows a write actually happened).
778    #[must_use]
779    pub fn is_write_tool(name: &str) -> bool {
780        matches!(
781            name,
782            "apply"
783                | "blocks_insert"
784                | "blocks_update"
785                | "blocks_move"
786                | "blocks_remove"
787                | "blocks_split"
788                | "blocks_merge"
789                | "tasks_complete"
790                | "node_set"
791                | "sections_append"
792                | "docs_append"
793                | "links_retarget"
794                | "links_repair"
795                | "docs_create"
796                | "docs_move"
797                | "docs_delete"
798                | "docs_set_meta"
799                | "docs_update"
800                | "observe"
801                | "observe_many"
802                | "observe_delete"
803        )
804    }
805
806    /// Run a tool: its JSON result, or the error envelope.
807    pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
808        match self.call_result(name, &args) {
809            Ok(body) => ToolOutcome {
810                body,
811                is_error: false,
812            },
813            Err(e) => ToolOutcome {
814                body: e.to_json(),
815                is_error: true,
816            },
817        }
818    }
819
820    fn now(&mut self) -> String {
821        (self.clock)()
822    }
823
824    fn notify(&mut self) {
825        if let Some(hook) = &mut self.on_mutation {
826            hook();
827        }
828    }
829
830    // ---- repo scoping (§4) ------------------------------------------------------------
831
832    fn repo_rows(&self) -> Result<Vec<RepoRow>> {
833        Ok(omgbase_sync::workspace::list_repos(&self.store)?)
834    }
835
836    /// `(repo id, derived root)` for the `repo` argument; omitted → the default.
837    fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
838        let rows = self.repo_rows()?;
839        match arg_str(args, "repo") {
840            None => {
841                let root = rows
842                    .iter()
843                    .find(|r| r.repo_id == self.default_repo)
844                    .and_then(|r| r.root_path.clone());
845                Ok((self.default_repo.clone(), root))
846            }
847            Some(slug) => rows
848                .iter()
849                .find(|r| r.slug == slug)
850                .map(|r| (r.repo_id.clone(), r.root_path.clone()))
851                .ok_or_else(|| {
852                    SurfaceError::with_data(
853                        "repo_not_found",
854                        format!("no repo '{slug}' in this workspace"),
855                        json!({ "repo": slug }),
856                    )
857                }),
858        }
859    }
860
861    fn require_root(&self, root: Option<&str>) -> Result<()> {
862        if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
863            Ok(())
864        } else {
865            Err(SurfaceError::new(
866                "repo_not_found",
867                "repo has no filesystem source; mutation disabled",
868            ))
869        }
870    }
871
872    /// Run `f` with the store and the write target for `root`.
873    fn with_writes<T>(
874        &mut self,
875        root: Option<&str>,
876        f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
877    ) -> Result<T> {
878        self.require_root(root)?;
879        match &mut self.writes {
880            WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
881            WriteTarget::Derived => {
882                let mut fs = FsDocStore::new(root.expect("checked by require_root"));
883                f(&mut self.store, &mut fs)
884            }
885        }
886    }
887
888    // ---- ref resolution (§4) ---------------------------------------------------------
889
890    /// The owning doc from `doc` (id or path) / `path` / `block`; `doc_missing` when none.
891    fn resolve_doc_id(
892        &self,
893        repo_id: &str,
894        doc: Option<&str>,
895        path: Option<&str>,
896        block: Option<&str>,
897    ) -> Result<String> {
898        let conn = self.store.conn();
899        let found = if let Some(d) = doc.filter(|d| !d.is_empty()) {
900            read::find_doc_by_ref(conn, repo_id, d)?.map(|i| i.doc_id)
901        } else if let Some(p) = path.filter(|p| !p.is_empty()) {
902            read::find_doc_by_path(conn, repo_id, p)?.map(|i| i.doc_id)
903        } else if let Some(b) = block.filter(|b| !b.is_empty()) {
904            conn.query_row(
905                "SELECT doc_id FROM blocks WHERE block_id = ?1",
906                params![b],
907                |r| r.get::<_, String>(0),
908            )
909            .optional()?
910        } else {
911            None
912        };
913        found.ok_or_else(|| {
914            let mut m = Map::new();
915            if let Some(d) = doc {
916                m.insert("doc".to_owned(), json!(d));
917            }
918            if let Some(p) = path {
919                m.insert("path".to_owned(), json!(p));
920            }
921            if let Some(b) = block {
922                m.insert("block".to_owned(), json!(b));
923            }
924            SurfaceError::with_data(
925                "doc_missing",
926                format!("no document for {}", Json::Object(m.clone())),
927                Json::Object(m),
928            )
929        })
930    }
931
932    fn resolve_doc_from_args(&self, repo_id: &str, args: &Json) -> Result<String> {
933        self.resolve_doc_id(repo_id, arg_str(args, "doc"), arg_str(args, "path"), None)
934    }
935
936    /// `heading` (a heading block id or heading text) → the heading block id.
937    fn resolve_heading_id(
938        &self,
939        repo_id: &str,
940        heading: &str,
941        doc: Option<&str>,
942        path: Option<&str>,
943    ) -> Result<String> {
944        let conn = self.store.conn();
945        let as_block: Option<String> = conn
946            .query_row(
947                "SELECT block_id FROM blocks WHERE block_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
948                params![heading],
949                |r| r.get(0),
950            )
951            .optional()?;
952        if let Some(b) = as_block {
953            return Ok(b);
954        }
955        let want_doc = if doc.is_some_and(|d| !d.is_empty()) || path.is_some_and(|p| !p.is_empty())
956        {
957            Some(self.resolve_doc_id(repo_id, doc, path, None)?)
958        } else {
959            None
960        };
961        let needle = if heading.trim_start_matches([' ', '\t']).starts_with('#') {
962            normalize_visible_text(heading, BlockKind::Heading, 0)
963        } else {
964            normalize_text(heading)
965        };
966        let rows: Vec<(String, String, String)> = match &want_doc {
967            Some(d) => {
968                let mut stmt = conn.prepare(
969                    "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND doc_id = ?2 AND deleted_commit IS NULL",
970                )?;
971                let it = stmt.query_map(params![repo_id, d], |r| {
972                    Ok((r.get(0)?, r.get(1)?, r.get(2)?))
973                })?;
974                it.collect::<std::result::Result<_, _>>()?
975            }
976            None => {
977                let mut stmt = conn.prepare(
978                    "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
979                )?;
980                let it =
981                    stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
982                it.collect::<std::result::Result<_, _>>()?
983            }
984        };
985        let matches: Vec<&(String, String, String)> = rows
986            .iter()
987            .filter(|(_, _, text)| normalize_text(text) == needle)
988            .collect();
989        match matches.len() {
990            0 => {
991                let mut data = Map::new();
992                data.insert("heading".to_owned(), json!(heading));
993                if let Some(d) = want_doc {
994                    data.insert("doc".to_owned(), json!(d));
995                }
996                Err(SurfaceError::with_data(
997                    "parent_missing",
998                    format!("no heading matching {}", Json::String(heading.to_owned())),
999                    Json::Object(data),
1000                ))
1001            }
1002            1 => Ok(matches[0].0.clone()),
1003            n => Err(SurfaceError::with_data(
1004                "ambiguous_heading",
1005                format!(
1006                    "heading {} matches {n} headings; pass its block id or a doc/path scope",
1007                    Json::String(heading.to_owned())
1008                ),
1009                json!({
1010                    "heading": heading,
1011                    "candidates": matches.iter().map(|(b, d, _)| json!({ "block": b, "doc": d })).collect::<Vec<_>>(),
1012                }),
1013            )),
1014        }
1015    }
1016
1017    /// A ref → a live block id (`block_missing` otherwise).
1018    fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1019        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1020            Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1021            _ => Err(SurfaceError::with_data(
1022                "block_missing",
1023                format!("not a block: {r}"),
1024                json!({ "ref": r }),
1025            )),
1026        }
1027    }
1028
1029    /// A parent ref: a block, or a document ref for its top level.
1030    fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1031        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1032            Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1033            Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1034            None => Err(SurfaceError::with_data(
1035                "block_missing",
1036                format!("not a block or document: {r}"),
1037                json!({ "ref": r }),
1038            )),
1039        }
1040    }
1041
1042    fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1043        Ok(self
1044            .store
1045            .conn()
1046            .query_row(
1047                "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1048                params![block_id],
1049                |r| r.get(0),
1050            )
1051            .optional()?)
1052    }
1053
1054    /// The live block's raw hash (hex) for CAS pinning; `None` when unknown.
1055    fn pin_hash(&self, block_id: &str) -> Result<Option<String>> {
1056        let h: Option<Vec<u8>> = self
1057            .store
1058            .conn()
1059            .query_row(
1060                "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1061                params![block_id],
1062                |r| r.get(0),
1063            )
1064            .optional()?;
1065        Ok(h.map(|h| omgbase_format::hash::hex(&h)))
1066    }
1067
1068    /// The `at` spec with its anchor ref resolved; absent → end.
1069    fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1070        match at {
1071            None | Some(Json::Null) => Ok(At::End),
1072            Some(Json::String(s)) if s == "start" => Ok(At::Start),
1073            Some(Json::String(s)) if s == "end" => Ok(At::End),
1074            Some(Json::Object(o)) => {
1075                if let Some(b) = o.get("before").and_then(Json::as_str) {
1076                    return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1077                }
1078                if let Some(a) = o.get("after").and_then(Json::as_str) {
1079                    return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1080                }
1081                Err(bad_args(
1082                    "`at` must be \"start\", \"end\", {before} or {after}",
1083                ))
1084            }
1085            Some(_) => Err(bad_args(
1086                "`at` must be \"start\", \"end\", {before} or {after}",
1087            )),
1088        }
1089    }
1090
1091    // ---- the apply tail -----------------------------------------------------------------
1092
1093    fn apply_ops(
1094        &mut self,
1095        repo_id: &str,
1096        root: Option<&str>,
1097        ops: Vec<Op>,
1098        reason: &str,
1099        dry_run: bool,
1100    ) -> Result<ApplyResult> {
1101        let ts = self.now();
1102        let req = ApplyRequest {
1103            repo_id: repo_id.to_owned(),
1104            ops,
1105            origin: ApplyOrigin::new(ACTOR, Some(reason)),
1106            dry_run,
1107            set_frontmatter: Vec::new(),
1108        };
1109        let res = self.with_writes(root, |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1110        if !dry_run {
1111            self.notify();
1112        }
1113        Ok(res)
1114    }
1115
1116    fn doc_ctx(&mut self, repo_id: &str) -> DocOpContext {
1117        DocOpContext {
1118            repo_id: repo_id.to_owned(),
1119            actor: Some(ACTOR.to_owned()),
1120            ts: self.now(),
1121        }
1122    }
1123
1124    // ---- dispatch -------------------------------------------------------------------------
1125
1126    /// Run a tool, returning its result or the error.
1127    #[allow(clippy::too_many_lines)]
1128    pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1129        match name {
1130            "docs_outline" => {
1131                let (repo, _) = self.scope(args)?;
1132                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1133                let skeleton = match arg_str(args, "resolution") {
1134                    None | Some("outline") => false,
1135                    Some("skeleton") => true,
1136                    Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1137                };
1138                read::docs_outline(
1139                    &self.store,
1140                    &doc_id,
1141                    skeleton,
1142                    arg_i64(args, "depth")?,
1143                    arg_usize(args, "budget_tokens")?,
1144                )
1145            }
1146            "docs_read" => {
1147                let (repo, _) = self.scope(args)?;
1148                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1149                read::docs_read(
1150                    &self.store,
1151                    &doc_id,
1152                    arg_bool(args, "include_ids")?.unwrap_or(false),
1153                )?
1154                .ok_or_else(|| SurfaceError::new("doc_missing", format!("no document for {args}")))
1155            }
1156            "docs_get_many" => {
1157                let (repo, _) = self.scope(args)?;
1158                let refs = arg_strings(args, "docs")?;
1159                read::docs_read_many(
1160                    &self.store,
1161                    &repo,
1162                    &refs,
1163                    arg_bool(args, "include_ids")?.unwrap_or(false),
1164                    arg_usize(args, "budget_tokens")?,
1165                )
1166            }
1167            "nodes_get" => {
1168                let (repo, _) = self.scope(args)?;
1169                let id = arg_string(args, "id")?;
1170                let doc_id = self.resolve_doc_id(
1171                    &repo,
1172                    arg_str(args, "doc"),
1173                    arg_str(args, "path"),
1174                    Some(&id),
1175                )?;
1176                read::nodes_get(
1177                    &self.store,
1178                    &doc_id,
1179                    &id,
1180                    resolution_arg(args, Resolution::Full)?,
1181                )?
1182                .ok_or_else(|| SurfaceError::new("block_missing", format!("no block {id}")))
1183            }
1184            "nodes_get_many" => {
1185                let (repo, _) = self.scope(args)?;
1186                let ids = arg_strings(args, "ids")?;
1187                let scoped = arg_str(args, "doc").is_some_and(|d| !d.is_empty())
1188                    || arg_str(args, "path").is_some_and(|p| !p.is_empty());
1189                let doc_id = if scoped {
1190                    Some(self.resolve_doc_from_args(&repo, args)?)
1191                } else {
1192                    None
1193                };
1194                read::nodes_get_many(
1195                    &self.store,
1196                    doc_id.as_deref(),
1197                    &ids,
1198                    resolution_arg(args, Resolution::Text)?,
1199                    arg_usize(args, "budget_tokens")?,
1200                )
1201            }
1202            "read_ref" => {
1203                let (repo, _) = self.scope(args)?;
1204                let r = arg_string(args, "ref")?;
1205                let resolved =
1206                    read::resolve_ref(self.store.conn(), &repo, &r)?.ok_or_else(|| {
1207                        SurfaceError::new(
1208                            "doc_missing",
1209                            format!("no document or block for {}", Json::String(r.clone())),
1210                        )
1211                    })?;
1212                match resolved {
1213                    ResolvedRef::Document { doc_id } => {
1214                        let res =
1215                            read::docs_read(&self.store, &doc_id, false)?.ok_or_else(|| {
1216                                SurfaceError::new(
1217                                    "doc_missing",
1218                                    format!("no document for {}", Json::String(r.clone())),
1219                                )
1220                            })?;
1221                        let mut m = Map::new();
1222                        m.insert("kind".to_owned(), json!("document"));
1223                        Ok(merge(m, res))
1224                    }
1225                    ResolvedRef::Block { doc_id, block_id } => {
1226                        let node = read::nodes_get(
1227                            &self.store,
1228                            &doc_id,
1229                            &block_id,
1230                            resolution_arg(args, Resolution::Raw)?,
1231                        )?
1232                        .ok_or_else(|| {
1233                            SurfaceError::new("block_missing", format!("no block {block_id}"))
1234                        })?;
1235                        let mut m = Map::new();
1236                        m.insert("kind".to_owned(), json!("block"));
1237                        Ok(merge(m, node))
1238                    }
1239                }
1240            }
1241            "docs_tree" => {
1242                let (repo, _) = self.scope(args)?;
1243                read::docs_tree(
1244                    &self.store,
1245                    &repo,
1246                    arg_str(args, "path"),
1247                    arg_i64(args, "depth")?,
1248                    arg_i64(args, "limit")?,
1249                    arg_str(args, "cursor"),
1250                    arg_usize(args, "budget_tokens")?,
1251                )
1252            }
1253            "docs_list" => {
1254                let (repo, _) = self.scope(args)?;
1255                read::docs_list(
1256                    &self.store,
1257                    &repo,
1258                    arg_str(args, "path_glob"),
1259                    arg_i64(args, "limit")?,
1260                    arg_str(args, "cursor"),
1261                    arg_usize(args, "budget_tokens")?,
1262                )
1263            }
1264            "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1265            "query" => {
1266                let (repo, _) = self.scope(args)?;
1267                let source = arg_string(args, "query")?;
1268                // A `semantic(...)` query with no provider is `semantic_unavailable`
1269                // here (the runner itself reports `filter_invalid`, §9).
1270                if self.provider.is_none()
1271                    && !crate::query::collect_semantic_phrases(&source).is_empty()
1272                {
1273                    return Err(SurfaceError::new(
1274                        "semantic_unavailable",
1275                        "no embedding provider configured for this server",
1276                    ));
1277                }
1278                let opts = QueryOptions {
1279                    limit: arg_usize(args, "limit")?,
1280                    cursor: arg_str(args, "cursor"),
1281                    provider: self.provider.as_deref(),
1282                    in_memory: false,
1283                };
1284                Ok(query(&self.store, &repo, &source, opts)?.to_json())
1285            }
1286            "graph" => {
1287                let (repo, _) = self.scope(args)?;
1288                let g = GraphArgs {
1289                    roots: arg_strings(args, "roots")?,
1290                    degrees: arg_i64(args, "degrees")?,
1291                    direction: arg_str(args, "direction").map(str::to_owned),
1292                    predicate: arg_str(args, "predicate").map(str::to_owned),
1293                    select: if args.get("select").is_some() {
1294                        arg_strings(args, "select")?
1295                    } else {
1296                        Vec::new()
1297                    },
1298                    max_documents: arg_i64(args, "max_documents")?,
1299                };
1300                if self.provider.is_none()
1301                    && g.select.iter().any(|s| {
1302                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1303                            .is_empty()
1304                    })
1305                {
1306                    return Err(SurfaceError::new(
1307                        "semantic_unavailable",
1308                        "no embedding provider configured for this server",
1309                    ));
1310                }
1311                graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1312            }
1313            "text_search" => {
1314                let (repo, _) = self.scope(args)?;
1315                let q = arg_string(args, "q")?;
1316                let res =
1317                    self.store
1318                        .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1319                Ok(json!({
1320                    "hits": res.hits.iter().map(|h| json!({
1321                        "blockId": h.block_id, "docId": h.doc_id, "path": h.path, "type": h.block_type, "text": h.text, "score": h.score,
1322                    })).collect::<Vec<_>>(),
1323                    "truncated": res.truncated,
1324                }))
1325            }
1326            "resolve" => {
1327                let (repo, _) = self.scope(args)?;
1328                let q = arg_string(args, "query")?;
1329                let vector = match &self.provider {
1330                    Some(p) => Some(QueryVector {
1331                        model: p.model().to_owned(),
1332                        vec: p
1333                            .embed_query(&q)
1334                            .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1335                    }),
1336                    None => None,
1337                };
1338                let hits = self
1339                    .store
1340                    .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1341                Ok(Json::Array(hits.iter().map(|h| json!({
1342                    "id": h.id, "locator": h.locator, "preview": h.preview, "evidence": evidence_json(&h.evidence),
1343                })).collect()))
1344            }
1345            "apply" => {
1346                let (repo, root) = self.scope(args)?;
1347                self.require_root(root.as_deref())?;
1348                let ops = args
1349                    .get("ops")
1350                    .and_then(Json::as_array)
1351                    .ok_or_else(|| bad_args("`ops` must be an array"))?;
1352                let ops: Vec<Op> = ops
1353                    .iter()
1354                    .map(|o| Op::from_json(o).map_err(bad_args))
1355                    .collect::<Result<_>>()?;
1356                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1357                let reason = arg_str(args, "reason").map(str::to_owned);
1358                let ts = self.now();
1359                let req = ApplyRequest {
1360                    repo_id: repo.clone(),
1361                    ops,
1362                    origin: ApplyOrigin::new(ACTOR, reason.as_deref()),
1363                    dry_run: dry,
1364                    set_frontmatter: Vec::new(),
1365                };
1366                let res =
1367                    self.with_writes(root.as_deref(), |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1368                if !dry {
1369                    self.notify();
1370                }
1371                Ok(apply_json(&res))
1372            }
1373            "blocks_insert" => {
1374                let (repo, root) = self.scope(args)?;
1375                self.require_root(root.as_deref())?;
1376                let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1377                let at = self.resolve_at(&repo, args.get("at"))?;
1378                let doc = if parent == Parent::Doc {
1379                    Some(doc_id)
1380                } else {
1381                    None
1382                };
1383                let ops = vec![Op::Insert {
1384                    doc,
1385                    to: To { parent, at },
1386                    markdown: arg_string(args, "markdown")?,
1387                    expect: arg_parent_expect(args)?,
1388                }];
1389                Ok(apply_json(&self.apply_ops(
1390                    &repo,
1391                    root.as_deref(),
1392                    ops,
1393                    "blocks_insert",
1394                    arg_bool(args, "dry_run")?.unwrap_or(false),
1395                )?))
1396            }
1397            "blocks_update" => {
1398                let (repo, root) = self.scope(args)?;
1399                self.require_root(root.as_deref())?;
1400                let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1401                let expect = match arg_expect(args)? {
1402                    Some(e) => Some(e),
1403                    None => self.pin_hash(&block)?.map(Expect::content),
1404                };
1405                let checked = arg_bool(args, "checked")?;
1406                let extra = arg_object(args, "attrs")?;
1407                let attrs = if checked.is_some() || extra.is_some() {
1408                    let mut a = Map::new();
1409                    if let Some(c) = checked {
1410                        a.insert("checked".to_owned(), json!(c));
1411                    }
1412                    if let Some(x) = extra {
1413                        for (k, v) in x {
1414                            a.insert(k.clone(), v.clone());
1415                        }
1416                    }
1417                    Some(a)
1418                } else {
1419                    None
1420                };
1421                let ops = vec![Op::Update {
1422                    block: block.clone(),
1423                    markdown: arg_str(args, "markdown").map(str::to_owned),
1424                    attrs,
1425                    expect,
1426                    trivia: None,
1427                    child_ids: None,
1428                }];
1429                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1430                let res = self.apply_ops(&repo, root.as_deref(), ops, "blocks_update", dry)?;
1431                let ids: Vec<String> = res
1432                    .results
1433                    .first()
1434                    .map_or_else(|| vec![block.clone()], |r| r.ids.clone());
1435                let mut m = Map::new();
1436                m.insert(
1437                    "id".to_owned(),
1438                    json!(ids.first().cloned().unwrap_or(block)),
1439                );
1440                m.insert("ids".to_owned(), json!(ids));
1441                Ok(merge(m, apply_json(&res)))
1442            }
1443            "blocks_move" => {
1444                let (repo, root) = self.scope(args)?;
1445                self.require_root(root.as_deref())?;
1446                let blocks: Vec<String> = arg_strings(args, "blocks")?
1447                    .iter()
1448                    .map(|b| self.resolve_block_ref(&repo, b))
1449                    .collect::<Result<_>>()?;
1450                let to_ref = arg_string(args, "to")?;
1451                let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1452                if parent == Parent::Doc {
1453                    if let Some(first) = blocks.first() {
1454                        if self.doc_id_of_block(first)? != Some(doc_id) {
1455                            return Err(SurfaceError::with_data(
1456                                "target_missing",
1457                                format!(
1458                                    "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1459                                ),
1460                                json!({ "to": to_ref }),
1461                            ));
1462                        }
1463                    }
1464                }
1465                let at = self.resolve_at(&repo, args.get("at"))?;
1466                let ops = vec![Op::Move {
1467                    blocks,
1468                    to: To { parent, at },
1469                    expect: arg_parent_expect(args)?,
1470                }];
1471                Ok(apply_json(&self.apply_ops(
1472                    &repo,
1473                    root.as_deref(),
1474                    ops,
1475                    "blocks_move",
1476                    arg_bool(args, "dry_run")?.unwrap_or(false),
1477                )?))
1478            }
1479            "blocks_remove" => {
1480                let (repo, root) = self.scope(args)?;
1481                self.require_root(root.as_deref())?;
1482                let blocks: Vec<String> = arg_strings(args, "blocks")?
1483                    .iter()
1484                    .map(|b| self.resolve_block_ref(&repo, b))
1485                    .collect::<Result<_>>()?;
1486                let ops = vec![Op::Remove {
1487                    blocks,
1488                    expect: None,
1489                }];
1490                Ok(apply_json(&self.apply_ops(
1491                    &repo,
1492                    root.as_deref(),
1493                    ops,
1494                    "blocks_remove",
1495                    arg_bool(args, "dry_run")?.unwrap_or(false),
1496                )?))
1497            }
1498            "blocks_split" => {
1499                let (repo, root) = self.scope(args)?;
1500                self.require_root(root.as_deref())?;
1501                let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1502                let at: Vec<usize> = args
1503                    .get("at")
1504                    .and_then(Json::as_array)
1505                    .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1506                    .iter()
1507                    .map(|v| {
1508                        v.as_u64()
1509                            .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1510                            .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1511                    })
1512                    .collect::<Result<_>>()?;
1513                // §9: an empty hash when the block has no live row.
1514                let expect = Some(Expect::content(self.pin_hash(&block)?.unwrap_or_default()));
1515                let ops = vec![Op::Split { block, at, expect }];
1516                Ok(apply_json(&self.apply_ops(
1517                    &repo,
1518                    root.as_deref(),
1519                    ops,
1520                    "blocks_split",
1521                    arg_bool(args, "dry_run")?.unwrap_or(false),
1522                )?))
1523            }
1524            "blocks_merge" => {
1525                let (repo, root) = self.scope(args)?;
1526                self.require_root(root.as_deref())?;
1527                let blocks: Vec<String> = arg_strings(args, "blocks")?
1528                    .iter()
1529                    .map(|b| self.resolve_block_ref(&repo, b))
1530                    .collect::<Result<_>>()?;
1531                let ops = vec![Op::Merge {
1532                    blocks,
1533                    separator: arg_str(args, "separator").map(str::to_owned),
1534                    expect: None,
1535                }];
1536                Ok(apply_json(&self.apply_ops(
1537                    &repo,
1538                    root.as_deref(),
1539                    ops,
1540                    "blocks_merge",
1541                    arg_bool(args, "dry_run")?.unwrap_or(false),
1542                )?))
1543            }
1544            "tasks_complete" => {
1545                let (repo, root) = self.scope(args)?;
1546                self.require_root(root.as_deref())?;
1547                let blocks: Vec<String> = arg_strings(args, "blocks")?
1548                    .iter()
1549                    .map(|b| self.resolve_block_ref(&repo, b))
1550                    .collect::<Result<_>>()?;
1551                let ops = if arg_bool(args, "checked")?.unwrap_or(true) {
1552                    self.store.tasks_complete(&blocks)?
1553                } else {
1554                    let mut ops = Vec::with_capacity(blocks.len());
1555                    for b in &blocks {
1556                        let mut a = Map::new();
1557                        a.insert("checked".to_owned(), json!(false));
1558                        ops.push(Op::Update {
1559                            block: b.clone(),
1560                            markdown: None,
1561                            attrs: Some(a),
1562                            expect: self.pin_hash(b)?.map(Expect::content),
1563                            trivia: None,
1564                            child_ids: None,
1565                        });
1566                    }
1567                    ops
1568                };
1569                Ok(apply_json(&self.apply_ops(
1570                    &repo,
1571                    root.as_deref(),
1572                    ops,
1573                    "tasks_complete",
1574                    arg_bool(args, "dry_run")?.unwrap_or(false),
1575                )?))
1576            }
1577            "node_set" => {
1578                let (repo, root) = self.scope(args)?;
1579                self.require_root(root.as_deref())?;
1580                let ops = self.store.node_set(
1581                    &arg_string(args, "node")?,
1582                    &arg_string(args, "prop")?,
1583                    &arg_string(args, "value")?,
1584                )?;
1585                Ok(apply_json(&self.apply_ops(
1586                    &repo,
1587                    root.as_deref(),
1588                    ops,
1589                    "node_set",
1590                    arg_bool(args, "dry_run")?.unwrap_or(false),
1591                )?))
1592            }
1593            "sections_append" => {
1594                let (repo, root) = self.scope(args)?;
1595                self.require_root(root.as_deref())?;
1596                let heading = self.resolve_heading_id(
1597                    &repo,
1598                    &arg_string(args, "heading")?,
1599                    arg_str(args, "doc"),
1600                    arg_str(args, "path"),
1601                )?;
1602                let ops = Store::sections_append(&heading, &arg_string(args, "markdown")?);
1603                Ok(apply_json(&self.apply_ops(
1604                    &repo,
1605                    root.as_deref(),
1606                    ops,
1607                    "sections_append",
1608                    arg_bool(args, "dry_run")?.unwrap_or(false),
1609                )?))
1610            }
1611            "docs_append" => {
1612                let (repo, root) = self.scope(args)?;
1613                self.require_root(root.as_deref())?;
1614                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1615                let ops = Store::docs_append(&doc_id, &arg_string(args, "text")?);
1616                Ok(apply_json(&self.apply_ops(
1617                    &repo,
1618                    root.as_deref(),
1619                    ops,
1620                    "docs_append",
1621                    false,
1622                )?))
1623            }
1624            "links_retarget" | "links_repair" => {
1625                let (repo, root) = self.scope(args)?;
1626                self.require_root(root.as_deref())?;
1627                let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1628                    vec![omgbase_store::LinkRepair {
1629                        from: arg_string(args, "from_target")?,
1630                        to: arg_string(args, "to_target")?,
1631                    }]
1632                } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1633                    list.iter()
1634                        .map(|r| {
1635                            Ok(omgbase_store::LinkRepair {
1636                                from: arg_string(r, "from")?,
1637                                to: arg_string(r, "to")?,
1638                            })
1639                        })
1640                        .collect::<Result<_>>()?
1641                } else if let (Some(f), Some(t)) =
1642                    (arg_str(args, "from_target"), arg_str(args, "to_target"))
1643                {
1644                    vec![omgbase_store::LinkRepair {
1645                        from: f.to_owned(),
1646                        to: t.to_owned(),
1647                    }]
1648                } else {
1649                    Vec::new()
1650                };
1651                if repairs.is_empty() {
1652                    return Err(SurfaceError::new(
1653                        "target_missing",
1654                        "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1655                    ));
1656                }
1657                let plan = self.store.links_repair(
1658                    &repo,
1659                    &repairs,
1660                    arg_str(args, "path_glob").filter(|g| !g.is_empty()),
1661                )?;
1662                let dry = arg_bool(args, "dry_run")?.unwrap_or(true);
1663                let res = self.apply_ops(&repo, root.as_deref(), plan.ops.clone(), name, dry)?;
1664                let mut m = Map::new();
1665                m.insert(
1666                    "hits".to_owned(),
1667                    Json::Array(plan.hits.iter().map(|h| json!({ "block": h.block, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1668                );
1669                m.insert(
1670                    "pairs".to_owned(),
1671                    Json::Array(
1672                        plan.pairs
1673                            .iter()
1674                            .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1675                            .collect(),
1676                    ),
1677                );
1678                m.insert("applied".to_owned(), json!(!dry));
1679                Ok(merge(m, apply_json(&res)))
1680            }
1681            "links_stale" => {
1682                let (repo, _) = self.scope(args)?;
1683                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1684                if arg_bool(args, "summary")?.unwrap_or(false) {
1685                    return links::links_stale_summary(&self.store, &repo, glob);
1686                }
1687                links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
1688            }
1689            "docs_create" => {
1690                let (repo, root) = self.scope(args)?;
1691                let ctx = self.doc_ctx(&repo);
1692                let path = arg_string(args, "path")?;
1693                let markdown = arg_string(args, "markdown")?;
1694                let fm = arg_object(args, "frontmatter")?.cloned();
1695                let res = self.with_writes(root.as_deref(), |store, ds| {
1696                    Ok(store.docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?)
1697                })?;
1698                self.notify();
1699                Ok(doc_op_json(&res))
1700            }
1701            "docs_move" => {
1702                let (repo, root) = self.scope(args)?;
1703                let ctx = self.doc_ctx(&repo);
1704                let doc = arg_string(args, "doc")?;
1705                let to = arg_string(args, "to_path")?;
1706                let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(false);
1707                let res = self.with_writes(root.as_deref(), |store, ds| {
1708                    Ok(store.docs_move(&ctx, ds, &doc, &to, retarget)?)
1709                })?;
1710                self.notify();
1711                Ok(json!({
1712                    "docId": res.doc_id,
1713                    "path": res.path,
1714                    "committed": res.committed,
1715                    "dangling": res.dangling.iter().map(omgbase_store::InboundLink::to_json).collect::<Vec<_>>(),
1716                    "retargeted": res.retargeted.as_ref().map(|r| json!({ "blocks": r.blocks, "docs": r.docs })),
1717                }))
1718            }
1719            "docs_delete" => {
1720                let (repo, root) = self.scope(args)?;
1721                let ctx = self.doc_ctx(&repo);
1722                let doc = arg_string(args, "doc")?;
1723                let res = self.with_writes(root.as_deref(), |store, ds| {
1724                    Ok(store.docs_delete(&ctx, ds, &doc)?)
1725                })?;
1726                self.notify();
1727                Ok(doc_op_json(&res))
1728            }
1729            "docs_set_meta" => {
1730                let (repo, root) = self.scope(args)?;
1731                let ctx = self.doc_ctx(&repo);
1732                let doc = arg_string(args, "doc")?;
1733                let set = arg_object(args, "set")?.cloned();
1734                let unset = if args.get("unset").is_some() {
1735                    arg_strings(args, "unset")?
1736                } else {
1737                    Vec::new()
1738                };
1739                let res = self.with_writes(root.as_deref(), |store, ds| {
1740                    Ok(store.docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?)
1741                })?;
1742                self.notify();
1743                Ok(doc_op_json(&res))
1744            }
1745            "docs_plan_update" => {
1746                let (repo, root) = self.scope(args)?;
1747                self.require_root(root.as_deref())?;
1748                let doc = arg_string(args, "doc")?;
1749                let content = arg_string(args, "content")?;
1750                let opset = self
1751                    .store
1752                    .plan_update(&repo, &doc, &content, &self.config.clone())?;
1753                Ok(json!({ "opset": opset_json(&opset), "plan": render_opset_plan(&opset) }))
1754            }
1755            "docs_update" => {
1756                let (repo, root) = self.scope(args)?;
1757                let doc = arg_string(args, "doc")?;
1758                let content = arg_string(args, "content")?;
1759                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1760                let reason = arg_str(args, "reason").map(str::to_owned);
1761                let origin = ApplyOrigin::new(ACTOR, reason.as_deref());
1762                let config = self.config.clone();
1763                let ts = self.now();
1764                let (opset, result) = self.with_writes(root.as_deref(), |store, ds| {
1765                    Ok(store.docs_update(&repo, &doc, &content, &config, &origin, dry, ds, &ts)?)
1766                })?;
1767                if !dry {
1768                    self.notify();
1769                }
1770                Ok(json!({
1771                    "opset": opset_json(&opset),
1772                    "plan": render_opset_plan(&opset),
1773                    "result": result.map(|r| apply_json(&r)),
1774                }))
1775            }
1776            "observe" => {
1777                let (repo, _) = self.scope(args)?;
1778                let path = arg_string(args, "path")?;
1779                let content = arg_string(args, "content")?;
1780                let ts = self.now();
1781                let config = self.config.clone();
1782                let out = self
1783                    .store
1784                    .observe_one(&repo, &path, &content, &ts, &config)?;
1785                self.store.sweep_pool(&ts)?;
1786                self.notify();
1787                Ok(observe_json(&out))
1788            }
1789            "observe_many" => {
1790                let (repo, _) = self.scope(args)?;
1791                let files = args
1792                    .get("files")
1793                    .and_then(Json::as_array)
1794                    .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
1795                let items: Vec<omgbase_store::BatchItem> = files
1796                    .iter()
1797                    .map(|f| {
1798                        Ok(omgbase_store::BatchItem::observed(
1799                            &arg_string(f, "path")?,
1800                            &arg_string(f, "content")?,
1801                        ))
1802                    })
1803                    .collect::<Result<_>>()?;
1804                let ts = self.now();
1805                let config = self.config.clone();
1806                let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
1807                self.store.sweep_pool(&ts)?;
1808                self.notify();
1809                let mut out = Vec::with_capacity(outcomes.len());
1810                for o in &outcomes {
1811                    match o.as_observed() {
1812                        Some(obs) => out.push(observe_json(obs)),
1813                        None => {
1814                            return Err(SurfaceError::other(format!(
1815                                "observe_many: unexpected outcome for {}",
1816                                o.path()
1817                            )));
1818                        }
1819                    }
1820                }
1821                Ok(Json::Array(out))
1822            }
1823            "observe_delete" => {
1824                let (repo, _) = self.scope(args)?;
1825                let path = arg_string(args, "path")?;
1826                let ts = self.now();
1827                let out = self.store.observe_delete(&repo, &path, &ts)?;
1828                self.notify();
1829                Ok(json!({ "docId": out.doc_id, "path": out.path, "deleted": out.deleted() }))
1830            }
1831            "history_node" => history::history_node(
1832                &self.store,
1833                &arg_string(args, "id")?,
1834                arg_i64(args, "limit")?,
1835            ),
1836            "diff" => {
1837                let (repo, _) = self.scope(args)?;
1838                let doc_id = self.resolve_doc_id(&repo, arg_str(args, "doc"), None, None)?;
1839                history::diff_blocks(
1840                    &self.store,
1841                    &doc_id,
1842                    &arg_string(args, "from_rev")?,
1843                    &arg_string(args, "to_rev")?,
1844                )
1845            }
1846            "diff_unified" => {
1847                let (repo, _) = self.scope(args)?;
1848                let doc_ref = arg_string(args, "doc")?;
1849                let doc_id = self.resolve_doc_id(&repo, Some(&doc_ref), None, None)?;
1850                let revs = history::recent_revs(self.store.conn(), &doc_id)?;
1851                let to_rev = arg_str(args, "to_rev")
1852                    .map(str::to_owned)
1853                    .or_else(|| revs.first().cloned());
1854                let from_rev = arg_str(args, "from_rev")
1855                    .map(str::to_owned)
1856                    .or_else(|| revs.get(1).cloned())
1857                    .or_else(|| revs.first().cloned());
1858                let (Some(from), Some(to)) = (from_rev, to_rev) else {
1859                    return Err(SurfaceError::new(
1860                        "target_missing",
1861                        format!("no revisions to diff for {}", Json::String(doc_ref)),
1862                    ));
1863                };
1864                let path: Option<String> = self
1865                    .store
1866                    .conn()
1867                    .query_row(
1868                        "SELECT path FROM docs WHERE doc_id = ?1",
1869                        params![doc_id],
1870                        |r| r.get(0),
1871                    )
1872                    .optional()?;
1873                let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
1874                Ok(
1875                    json!({ "doc": doc_id, "path": path.unwrap_or_default(), "from": from, "to": to, "diff": diff }),
1876                )
1877            }
1878            "docs_read_at" => {
1879                let (repo, _) = self.scope(args)?;
1880                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1881                let rev = arg_string(args, "rev")?;
1882                read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
1883                    SurfaceError::with_data(
1884                        "target_missing",
1885                        format!(
1886                            "no revision {} for document {doc_id}",
1887                            Json::String(rev.clone())
1888                        ),
1889                        json!({ "doc": doc_id, "rev": rev }),
1890                    )
1891                })
1892            }
1893            "docs_history" => {
1894                let (repo, _) = self.scope(args)?;
1895                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1896                let doc = arg_str(args, "doc").filter(|d| !d.is_empty());
1897                if glob.is_none() && doc.is_none() {
1898                    return Err(SurfaceError::new(
1899                        "target_missing",
1900                        "docs_history requires one of path_glob or doc",
1901                    ));
1902                }
1903                let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
1904                if let Some(d) = doc {
1905                    if history::resolve_doc_row(self.store.conn(), &repo, d, include_deleted)?
1906                        .is_none()
1907                    {
1908                        return Err(SurfaceError::new(
1909                            "doc_missing",
1910                            format!("no document for {}", Json::String(d.to_owned())),
1911                        ));
1912                    }
1913                }
1914                history::docs_history(
1915                    &self.store,
1916                    &repo,
1917                    glob,
1918                    doc,
1919                    include_deleted,
1920                    arg_i64(args, "limit")?,
1921                )
1922            }
1923            "changes_since" => {
1924                let (repo, _) = self.scope(args)?;
1925                let page = self.store.changes_since(
1926                    &repo,
1927                    arg_i64(args, "cursor")?.unwrap_or(0),
1928                    arg_usize(args, "limit")?.unwrap_or(50),
1929                    arg_str(args, "origin").filter(|o| !o.is_empty()),
1930                )?;
1931                Ok(page.to_json())
1932            }
1933            "repos_status" => {
1934                let (repo, root) = self.scope(args)?;
1935                let fs = RealFileSystem;
1936                let disk = root
1937                    .as_deref()
1938                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
1939                Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
1940            }
1941            "sync_status" => {
1942                let (repo, root) = self.scope(args)?;
1943                let fs = RealFileSystem;
1944                let disk = root
1945                    .as_deref()
1946                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
1947                Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
1948            }
1949            "repos" => {
1950                let rows = self.repo_rows()?;
1951                Ok(json!({
1952                    "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
1953                }))
1954            }
1955            other => Err(SurfaceError::other(format!("unknown tool {other}"))),
1956        }
1957    }
1958}
1959
1960/// `spec/mutate` §4's result on the wire: `{ results: [{ ids, removed?,
1961/// mergedInto? }], revisions, diffs?, committed }` (camelCase, as the
1962/// reference).
1963#[must_use]
1964pub fn apply_json(res: &ApplyResult) -> Json {
1965    let mut m = Map::new();
1966    m.insert(
1967        "results".to_owned(),
1968        Json::Array(
1969            res.results
1970                .iter()
1971                .map(|r| {
1972                    let mut o = Map::new();
1973                    o.insert("ids".to_owned(), json!(r.ids));
1974                    if let Some(rm) = &r.removed {
1975                        o.insert("removed".to_owned(), json!(rm));
1976                    }
1977                    if let Some(mi) = &r.merged_into {
1978                        o.insert("mergedInto".to_owned(), json!(mi));
1979                    }
1980                    Json::Object(o)
1981                })
1982                .collect(),
1983        ),
1984    );
1985    m.insert(
1986        "revisions".to_owned(),
1987        Json::Array(
1988            res.revisions
1989                .iter()
1990                .map(|r| json!({ "doc": r.doc, "path": r.path }))
1991                .collect(),
1992        ),
1993    );
1994    if let Some(diffs) = &res.diffs {
1995        let mut d = Map::new();
1996        for (path, diff) in diffs {
1997            d.insert(
1998                path.clone(),
1999                json!({ "before": diff.before, "after": diff.after }),
2000            );
2001        }
2002        m.insert("diffs".to_owned(), Json::Object(d));
2003    }
2004    m.insert("committed".to_owned(), json!(res.committed));
2005    Json::Object(m)
2006}
2007
2008/// A document operation's result: `{ docId, path, committed }`.
2009fn doc_op_json(res: &omgbase_store::DocOpResult) -> Json {
2010    json!({ "docId": res.doc_id, "path": res.path, "committed": res.committed })
2011}
2012
2013/// `spec/mutate` §7's opset on the wire (camelCase precondition keys and
2014/// `matcherV`, as the reference).
2015#[must_use]
2016pub fn opset_json(opset: &Opset) -> Json {
2017    let mut m = Map::new();
2018    m.insert("version".to_owned(), json!(1));
2019    m.insert("kind".to_owned(), json!("doc_update"));
2020    m.insert(
2021        "target".to_owned(),
2022        json!({ "doc": opset.target_doc, "path": opset.target_path }),
2023    );
2024    m.insert(
2025        "precondition".to_owned(),
2026        json!({
2027            "doc": opset.precondition.doc,
2028            "path": opset.precondition.path,
2029            "baseRevision": opset.precondition.base_revision,
2030            "baseContentHash": opset.precondition.base_content_hash,
2031        }),
2032    );
2033    m.insert("matcherV".to_owned(), json!(opset.matcher_v));
2034    m.insert(
2035        "ops".to_owned(),
2036        Json::Array(
2037            opset
2038                .ops
2039                .iter()
2040                .map(|p| {
2041                    let mut o = Map::new();
2042                    // The kernel's JSON spells the carried ids `child_ids`
2043                    // (the `spec/mutate` fixture form); the wire is camelCase
2044                    // like every other key here (`spec/surface` §9;
2045                    // `Op::from_json` accepts both on the way back in).
2046                    let mut op = p.op.to_json();
2047                    if let Some(m) = op.as_object_mut()
2048                        && let Some(c) = m.remove("child_ids")
2049                    {
2050                        m.insert("childIds".to_owned(), c);
2051                    }
2052                    o.insert("op".to_owned(), op);
2053                    o.insert("disposition".to_owned(), json!(p.disposition.as_str()));
2054                    o.insert("blocks".to_owned(), json!(p.blocks));
2055                    o.insert("confidence".to_owned(), json!(p.confidence));
2056                    o.insert("reason".to_owned(), json!(p.reason));
2057                    if let Some(d) = &p.detail {
2058                        o.insert("detail".to_owned(), d.clone());
2059                    }
2060                    Json::Object(o)
2061                })
2062                .collect(),
2063        ),
2064    );
2065    if let Some(fm) = &opset.frontmatter {
2066        m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
2067    }
2068    m.insert("summary".to_owned(), opset.summary.to_json());
2069    m.insert("converges".to_owned(), json!(opset.converges));
2070    m.insert("diagnostics".to_owned(), json!(opset.diagnostics));
2071    Json::Object(m)
2072}
2073
2074/// `spec/search` §4's evidence on the wire: `{ rrf, boosts, ftsRank?,
2075/// vectorRank?, cosine? }`.
2076fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2077    let mut m = Map::new();
2078    m.insert("rrf".to_owned(), json!(e.rrf));
2079    m.insert("boosts".to_owned(), e.boosts.to_json());
2080    if let Some(r) = e.fts_rank {
2081        m.insert("ftsRank".to_owned(), json!(r));
2082    }
2083    if let Some(r) = e.vector_rank {
2084        m.insert("vectorRank".to_owned(), json!(r));
2085        if let Some(c) = e.cosine {
2086            m.insert("cosine".to_owned(), json!(c));
2087        }
2088    }
2089    Json::Object(m)
2090}
2091
2092/// The public `observe` result: `{ docId, path, rev, commitId, converged,
2093/// echo, conflicted, dispositions: [{ kind, count }] }`.
2094fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2095    json!({
2096        "docId": o.doc_id,
2097        "path": o.path,
2098        "rev": o.rev,
2099        "commitId": o.commit_id,
2100        "converged": o.converged,
2101        "echo": o.echo,
2102        "conflicted": o.conflicted,
2103        "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2104    })
2105}
2106
2107fn verb(d: omgbase_store::mutate_kernel::PlanDisposition) -> &'static str {
2108    use omgbase_store::mutate_kernel::PlanDisposition as D;
2109    match d {
2110        D::Same => "KEEP  ",
2111        D::Edited | D::EditedMoved => "UPDATE",
2112        D::Moved => "MOVE  ",
2113        D::Inserted => "INSERT",
2114        D::Deleted => "REMOVE",
2115        D::SplitFrom => "SPLIT ",
2116        D::MergedInto => "MERGE ",
2117        D::CopiedFrom => "COPY  ",
2118        D::Resurrected => "RESURR",
2119        D::BulkRewrite => "REWRITE",
2120        D::Retiled => "RETILE",
2121    }
2122}
2123
2124/// The one-line-per-op plan text of `docs_plan_update` / `docs_update`.
2125#[must_use]
2126pub fn render_opset_plan(opset: &Opset) -> String {
2127    let mut lines = Vec::new();
2128    for p in &opset.ops {
2129        let subject = p.blocks.first().map_or("(new)", String::as_str);
2130        let conf = p.confidence.map_or(String::new(), |c| format!(" ~{c:.2}"));
2131        let why = p
2132            .reason
2133            .as_ref()
2134            .map_or(String::new(), |r| format!(" [{r}]"));
2135        lines.push(format!(
2136            "{} {:<9} {}{conf}{why}",
2137            verb(p.disposition),
2138            subject,
2139            p.disposition.as_str()
2140        ));
2141    }
2142    let s = &opset.summary;
2143    lines.push(String::new());
2144    lines.push(format!(
2145        "preserved: {}  updated: {}  moved: {}  created: {}  removed: {}  split: {}  merged: {}  ambiguous: {}",
2146        s.preserved, s.updated, s.moved, s.created, s.removed, s.split, s.merged, s.ambiguous
2147    ));
2148    if !opset.converges {
2149        lines.push(
2150            "WARNING: plan does not reproduce the proposed content exactly — will not apply."
2151                .to_owned(),
2152        );
2153    }
2154    lines.join("\n")
2155}
2156
2157#[cfg(test)]
2158mod tests {
2159    use super::*;
2160    use omgbase_store::{MemDocStore, SequentialMinter};
2161
2162    fn surface() -> Surface {
2163        let mut store =
2164            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2165        let repo = store.create_repo("fixture").unwrap();
2166        let mut n = 0;
2167        Surface::new(store, &repo, None)
2168            .with_doc_store(Box::new(MemDocStore::new()))
2169            .with_clock(move || {
2170                n += 1;
2171                format!("2026-09-26T10:{n:02}:00.000Z")
2172            })
2173    }
2174
2175    #[test]
2176    fn catalog_lists_every_tool_of_the_table() {
2177        let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2178        for want in [
2179            "docs_outline",
2180            "docs_read",
2181            "docs_get_many",
2182            "nodes_get",
2183            "nodes_get_many",
2184            "read_ref",
2185            "docs_tree",
2186            "docs_list",
2187            "query_syntax",
2188            "query",
2189            "graph",
2190            "text_search",
2191            "resolve",
2192            "apply",
2193            "blocks_insert",
2194            "blocks_update",
2195            "blocks_move",
2196            "blocks_remove",
2197            "blocks_split",
2198            "blocks_merge",
2199            "tasks_complete",
2200            "node_set",
2201            "sections_append",
2202            "docs_append",
2203            "links_retarget",
2204            "links_stale",
2205            "links_repair",
2206            "docs_create",
2207            "docs_move",
2208            "docs_delete",
2209            "docs_set_meta",
2210            "docs_plan_update",
2211            "docs_update",
2212            "observe",
2213            "observe_many",
2214            "observe_delete",
2215            "history_node",
2216            "diff",
2217            "diff_unified",
2218            "docs_read_at",
2219            "docs_history",
2220            "changes_since",
2221            "repos_status",
2222            "sync_status",
2223            "repos",
2224        ] {
2225            assert!(names.contains(&want), "missing {want}");
2226        }
2227        assert_eq!(names.len(), 45);
2228        for t in tools() {
2229            assert_eq!(t.input_schema["type"], "object");
2230        }
2231    }
2232
2233    #[test]
2234    fn observe_read_and_query_round_trip() {
2235        let mut s = surface();
2236        let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2237        assert!(!out.is_error, "{}", out.body);
2238        assert_eq!(out.body["docId"], "d_0");
2239        assert_eq!(out.body["echo"], false);
2240        let read = s.call("docs_read", json!({ "doc": "a.md", "include_ids": true }));
2241        assert!(!read.is_error);
2242        assert_eq!(read.body["path"], "a.md");
2243        assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2244        assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2245        let q = s.call("query", json!({ "query": "select $title, layer, t: nodes collect { value where kind == \"md:task\" } from docs where layer == \"canon\" && nodes count { where kind == \"md:task\" } == 1" }));
2246        assert!(!q.is_error, "{}", q.body);
2247        assert_eq!(q.body["hits"][0]["$title"], "Title");
2248        assert_eq!(q.body["hits"][0]["layer"], "canon");
2249        assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2250        assert_eq!(q.body["hits"][0]["id"], "d_0");
2251        assert_eq!(q.body["consumer"], "collect");
2252        let c = s.call(
2253            "query",
2254            json!({ "query": "$repo.blocks count { where text(\"hello\") }" }),
2255        );
2256        assert_eq!(c.body["count"], 1);
2257        let bad = s.call(
2258            "query",
2259            json!({ "query": "from docs where path == \"a.md\"" }),
2260        );
2261        assert!(bad.is_error);
2262        assert_eq!(bad.body["error"], "filter_invalid");
2263        assert!(
2264            bad.body["message"]
2265                .as_str()
2266                .unwrap()
2267                .contains("did you mean the intrinsic $path")
2268        );
2269        let sem = s.call(
2270            "query",
2271            json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2272        );
2273        assert_eq!(sem.body["error"], "semantic_unavailable");
2274        let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2275        let text = outline.body["text"].as_str().unwrap();
2276        assert!(text.starts_with("b_0 h1   Title  §"), "{text}");
2277        assert!(text.contains("☐ task one"));
2278        let missing = s.call("docs_read", json!({ "doc": "nope.md" }));
2279        assert_eq!(missing.body["error"], "doc_missing");
2280        let repos = s.call("repos", json!({}));
2281        assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2282        let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2283        assert_eq!(unknown.body["error"], "repo_not_found");
2284    }
2285
2286    #[test]
2287    fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2288        let mut s = surface();
2289        s.call(
2290            "observe",
2291            json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2292        );
2293        s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2294        // §4 (1.2): a stale `expect.parent_children_hash` on blocks_insert is
2295        // the kernel's destination-parent CAS: `stale_expectation` carrying the
2296        // current hash, from which the caller retries without a read.
2297        let stale = s.call(
2298            "blocks_insert",
2299            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2300        );
2301        assert!(stale.is_error, "{}", stale.body);
2302        assert_eq!(stale.body["error"], "stale_expectation");
2303        assert_eq!(stale.body["retriable"], true);
2304        let current = stale.body["data"]["current"]["parent_children_hash"]
2305            .as_str()
2306            .expect("current hash")
2307            .to_owned();
2308        assert_eq!(current.len(), 64, "{current}");
2309        let ok = s.call(
2310            "blocks_insert",
2311            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2312        );
2313        assert!(!ok.is_error, "{}", ok.body);
2314        assert_eq!(ok.body["committed"], true);
2315        let read = s.call("docs_read", json!({ "doc": "a.md" }));
2316        assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2317        // blocks_move: the destination is checked once for the whole run.
2318        let stale = s.call(
2319            "blocks_move",
2320            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2321        );
2322        assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2323        let current = stale.body["data"]["current"]["parent_children_hash"]
2324            .as_str()
2325            .expect("current hash")
2326            .to_owned();
2327        let ok = s.call(
2328            "blocks_move",
2329            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2330        );
2331        assert!(!ok.is_error, "{}", ok.body);
2332        let read = s.call("docs_read", json!({ "doc": "a.md" }));
2333        assert_eq!(read.body["content"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2334        // A `dry_run` still runs the CAS.
2335        let dry = s.call(
2336            "blocks_move",
2337            json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "dry_run": true, "expect": { "parent_children_hash": "00" } }),
2338        );
2339        assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2340        // §2 (1.2): the budget is applied from the second item on — one item
2341        // always comes back, `truncated` says more remained.
2342        let many = s.call(
2343            "docs_get_many",
2344            json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2345        );
2346        assert_eq!(
2347            many.body["items"].as_array().unwrap().len(),
2348            1,
2349            "{}",
2350            many.body
2351        );
2352        assert_eq!(many.body["items"][0]["path"], "a.md");
2353        assert_eq!(many.body["truncated"], true);
2354        let one = s.call(
2355            "docs_get_many",
2356            json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2357        );
2358        assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2359        assert_eq!(one.body["truncated"], false);
2360        let nodes = s.call(
2361            "nodes_get_many",
2362            json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2363        );
2364        assert_eq!(
2365            nodes.body["nodes"].as_array().unwrap().len(),
2366            1,
2367            "{}",
2368            nodes.body
2369        );
2370        assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2371        assert_eq!(nodes.body["truncated"], true);
2372        assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2373    }
2374
2375    #[test]
2376    fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2377        use std::cell::Cell;
2378        use std::rc::Rc;
2379        let fired = Rc::new(Cell::new(0));
2380        let f2 = Rc::clone(&fired);
2381        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2382        s.call(
2383            "observe",
2384            json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
2385        );
2386        assert_eq!(fired.get(), 1);
2387        let dry = s.call(
2388            "blocks_insert",
2389            json!({ "to": "a.md", "markdown": "Two.", "dry_run": true }),
2390        );
2391        assert!(!dry.is_error, "{}", dry.body);
2392        assert_eq!(dry.body["committed"], false);
2393        assert_eq!(fired.get(), 1, "a dry run never fires the hook");
2394        let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
2395        assert!(!wet.is_error, "{}", wet.body);
2396        assert_eq!(fired.get(), 2);
2397        let read = s.call("docs_read", json!({ "doc": "d_0" }));
2398        assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n");
2399        let upd = s.call(
2400            "blocks_update",
2401            json!({ "block": "b_1", "markdown": "One, edited." }),
2402        );
2403        assert!(!upd.is_error, "{}", upd.body);
2404        assert_eq!(upd.body["id"], "b_1");
2405        let app = s.call(
2406            "sections_append",
2407            json!({ "heading": "T", "markdown": "Three." }),
2408        );
2409        assert!(!app.is_error, "{}", app.body);
2410        let amb = s.call(
2411            "sections_append",
2412            json!({ "heading": "Nope", "markdown": "x" }),
2413        );
2414        assert_eq!(amb.body["error"], "parent_missing");
2415        let hist = s.call("history_node", json!({ "id": "b_1" }));
2416        let entries = hist.body.as_array().unwrap();
2417        assert!(entries.len() >= 2, "{}", hist.body);
2418        assert_eq!(entries[0]["origin"], "api", "newest first");
2419        let du = s.call("diff_unified", json!({ "doc": "a.md" }));
2420        assert!(!du.is_error, "{}", du.body);
2421        assert!(du.body["diff"].as_str().unwrap().contains("+Three."));
2422    }
2423}