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        // Read-path tuning of the surface's own connection; neither pragma
716        // changes a result. The reference runs on better-sqlite3, whose
717        // bundled SQLite is compiled with a 16 MB default page cache
718        // (`SQLITE_DEFAULT_CACHE_SIZE=-16000`); rusqlite's bundled build
719        // keeps the stock 2 MB, so the same root scans and pushed
720        // predicates over a database of tens of MB miss the cache and
721        // `pread` page by page. `temp_store=MEMORY` keeps the sorter of a
722        // `… ORDER BY d.path, b.block_id` scan (tens of thousands of rows)
723        // from spilling its runs to temp files. A failure here only leaves
724        // the connection untuned.
725        let _ = store
726            .conn()
727            .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
728        Self {
729            store,
730            default_repo: default_repo.to_owned(),
731            provider,
732            on_mutation: None,
733            clock: Box::new(omgbase_sync::now_ts),
734            writes: WriteTarget::Derived,
735            config: Config::default(),
736        }
737    }
738
739    /// Route every mutation's file writes through `doc_store` regardless of
740    /// the repo's sources (a runner's in-memory store).
741    #[must_use]
742    pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
743        self.writes = WriteTarget::Fixed(doc_store);
744        self
745    }
746
747    /// Stamp commits with `clock()` instead of the wall clock.
748    #[must_use]
749    pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
750        self.clock = Box::new(clock);
751        self
752    }
753
754    /// Called after every successful write (never after a dry run).
755    #[must_use]
756    pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
757        self.on_mutation = Some(Box::new(hook));
758        self
759    }
760
761    /// The matcher thresholds the observe tools pass through.
762    #[must_use]
763    pub fn with_config(mut self, config: Config) -> Self {
764        self.config = config;
765        self
766    }
767
768    #[must_use]
769    pub fn store(&self) -> &Store {
770        &self.store
771    }
772
773    pub fn store_mut(&mut self) -> &mut Store {
774        &mut self.store
775    }
776
777    #[must_use]
778    pub fn default_repo(&self) -> &str {
779        &self.default_repo
780    }
781
782    /// The catalog.
783    #[must_use]
784    pub fn tools(&self) -> Vec<ToolSpec> {
785        tools()
786    }
787
788    /// Whether `name` is a tool that can commit (a host serializes these
789    /// under the writer lock of `spec/sync` §7; `dry_run` calls still count
790    /// here — only the mutation hook knows a write actually happened).
791    #[must_use]
792    pub fn is_write_tool(name: &str) -> bool {
793        matches!(
794            name,
795            "apply"
796                | "blocks_insert"
797                | "blocks_update"
798                | "blocks_move"
799                | "blocks_remove"
800                | "blocks_split"
801                | "blocks_merge"
802                | "tasks_complete"
803                | "node_set"
804                | "sections_append"
805                | "docs_append"
806                | "links_retarget"
807                | "links_repair"
808                | "docs_create"
809                | "docs_move"
810                | "docs_delete"
811                | "docs_set_meta"
812                | "docs_update"
813                | "observe"
814                | "observe_many"
815                | "observe_delete"
816        )
817    }
818
819    /// Run a tool: its JSON result, or the error envelope.
820    pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
821        match self.call_result(name, &args) {
822            Ok(body) => ToolOutcome {
823                body,
824                is_error: false,
825            },
826            Err(e) => ToolOutcome {
827                body: e.to_json(),
828                is_error: true,
829            },
830        }
831    }
832
833    fn now(&mut self) -> String {
834        (self.clock)()
835    }
836
837    fn notify(&mut self) {
838        if let Some(hook) = &mut self.on_mutation {
839            hook();
840        }
841    }
842
843    // ---- repo scoping (§4) ------------------------------------------------------------
844
845    fn repo_rows(&self) -> Result<Vec<RepoRow>> {
846        Ok(omgbase_sync::workspace::list_repos(&self.store)?)
847    }
848
849    /// `(repo id, derived root)` for the `repo` argument; omitted → the default.
850    fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
851        let rows = self.repo_rows()?;
852        match arg_str(args, "repo") {
853            None => {
854                let root = rows
855                    .iter()
856                    .find(|r| r.repo_id == self.default_repo)
857                    .and_then(|r| r.root_path.clone());
858                Ok((self.default_repo.clone(), root))
859            }
860            Some(slug) => rows
861                .iter()
862                .find(|r| r.slug == slug)
863                .map(|r| (r.repo_id.clone(), r.root_path.clone()))
864                .ok_or_else(|| {
865                    SurfaceError::with_data(
866                        "repo_not_found",
867                        format!("no repo '{slug}' in this workspace"),
868                        json!({ "repo": slug }),
869                    )
870                }),
871        }
872    }
873
874    fn require_root(&self, root: Option<&str>) -> Result<()> {
875        if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
876            Ok(())
877        } else {
878            Err(SurfaceError::new(
879                "repo_not_found",
880                "repo has no filesystem source; mutation disabled",
881            ))
882        }
883    }
884
885    /// Run `f` with the store and the write target for `root`.
886    fn with_writes<T>(
887        &mut self,
888        root: Option<&str>,
889        f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
890    ) -> Result<T> {
891        self.require_root(root)?;
892        match &mut self.writes {
893            WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
894            WriteTarget::Derived => {
895                let mut fs = FsDocStore::new(root.expect("checked by require_root"));
896                f(&mut self.store, &mut fs)
897            }
898        }
899    }
900
901    // ---- ref resolution (§4) ---------------------------------------------------------
902
903    /// The owning doc from `doc` (id or path) / `path` / `block`; `doc_missing` when none.
904    fn resolve_doc_id(
905        &self,
906        repo_id: &str,
907        doc: Option<&str>,
908        path: Option<&str>,
909        block: Option<&str>,
910    ) -> Result<String> {
911        let conn = self.store.conn();
912        let found = if let Some(d) = doc.filter(|d| !d.is_empty()) {
913            read::find_doc_by_ref(conn, repo_id, d)?.map(|i| i.doc_id)
914        } else if let Some(p) = path.filter(|p| !p.is_empty()) {
915            read::find_doc_by_path(conn, repo_id, p)?.map(|i| i.doc_id)
916        } else if let Some(b) = block.filter(|b| !b.is_empty()) {
917            conn.query_row(
918                "SELECT doc_id FROM blocks WHERE block_id = ?1",
919                params![b],
920                |r| r.get::<_, String>(0),
921            )
922            .optional()?
923        } else {
924            None
925        };
926        found.ok_or_else(|| {
927            let mut m = Map::new();
928            if let Some(d) = doc {
929                m.insert("doc".to_owned(), json!(d));
930            }
931            if let Some(p) = path {
932                m.insert("path".to_owned(), json!(p));
933            }
934            if let Some(b) = block {
935                m.insert("block".to_owned(), json!(b));
936            }
937            SurfaceError::with_data(
938                "doc_missing",
939                format!("no document for {}", Json::Object(m.clone())),
940                Json::Object(m),
941            )
942        })
943    }
944
945    fn resolve_doc_from_args(&self, repo_id: &str, args: &Json) -> Result<String> {
946        self.resolve_doc_id(repo_id, arg_str(args, "doc"), arg_str(args, "path"), None)
947    }
948
949    /// `heading` (a heading block id or heading text) → the heading block id.
950    fn resolve_heading_id(
951        &self,
952        repo_id: &str,
953        heading: &str,
954        doc: Option<&str>,
955        path: Option<&str>,
956    ) -> Result<String> {
957        let conn = self.store.conn();
958        let as_block: Option<String> = conn
959            .query_row(
960                "SELECT block_id FROM blocks WHERE block_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
961                params![heading],
962                |r| r.get(0),
963            )
964            .optional()?;
965        if let Some(b) = as_block {
966            return Ok(b);
967        }
968        let want_doc = if doc.is_some_and(|d| !d.is_empty()) || path.is_some_and(|p| !p.is_empty())
969        {
970            Some(self.resolve_doc_id(repo_id, doc, path, None)?)
971        } else {
972            None
973        };
974        let needle = if heading.trim_start_matches([' ', '\t']).starts_with('#') {
975            normalize_visible_text(heading, BlockKind::Heading, 0)
976        } else {
977            normalize_text(heading)
978        };
979        let rows: Vec<(String, String, String)> = match &want_doc {
980            Some(d) => {
981                let mut stmt = conn.prepare(
982                    "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND doc_id = ?2 AND deleted_commit IS NULL",
983                )?;
984                let it = stmt.query_map(params![repo_id, d], |r| {
985                    Ok((r.get(0)?, r.get(1)?, r.get(2)?))
986                })?;
987                it.collect::<std::result::Result<_, _>>()?
988            }
989            None => {
990                let mut stmt = conn.prepare(
991                    "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
992                )?;
993                let it =
994                    stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
995                it.collect::<std::result::Result<_, _>>()?
996            }
997        };
998        let matches: Vec<&(String, String, String)> = rows
999            .iter()
1000            .filter(|(_, _, text)| normalize_text(text) == needle)
1001            .collect();
1002        match matches.len() {
1003            0 => {
1004                let mut data = Map::new();
1005                data.insert("heading".to_owned(), json!(heading));
1006                if let Some(d) = want_doc {
1007                    data.insert("doc".to_owned(), json!(d));
1008                }
1009                Err(SurfaceError::with_data(
1010                    "parent_missing",
1011                    format!("no heading matching {}", Json::String(heading.to_owned())),
1012                    Json::Object(data),
1013                ))
1014            }
1015            1 => Ok(matches[0].0.clone()),
1016            n => Err(SurfaceError::with_data(
1017                "ambiguous_heading",
1018                format!(
1019                    "heading {} matches {n} headings; pass its block id or a doc/path scope",
1020                    Json::String(heading.to_owned())
1021                ),
1022                json!({
1023                    "heading": heading,
1024                    "candidates": matches.iter().map(|(b, d, _)| json!({ "block": b, "doc": d })).collect::<Vec<_>>(),
1025                }),
1026            )),
1027        }
1028    }
1029
1030    /// A ref → a live block id (`block_missing` otherwise).
1031    fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1032        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1033            Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1034            _ => Err(SurfaceError::with_data(
1035                "block_missing",
1036                format!("not a block: {r}"),
1037                json!({ "ref": r }),
1038            )),
1039        }
1040    }
1041
1042    /// A parent ref: a block, or a document ref for its top level.
1043    fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1044        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1045            Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1046            Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1047            None => Err(SurfaceError::with_data(
1048                "block_missing",
1049                format!("not a block or document: {r}"),
1050                json!({ "ref": r }),
1051            )),
1052        }
1053    }
1054
1055    fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1056        Ok(self
1057            .store
1058            .conn()
1059            .query_row(
1060                "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1061                params![block_id],
1062                |r| r.get(0),
1063            )
1064            .optional()?)
1065    }
1066
1067    /// The live block's raw hash (hex) for CAS pinning; `None` when unknown.
1068    fn pin_hash(&self, block_id: &str) -> Result<Option<String>> {
1069        let h: Option<Vec<u8>> = self
1070            .store
1071            .conn()
1072            .query_row(
1073                "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1074                params![block_id],
1075                |r| r.get(0),
1076            )
1077            .optional()?;
1078        Ok(h.map(|h| omgbase_format::hash::hex(&h)))
1079    }
1080
1081    /// The `at` spec with its anchor ref resolved; absent → end.
1082    fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1083        match at {
1084            None | Some(Json::Null) => Ok(At::End),
1085            Some(Json::String(s)) if s == "start" => Ok(At::Start),
1086            Some(Json::String(s)) if s == "end" => Ok(At::End),
1087            Some(Json::Object(o)) => {
1088                if let Some(b) = o.get("before").and_then(Json::as_str) {
1089                    return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1090                }
1091                if let Some(a) = o.get("after").and_then(Json::as_str) {
1092                    return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1093                }
1094                Err(bad_args(
1095                    "`at` must be \"start\", \"end\", {before} or {after}",
1096                ))
1097            }
1098            Some(_) => Err(bad_args(
1099                "`at` must be \"start\", \"end\", {before} or {after}",
1100            )),
1101        }
1102    }
1103
1104    // ---- the apply tail -----------------------------------------------------------------
1105
1106    fn apply_ops(
1107        &mut self,
1108        repo_id: &str,
1109        root: Option<&str>,
1110        ops: Vec<Op>,
1111        reason: &str,
1112        dry_run: bool,
1113    ) -> Result<ApplyResult> {
1114        let ts = self.now();
1115        let req = ApplyRequest {
1116            repo_id: repo_id.to_owned(),
1117            ops,
1118            origin: ApplyOrigin::new(ACTOR, Some(reason)),
1119            dry_run,
1120            set_frontmatter: Vec::new(),
1121        };
1122        let res = self.with_writes(root, |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1123        if !dry_run {
1124            self.notify();
1125        }
1126        Ok(res)
1127    }
1128
1129    fn doc_ctx(&mut self, repo_id: &str) -> DocOpContext {
1130        DocOpContext {
1131            repo_id: repo_id.to_owned(),
1132            actor: Some(ACTOR.to_owned()),
1133            ts: self.now(),
1134        }
1135    }
1136
1137    // ---- dispatch -------------------------------------------------------------------------
1138
1139    /// Run a tool, returning its result or the error.
1140    #[allow(clippy::too_many_lines)]
1141    pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1142        match name {
1143            "docs_outline" => {
1144                let (repo, _) = self.scope(args)?;
1145                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1146                let skeleton = match arg_str(args, "resolution") {
1147                    None | Some("outline") => false,
1148                    Some("skeleton") => true,
1149                    Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1150                };
1151                read::docs_outline(
1152                    &self.store,
1153                    &doc_id,
1154                    skeleton,
1155                    arg_i64(args, "depth")?,
1156                    arg_usize(args, "budget_tokens")?,
1157                )
1158            }
1159            "docs_read" => {
1160                let (repo, _) = self.scope(args)?;
1161                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1162                read::docs_read(
1163                    &self.store,
1164                    &doc_id,
1165                    arg_bool(args, "include_ids")?.unwrap_or(false),
1166                )?
1167                .ok_or_else(|| SurfaceError::new("doc_missing", format!("no document for {args}")))
1168            }
1169            "docs_get_many" => {
1170                let (repo, _) = self.scope(args)?;
1171                let refs = arg_strings(args, "docs")?;
1172                read::docs_read_many(
1173                    &self.store,
1174                    &repo,
1175                    &refs,
1176                    arg_bool(args, "include_ids")?.unwrap_or(false),
1177                    arg_usize(args, "budget_tokens")?,
1178                )
1179            }
1180            "nodes_get" => {
1181                let (repo, _) = self.scope(args)?;
1182                let id = arg_string(args, "id")?;
1183                let doc_id = self.resolve_doc_id(
1184                    &repo,
1185                    arg_str(args, "doc"),
1186                    arg_str(args, "path"),
1187                    Some(&id),
1188                )?;
1189                read::nodes_get(
1190                    &self.store,
1191                    &doc_id,
1192                    &id,
1193                    resolution_arg(args, Resolution::Full)?,
1194                )?
1195                .ok_or_else(|| SurfaceError::new("block_missing", format!("no block {id}")))
1196            }
1197            "nodes_get_many" => {
1198                let (repo, _) = self.scope(args)?;
1199                let ids = arg_strings(args, "ids")?;
1200                let scoped = arg_str(args, "doc").is_some_and(|d| !d.is_empty())
1201                    || arg_str(args, "path").is_some_and(|p| !p.is_empty());
1202                let doc_id = if scoped {
1203                    Some(self.resolve_doc_from_args(&repo, args)?)
1204                } else {
1205                    None
1206                };
1207                read::nodes_get_many(
1208                    &self.store,
1209                    doc_id.as_deref(),
1210                    &ids,
1211                    resolution_arg(args, Resolution::Text)?,
1212                    arg_usize(args, "budget_tokens")?,
1213                )
1214            }
1215            "read_ref" => {
1216                let (repo, _) = self.scope(args)?;
1217                let r = arg_string(args, "ref")?;
1218                let resolved =
1219                    read::resolve_ref(self.store.conn(), &repo, &r)?.ok_or_else(|| {
1220                        SurfaceError::new(
1221                            "doc_missing",
1222                            format!("no document or block for {}", Json::String(r.clone())),
1223                        )
1224                    })?;
1225                match resolved {
1226                    ResolvedRef::Document { doc_id } => {
1227                        let res =
1228                            read::docs_read(&self.store, &doc_id, false)?.ok_or_else(|| {
1229                                SurfaceError::new(
1230                                    "doc_missing",
1231                                    format!("no document for {}", Json::String(r.clone())),
1232                                )
1233                            })?;
1234                        let mut m = Map::new();
1235                        m.insert("kind".to_owned(), json!("document"));
1236                        Ok(merge(m, res))
1237                    }
1238                    ResolvedRef::Block { doc_id, block_id } => {
1239                        let node = read::nodes_get(
1240                            &self.store,
1241                            &doc_id,
1242                            &block_id,
1243                            resolution_arg(args, Resolution::Raw)?,
1244                        )?
1245                        .ok_or_else(|| {
1246                            SurfaceError::new("block_missing", format!("no block {block_id}"))
1247                        })?;
1248                        let mut m = Map::new();
1249                        m.insert("kind".to_owned(), json!("block"));
1250                        Ok(merge(m, node))
1251                    }
1252                }
1253            }
1254            "docs_tree" => {
1255                let (repo, _) = self.scope(args)?;
1256                read::docs_tree(
1257                    &self.store,
1258                    &repo,
1259                    arg_str(args, "path"),
1260                    arg_i64(args, "depth")?,
1261                    arg_i64(args, "limit")?,
1262                    arg_str(args, "cursor"),
1263                    arg_usize(args, "budget_tokens")?,
1264                )
1265            }
1266            "docs_list" => {
1267                let (repo, _) = self.scope(args)?;
1268                read::docs_list(
1269                    &self.store,
1270                    &repo,
1271                    arg_str(args, "path_glob"),
1272                    arg_i64(args, "limit")?,
1273                    arg_str(args, "cursor"),
1274                    arg_usize(args, "budget_tokens")?,
1275                )
1276            }
1277            "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1278            "query" => {
1279                let (repo, _) = self.scope(args)?;
1280                let source = arg_string(args, "query")?;
1281                // A `semantic(...)` query with no provider is `semantic_unavailable`
1282                // here (the runner itself reports `filter_invalid`, §9).
1283                if self.provider.is_none()
1284                    && !crate::query::collect_semantic_phrases(&source).is_empty()
1285                {
1286                    return Err(SurfaceError::new(
1287                        "semantic_unavailable",
1288                        "no embedding provider configured for this server",
1289                    ));
1290                }
1291                let opts = QueryOptions {
1292                    limit: arg_usize(args, "limit")?,
1293                    cursor: arg_str(args, "cursor"),
1294                    provider: self.provider.as_deref(),
1295                    in_memory: false,
1296                };
1297                Ok(query(&self.store, &repo, &source, opts)?.to_json())
1298            }
1299            "graph" => {
1300                let (repo, _) = self.scope(args)?;
1301                let g = GraphArgs {
1302                    roots: arg_strings(args, "roots")?,
1303                    degrees: arg_i64(args, "degrees")?,
1304                    direction: arg_str(args, "direction").map(str::to_owned),
1305                    predicate: arg_str(args, "predicate").map(str::to_owned),
1306                    select: if args.get("select").is_some() {
1307                        arg_strings(args, "select")?
1308                    } else {
1309                        Vec::new()
1310                    },
1311                    max_documents: arg_i64(args, "max_documents")?,
1312                };
1313                if self.provider.is_none()
1314                    && g.select.iter().any(|s| {
1315                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1316                            .is_empty()
1317                    })
1318                {
1319                    return Err(SurfaceError::new(
1320                        "semantic_unavailable",
1321                        "no embedding provider configured for this server",
1322                    ));
1323                }
1324                graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1325            }
1326            "text_search" => {
1327                let (repo, _) = self.scope(args)?;
1328                let q = arg_string(args, "q")?;
1329                let res =
1330                    self.store
1331                        .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1332                Ok(json!({
1333                    "hits": res.hits.iter().map(|h| json!({
1334                        "blockId": h.block_id, "docId": h.doc_id, "path": h.path, "type": h.block_type, "text": h.text, "score": h.score,
1335                    })).collect::<Vec<_>>(),
1336                    "truncated": res.truncated,
1337                }))
1338            }
1339            "resolve" => {
1340                let (repo, _) = self.scope(args)?;
1341                let q = arg_string(args, "query")?;
1342                let vector = match &self.provider {
1343                    Some(p) => Some(QueryVector {
1344                        model: p.model().to_owned(),
1345                        vec: p
1346                            .embed_query(&q)
1347                            .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1348                    }),
1349                    None => None,
1350                };
1351                let hits = self
1352                    .store
1353                    .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1354                Ok(Json::Array(hits.iter().map(|h| json!({
1355                    "id": h.id, "locator": h.locator, "preview": h.preview, "evidence": evidence_json(&h.evidence),
1356                })).collect()))
1357            }
1358            "apply" => {
1359                let (repo, root) = self.scope(args)?;
1360                self.require_root(root.as_deref())?;
1361                let ops = args
1362                    .get("ops")
1363                    .and_then(Json::as_array)
1364                    .ok_or_else(|| bad_args("`ops` must be an array"))?;
1365                let ops: Vec<Op> = ops
1366                    .iter()
1367                    .map(|o| Op::from_json(o).map_err(bad_args))
1368                    .collect::<Result<_>>()?;
1369                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1370                let reason = arg_str(args, "reason").map(str::to_owned);
1371                let ts = self.now();
1372                let req = ApplyRequest {
1373                    repo_id: repo.clone(),
1374                    ops,
1375                    origin: ApplyOrigin::new(ACTOR, reason.as_deref()),
1376                    dry_run: dry,
1377                    set_frontmatter: Vec::new(),
1378                };
1379                let res =
1380                    self.with_writes(root.as_deref(), |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1381                if !dry {
1382                    self.notify();
1383                }
1384                Ok(apply_json(&res))
1385            }
1386            "blocks_insert" => {
1387                let (repo, root) = self.scope(args)?;
1388                self.require_root(root.as_deref())?;
1389                let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1390                let at = self.resolve_at(&repo, args.get("at"))?;
1391                let doc = if parent == Parent::Doc {
1392                    Some(doc_id)
1393                } else {
1394                    None
1395                };
1396                let ops = vec![Op::Insert {
1397                    doc,
1398                    to: To { parent, at },
1399                    markdown: arg_string(args, "markdown")?,
1400                    expect: arg_parent_expect(args)?,
1401                }];
1402                Ok(apply_json(&self.apply_ops(
1403                    &repo,
1404                    root.as_deref(),
1405                    ops,
1406                    "blocks_insert",
1407                    arg_bool(args, "dry_run")?.unwrap_or(false),
1408                )?))
1409            }
1410            "blocks_update" => {
1411                let (repo, root) = self.scope(args)?;
1412                self.require_root(root.as_deref())?;
1413                let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1414                let expect = match arg_expect(args)? {
1415                    Some(e) => Some(e),
1416                    None => self.pin_hash(&block)?.map(Expect::content),
1417                };
1418                let checked = arg_bool(args, "checked")?;
1419                let extra = arg_object(args, "attrs")?;
1420                let attrs = if checked.is_some() || extra.is_some() {
1421                    let mut a = Map::new();
1422                    if let Some(c) = checked {
1423                        a.insert("checked".to_owned(), json!(c));
1424                    }
1425                    if let Some(x) = extra {
1426                        for (k, v) in x {
1427                            a.insert(k.clone(), v.clone());
1428                        }
1429                    }
1430                    Some(a)
1431                } else {
1432                    None
1433                };
1434                let ops = vec![Op::Update {
1435                    block: block.clone(),
1436                    markdown: arg_str(args, "markdown").map(str::to_owned),
1437                    attrs,
1438                    expect,
1439                    trivia: None,
1440                    child_ids: None,
1441                }];
1442                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1443                let res = self.apply_ops(&repo, root.as_deref(), ops, "blocks_update", dry)?;
1444                let ids: Vec<String> = res
1445                    .results
1446                    .first()
1447                    .map_or_else(|| vec![block.clone()], |r| r.ids.clone());
1448                let mut m = Map::new();
1449                m.insert(
1450                    "id".to_owned(),
1451                    json!(ids.first().cloned().unwrap_or(block)),
1452                );
1453                m.insert("ids".to_owned(), json!(ids));
1454                Ok(merge(m, apply_json(&res)))
1455            }
1456            "blocks_move" => {
1457                let (repo, root) = self.scope(args)?;
1458                self.require_root(root.as_deref())?;
1459                let blocks: Vec<String> = arg_strings(args, "blocks")?
1460                    .iter()
1461                    .map(|b| self.resolve_block_ref(&repo, b))
1462                    .collect::<Result<_>>()?;
1463                let to_ref = arg_string(args, "to")?;
1464                let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1465                if parent == Parent::Doc {
1466                    if let Some(first) = blocks.first() {
1467                        if self.doc_id_of_block(first)? != Some(doc_id) {
1468                            return Err(SurfaceError::with_data(
1469                                "target_missing",
1470                                format!(
1471                                    "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1472                                ),
1473                                json!({ "to": to_ref }),
1474                            ));
1475                        }
1476                    }
1477                }
1478                let at = self.resolve_at(&repo, args.get("at"))?;
1479                let ops = vec![Op::Move {
1480                    blocks,
1481                    to: To { parent, at },
1482                    expect: arg_parent_expect(args)?,
1483                }];
1484                Ok(apply_json(&self.apply_ops(
1485                    &repo,
1486                    root.as_deref(),
1487                    ops,
1488                    "blocks_move",
1489                    arg_bool(args, "dry_run")?.unwrap_or(false),
1490                )?))
1491            }
1492            "blocks_remove" => {
1493                let (repo, root) = self.scope(args)?;
1494                self.require_root(root.as_deref())?;
1495                let blocks: Vec<String> = arg_strings(args, "blocks")?
1496                    .iter()
1497                    .map(|b| self.resolve_block_ref(&repo, b))
1498                    .collect::<Result<_>>()?;
1499                let ops = vec![Op::Remove {
1500                    blocks,
1501                    expect: None,
1502                }];
1503                Ok(apply_json(&self.apply_ops(
1504                    &repo,
1505                    root.as_deref(),
1506                    ops,
1507                    "blocks_remove",
1508                    arg_bool(args, "dry_run")?.unwrap_or(false),
1509                )?))
1510            }
1511            "blocks_split" => {
1512                let (repo, root) = self.scope(args)?;
1513                self.require_root(root.as_deref())?;
1514                let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1515                let at: Vec<usize> = args
1516                    .get("at")
1517                    .and_then(Json::as_array)
1518                    .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1519                    .iter()
1520                    .map(|v| {
1521                        v.as_u64()
1522                            .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1523                            .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1524                    })
1525                    .collect::<Result<_>>()?;
1526                // §9: an empty hash when the block has no live row.
1527                let expect = Some(Expect::content(self.pin_hash(&block)?.unwrap_or_default()));
1528                let ops = vec![Op::Split { block, at, expect }];
1529                Ok(apply_json(&self.apply_ops(
1530                    &repo,
1531                    root.as_deref(),
1532                    ops,
1533                    "blocks_split",
1534                    arg_bool(args, "dry_run")?.unwrap_or(false),
1535                )?))
1536            }
1537            "blocks_merge" => {
1538                let (repo, root) = self.scope(args)?;
1539                self.require_root(root.as_deref())?;
1540                let blocks: Vec<String> = arg_strings(args, "blocks")?
1541                    .iter()
1542                    .map(|b| self.resolve_block_ref(&repo, b))
1543                    .collect::<Result<_>>()?;
1544                let ops = vec![Op::Merge {
1545                    blocks,
1546                    separator: arg_str(args, "separator").map(str::to_owned),
1547                    expect: None,
1548                }];
1549                Ok(apply_json(&self.apply_ops(
1550                    &repo,
1551                    root.as_deref(),
1552                    ops,
1553                    "blocks_merge",
1554                    arg_bool(args, "dry_run")?.unwrap_or(false),
1555                )?))
1556            }
1557            "tasks_complete" => {
1558                let (repo, root) = self.scope(args)?;
1559                self.require_root(root.as_deref())?;
1560                let blocks: Vec<String> = arg_strings(args, "blocks")?
1561                    .iter()
1562                    .map(|b| self.resolve_block_ref(&repo, b))
1563                    .collect::<Result<_>>()?;
1564                let ops = if arg_bool(args, "checked")?.unwrap_or(true) {
1565                    self.store.tasks_complete(&blocks)?
1566                } else {
1567                    let mut ops = Vec::with_capacity(blocks.len());
1568                    for b in &blocks {
1569                        let mut a = Map::new();
1570                        a.insert("checked".to_owned(), json!(false));
1571                        ops.push(Op::Update {
1572                            block: b.clone(),
1573                            markdown: None,
1574                            attrs: Some(a),
1575                            expect: self.pin_hash(b)?.map(Expect::content),
1576                            trivia: None,
1577                            child_ids: None,
1578                        });
1579                    }
1580                    ops
1581                };
1582                Ok(apply_json(&self.apply_ops(
1583                    &repo,
1584                    root.as_deref(),
1585                    ops,
1586                    "tasks_complete",
1587                    arg_bool(args, "dry_run")?.unwrap_or(false),
1588                )?))
1589            }
1590            "node_set" => {
1591                let (repo, root) = self.scope(args)?;
1592                self.require_root(root.as_deref())?;
1593                let ops = self.store.node_set(
1594                    &arg_string(args, "node")?,
1595                    &arg_string(args, "prop")?,
1596                    &arg_string(args, "value")?,
1597                )?;
1598                Ok(apply_json(&self.apply_ops(
1599                    &repo,
1600                    root.as_deref(),
1601                    ops,
1602                    "node_set",
1603                    arg_bool(args, "dry_run")?.unwrap_or(false),
1604                )?))
1605            }
1606            "sections_append" => {
1607                let (repo, root) = self.scope(args)?;
1608                self.require_root(root.as_deref())?;
1609                let heading = self.resolve_heading_id(
1610                    &repo,
1611                    &arg_string(args, "heading")?,
1612                    arg_str(args, "doc"),
1613                    arg_str(args, "path"),
1614                )?;
1615                let ops = Store::sections_append(&heading, &arg_string(args, "markdown")?);
1616                Ok(apply_json(&self.apply_ops(
1617                    &repo,
1618                    root.as_deref(),
1619                    ops,
1620                    "sections_append",
1621                    arg_bool(args, "dry_run")?.unwrap_or(false),
1622                )?))
1623            }
1624            "docs_append" => {
1625                let (repo, root) = self.scope(args)?;
1626                self.require_root(root.as_deref())?;
1627                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1628                let ops = Store::docs_append(&doc_id, &arg_string(args, "text")?);
1629                Ok(apply_json(&self.apply_ops(
1630                    &repo,
1631                    root.as_deref(),
1632                    ops,
1633                    "docs_append",
1634                    false,
1635                )?))
1636            }
1637            "links_retarget" | "links_repair" => {
1638                let (repo, root) = self.scope(args)?;
1639                self.require_root(root.as_deref())?;
1640                let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1641                    vec![omgbase_store::LinkRepair {
1642                        from: arg_string(args, "from_target")?,
1643                        to: arg_string(args, "to_target")?,
1644                    }]
1645                } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1646                    list.iter()
1647                        .map(|r| {
1648                            Ok(omgbase_store::LinkRepair {
1649                                from: arg_string(r, "from")?,
1650                                to: arg_string(r, "to")?,
1651                            })
1652                        })
1653                        .collect::<Result<_>>()?
1654                } else if let (Some(f), Some(t)) =
1655                    (arg_str(args, "from_target"), arg_str(args, "to_target"))
1656                {
1657                    vec![omgbase_store::LinkRepair {
1658                        from: f.to_owned(),
1659                        to: t.to_owned(),
1660                    }]
1661                } else {
1662                    Vec::new()
1663                };
1664                if repairs.is_empty() {
1665                    return Err(SurfaceError::new(
1666                        "target_missing",
1667                        "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1668                    ));
1669                }
1670                let plan = self.store.links_repair(
1671                    &repo,
1672                    &repairs,
1673                    arg_str(args, "path_glob").filter(|g| !g.is_empty()),
1674                )?;
1675                let dry = arg_bool(args, "dry_run")?.unwrap_or(true);
1676                let res = self.apply_ops(&repo, root.as_deref(), plan.ops.clone(), name, dry)?;
1677                let mut m = Map::new();
1678                m.insert(
1679                    "hits".to_owned(),
1680                    Json::Array(plan.hits.iter().map(|h| json!({ "block": h.block, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1681                );
1682                m.insert(
1683                    "pairs".to_owned(),
1684                    Json::Array(
1685                        plan.pairs
1686                            .iter()
1687                            .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1688                            .collect(),
1689                    ),
1690                );
1691                m.insert("applied".to_owned(), json!(!dry));
1692                Ok(merge(m, apply_json(&res)))
1693            }
1694            "links_stale" => {
1695                let (repo, _) = self.scope(args)?;
1696                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1697                if arg_bool(args, "summary")?.unwrap_or(false) {
1698                    return links::links_stale_summary(&self.store, &repo, glob);
1699                }
1700                links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
1701            }
1702            "docs_create" => {
1703                let (repo, root) = self.scope(args)?;
1704                let ctx = self.doc_ctx(&repo);
1705                let path = arg_string(args, "path")?;
1706                let markdown = arg_string(args, "markdown")?;
1707                let fm = arg_object(args, "frontmatter")?.cloned();
1708                let res = self.with_writes(root.as_deref(), |store, ds| {
1709                    Ok(store.docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?)
1710                })?;
1711                self.notify();
1712                Ok(doc_op_json(&res))
1713            }
1714            "docs_move" => {
1715                let (repo, root) = self.scope(args)?;
1716                let ctx = self.doc_ctx(&repo);
1717                let doc = arg_string(args, "doc")?;
1718                let to = arg_string(args, "to_path")?;
1719                let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(false);
1720                let res = self.with_writes(root.as_deref(), |store, ds| {
1721                    Ok(store.docs_move(&ctx, ds, &doc, &to, retarget)?)
1722                })?;
1723                self.notify();
1724                Ok(json!({
1725                    "docId": res.doc_id,
1726                    "path": res.path,
1727                    "committed": res.committed,
1728                    "dangling": res.dangling.iter().map(omgbase_store::InboundLink::to_json).collect::<Vec<_>>(),
1729                    "retargeted": res.retargeted.as_ref().map(|r| json!({ "blocks": r.blocks, "docs": r.docs })),
1730                }))
1731            }
1732            "docs_delete" => {
1733                let (repo, root) = self.scope(args)?;
1734                let ctx = self.doc_ctx(&repo);
1735                let doc = arg_string(args, "doc")?;
1736                let res = self.with_writes(root.as_deref(), |store, ds| {
1737                    Ok(store.docs_delete(&ctx, ds, &doc)?)
1738                })?;
1739                self.notify();
1740                Ok(doc_op_json(&res))
1741            }
1742            "docs_set_meta" => {
1743                let (repo, root) = self.scope(args)?;
1744                let ctx = self.doc_ctx(&repo);
1745                let doc = arg_string(args, "doc")?;
1746                let set = arg_object(args, "set")?.cloned();
1747                let unset = if args.get("unset").is_some() {
1748                    arg_strings(args, "unset")?
1749                } else {
1750                    Vec::new()
1751                };
1752                let res = self.with_writes(root.as_deref(), |store, ds| {
1753                    Ok(store.docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?)
1754                })?;
1755                self.notify();
1756                Ok(doc_op_json(&res))
1757            }
1758            "docs_plan_update" => {
1759                let (repo, root) = self.scope(args)?;
1760                self.require_root(root.as_deref())?;
1761                let doc = arg_string(args, "doc")?;
1762                let content = arg_string(args, "content")?;
1763                let opset = self
1764                    .store
1765                    .plan_update(&repo, &doc, &content, &self.config.clone())?;
1766                Ok(json!({ "opset": opset_json(&opset), "plan": render_opset_plan(&opset) }))
1767            }
1768            "docs_update" => {
1769                let (repo, root) = self.scope(args)?;
1770                let doc = arg_string(args, "doc")?;
1771                let content = arg_string(args, "content")?;
1772                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1773                let reason = arg_str(args, "reason").map(str::to_owned);
1774                let origin = ApplyOrigin::new(ACTOR, reason.as_deref());
1775                let config = self.config.clone();
1776                let ts = self.now();
1777                let (opset, result) = self.with_writes(root.as_deref(), |store, ds| {
1778                    Ok(store.docs_update(&repo, &doc, &content, &config, &origin, dry, ds, &ts)?)
1779                })?;
1780                if !dry {
1781                    self.notify();
1782                }
1783                Ok(json!({
1784                    "opset": opset_json(&opset),
1785                    "plan": render_opset_plan(&opset),
1786                    "result": result.map(|r| apply_json(&r)),
1787                }))
1788            }
1789            "observe" => {
1790                let (repo, _) = self.scope(args)?;
1791                let path = arg_string(args, "path")?;
1792                let content = arg_string(args, "content")?;
1793                let ts = self.now();
1794                let config = self.config.clone();
1795                let out = self
1796                    .store
1797                    .observe_one(&repo, &path, &content, &ts, &config)?;
1798                self.store.sweep_pool(&ts)?;
1799                self.notify();
1800                Ok(observe_json(&out))
1801            }
1802            "observe_many" => {
1803                let (repo, _) = self.scope(args)?;
1804                let files = args
1805                    .get("files")
1806                    .and_then(Json::as_array)
1807                    .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
1808                let items: Vec<omgbase_store::BatchItem> = files
1809                    .iter()
1810                    .map(|f| {
1811                        Ok(omgbase_store::BatchItem::observed(
1812                            &arg_string(f, "path")?,
1813                            &arg_string(f, "content")?,
1814                        ))
1815                    })
1816                    .collect::<Result<_>>()?;
1817                let ts = self.now();
1818                let config = self.config.clone();
1819                let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
1820                self.store.sweep_pool(&ts)?;
1821                self.notify();
1822                let mut out = Vec::with_capacity(outcomes.len());
1823                for o in &outcomes {
1824                    match o.as_observed() {
1825                        Some(obs) => out.push(observe_json(obs)),
1826                        None => {
1827                            return Err(SurfaceError::other(format!(
1828                                "observe_many: unexpected outcome for {}",
1829                                o.path()
1830                            )));
1831                        }
1832                    }
1833                }
1834                Ok(Json::Array(out))
1835            }
1836            "observe_delete" => {
1837                let (repo, _) = self.scope(args)?;
1838                let path = arg_string(args, "path")?;
1839                let ts = self.now();
1840                let out = self.store.observe_delete(&repo, &path, &ts)?;
1841                self.notify();
1842                Ok(json!({ "docId": out.doc_id, "path": out.path, "deleted": out.deleted() }))
1843            }
1844            "history_node" => history::history_node(
1845                &self.store,
1846                &arg_string(args, "id")?,
1847                arg_i64(args, "limit")?,
1848            ),
1849            "diff" => {
1850                let (repo, _) = self.scope(args)?;
1851                let doc_id = self.resolve_doc_id(&repo, arg_str(args, "doc"), None, None)?;
1852                history::diff_blocks(
1853                    &self.store,
1854                    &doc_id,
1855                    &arg_string(args, "from_rev")?,
1856                    &arg_string(args, "to_rev")?,
1857                )
1858            }
1859            "diff_unified" => {
1860                let (repo, _) = self.scope(args)?;
1861                let doc_ref = arg_string(args, "doc")?;
1862                let doc_id = self.resolve_doc_id(&repo, Some(&doc_ref), None, None)?;
1863                let revs = history::recent_revs(self.store.conn(), &doc_id)?;
1864                let to_rev = arg_str(args, "to_rev")
1865                    .map(str::to_owned)
1866                    .or_else(|| revs.first().cloned());
1867                let from_rev = arg_str(args, "from_rev")
1868                    .map(str::to_owned)
1869                    .or_else(|| revs.get(1).cloned())
1870                    .or_else(|| revs.first().cloned());
1871                let (Some(from), Some(to)) = (from_rev, to_rev) else {
1872                    return Err(SurfaceError::new(
1873                        "target_missing",
1874                        format!("no revisions to diff for {}", Json::String(doc_ref)),
1875                    ));
1876                };
1877                let path: Option<String> = self
1878                    .store
1879                    .conn()
1880                    .query_row(
1881                        "SELECT path FROM docs WHERE doc_id = ?1",
1882                        params![doc_id],
1883                        |r| r.get(0),
1884                    )
1885                    .optional()?;
1886                let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
1887                Ok(
1888                    json!({ "doc": doc_id, "path": path.unwrap_or_default(), "from": from, "to": to, "diff": diff }),
1889                )
1890            }
1891            "docs_read_at" => {
1892                let (repo, _) = self.scope(args)?;
1893                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1894                let rev = arg_string(args, "rev")?;
1895                read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
1896                    SurfaceError::with_data(
1897                        "target_missing",
1898                        format!(
1899                            "no revision {} for document {doc_id}",
1900                            Json::String(rev.clone())
1901                        ),
1902                        json!({ "doc": doc_id, "rev": rev }),
1903                    )
1904                })
1905            }
1906            "docs_history" => {
1907                let (repo, _) = self.scope(args)?;
1908                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1909                let doc = arg_str(args, "doc").filter(|d| !d.is_empty());
1910                if glob.is_none() && doc.is_none() {
1911                    return Err(SurfaceError::new(
1912                        "target_missing",
1913                        "docs_history requires one of path_glob or doc",
1914                    ));
1915                }
1916                let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
1917                if let Some(d) = doc {
1918                    if history::resolve_doc_row(self.store.conn(), &repo, d, include_deleted)?
1919                        .is_none()
1920                    {
1921                        return Err(SurfaceError::new(
1922                            "doc_missing",
1923                            format!("no document for {}", Json::String(d.to_owned())),
1924                        ));
1925                    }
1926                }
1927                history::docs_history(
1928                    &self.store,
1929                    &repo,
1930                    glob,
1931                    doc,
1932                    include_deleted,
1933                    arg_i64(args, "limit")?,
1934                )
1935            }
1936            "changes_since" => {
1937                let (repo, _) = self.scope(args)?;
1938                let page = self.store.changes_since(
1939                    &repo,
1940                    arg_i64(args, "cursor")?.unwrap_or(0),
1941                    arg_usize(args, "limit")?.unwrap_or(50),
1942                    arg_str(args, "origin").filter(|o| !o.is_empty()),
1943                )?;
1944                Ok(page.to_json())
1945            }
1946            "repos_status" => {
1947                let (repo, root) = self.scope(args)?;
1948                let fs = RealFileSystem;
1949                let disk = root
1950                    .as_deref()
1951                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
1952                Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
1953            }
1954            "sync_status" => {
1955                let (repo, root) = self.scope(args)?;
1956                let fs = RealFileSystem;
1957                let disk = root
1958                    .as_deref()
1959                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
1960                Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
1961            }
1962            "repos" => {
1963                let rows = self.repo_rows()?;
1964                Ok(json!({
1965                    "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
1966                }))
1967            }
1968            other => Err(SurfaceError::other(format!("unknown tool {other}"))),
1969        }
1970    }
1971}
1972
1973/// `spec/mutate` §4's result on the wire: `{ results: [{ ids, removed?,
1974/// mergedInto? }], revisions, diffs?, committed }` (camelCase, as the
1975/// reference).
1976#[must_use]
1977pub fn apply_json(res: &ApplyResult) -> Json {
1978    let mut m = Map::new();
1979    m.insert(
1980        "results".to_owned(),
1981        Json::Array(
1982            res.results
1983                .iter()
1984                .map(|r| {
1985                    let mut o = Map::new();
1986                    o.insert("ids".to_owned(), json!(r.ids));
1987                    if let Some(rm) = &r.removed {
1988                        o.insert("removed".to_owned(), json!(rm));
1989                    }
1990                    if let Some(mi) = &r.merged_into {
1991                        o.insert("mergedInto".to_owned(), json!(mi));
1992                    }
1993                    Json::Object(o)
1994                })
1995                .collect(),
1996        ),
1997    );
1998    m.insert(
1999        "revisions".to_owned(),
2000        Json::Array(
2001            res.revisions
2002                .iter()
2003                .map(|r| json!({ "doc": r.doc, "path": r.path }))
2004                .collect(),
2005        ),
2006    );
2007    if let Some(diffs) = &res.diffs {
2008        let mut d = Map::new();
2009        for (path, diff) in diffs {
2010            d.insert(
2011                path.clone(),
2012                json!({ "before": diff.before, "after": diff.after }),
2013            );
2014        }
2015        m.insert("diffs".to_owned(), Json::Object(d));
2016    }
2017    m.insert("committed".to_owned(), json!(res.committed));
2018    Json::Object(m)
2019}
2020
2021/// A document operation's result: `{ docId, path, committed }`.
2022fn doc_op_json(res: &omgbase_store::DocOpResult) -> Json {
2023    json!({ "docId": res.doc_id, "path": res.path, "committed": res.committed })
2024}
2025
2026/// `spec/mutate` §7's opset on the wire (camelCase precondition keys and
2027/// `matcherV`, as the reference).
2028#[must_use]
2029pub fn opset_json(opset: &Opset) -> Json {
2030    let mut m = Map::new();
2031    m.insert("version".to_owned(), json!(1));
2032    m.insert("kind".to_owned(), json!("doc_update"));
2033    m.insert(
2034        "target".to_owned(),
2035        json!({ "doc": opset.target_doc, "path": opset.target_path }),
2036    );
2037    m.insert(
2038        "precondition".to_owned(),
2039        json!({
2040            "doc": opset.precondition.doc,
2041            "path": opset.precondition.path,
2042            "baseRevision": opset.precondition.base_revision,
2043            "baseContentHash": opset.precondition.base_content_hash,
2044        }),
2045    );
2046    m.insert("matcherV".to_owned(), json!(opset.matcher_v));
2047    m.insert(
2048        "ops".to_owned(),
2049        Json::Array(
2050            opset
2051                .ops
2052                .iter()
2053                .map(|p| {
2054                    let mut o = Map::new();
2055                    // The kernel's JSON spells the carried ids `child_ids`
2056                    // (the `spec/mutate` fixture form); the wire is camelCase
2057                    // like every other key here (`spec/surface` §9;
2058                    // `Op::from_json` accepts both on the way back in).
2059                    let mut op = p.op.to_json();
2060                    if let Some(m) = op.as_object_mut()
2061                        && let Some(c) = m.remove("child_ids")
2062                    {
2063                        m.insert("childIds".to_owned(), c);
2064                    }
2065                    o.insert("op".to_owned(), op);
2066                    o.insert("disposition".to_owned(), json!(p.disposition.as_str()));
2067                    o.insert("blocks".to_owned(), json!(p.blocks));
2068                    o.insert("confidence".to_owned(), json!(p.confidence));
2069                    o.insert("reason".to_owned(), json!(p.reason));
2070                    if let Some(d) = &p.detail {
2071                        o.insert("detail".to_owned(), d.clone());
2072                    }
2073                    Json::Object(o)
2074                })
2075                .collect(),
2076        ),
2077    );
2078    if let Some(fm) = &opset.frontmatter {
2079        m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
2080    }
2081    m.insert("summary".to_owned(), opset.summary.to_json());
2082    m.insert("converges".to_owned(), json!(opset.converges));
2083    m.insert("diagnostics".to_owned(), json!(opset.diagnostics));
2084    Json::Object(m)
2085}
2086
2087/// `spec/search` §4's evidence on the wire: `{ rrf, boosts, ftsRank?,
2088/// vectorRank?, cosine? }`.
2089fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2090    let mut m = Map::new();
2091    m.insert("rrf".to_owned(), json!(e.rrf));
2092    m.insert("boosts".to_owned(), e.boosts.to_json());
2093    if let Some(r) = e.fts_rank {
2094        m.insert("ftsRank".to_owned(), json!(r));
2095    }
2096    if let Some(r) = e.vector_rank {
2097        m.insert("vectorRank".to_owned(), json!(r));
2098        if let Some(c) = e.cosine {
2099            m.insert("cosine".to_owned(), json!(c));
2100        }
2101    }
2102    Json::Object(m)
2103}
2104
2105/// The public `observe` result: `{ docId, path, rev, commitId, converged,
2106/// echo, conflicted, dispositions: [{ kind, count }] }`.
2107fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2108    json!({
2109        "docId": o.doc_id,
2110        "path": o.path,
2111        "rev": o.rev,
2112        "commitId": o.commit_id,
2113        "converged": o.converged,
2114        "echo": o.echo,
2115        "conflicted": o.conflicted,
2116        "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2117    })
2118}
2119
2120fn verb(d: omgbase_store::mutate_kernel::PlanDisposition) -> &'static str {
2121    use omgbase_store::mutate_kernel::PlanDisposition as D;
2122    match d {
2123        D::Same => "KEEP  ",
2124        D::Edited | D::EditedMoved => "UPDATE",
2125        D::Moved => "MOVE  ",
2126        D::Inserted => "INSERT",
2127        D::Deleted => "REMOVE",
2128        D::SplitFrom => "SPLIT ",
2129        D::MergedInto => "MERGE ",
2130        D::CopiedFrom => "COPY  ",
2131        D::Resurrected => "RESURR",
2132        D::BulkRewrite => "REWRITE",
2133        D::Retiled => "RETILE",
2134    }
2135}
2136
2137/// The one-line-per-op plan text of `docs_plan_update` / `docs_update`.
2138#[must_use]
2139pub fn render_opset_plan(opset: &Opset) -> String {
2140    let mut lines = Vec::new();
2141    for p in &opset.ops {
2142        let subject = p.blocks.first().map_or("(new)", String::as_str);
2143        let conf = p.confidence.map_or(String::new(), |c| format!(" ~{c:.2}"));
2144        let why = p
2145            .reason
2146            .as_ref()
2147            .map_or(String::new(), |r| format!(" [{r}]"));
2148        lines.push(format!(
2149            "{} {:<9} {}{conf}{why}",
2150            verb(p.disposition),
2151            subject,
2152            p.disposition.as_str()
2153        ));
2154    }
2155    let s = &opset.summary;
2156    lines.push(String::new());
2157    lines.push(format!(
2158        "preserved: {}  updated: {}  moved: {}  created: {}  removed: {}  split: {}  merged: {}  ambiguous: {}",
2159        s.preserved, s.updated, s.moved, s.created, s.removed, s.split, s.merged, s.ambiguous
2160    ));
2161    if !opset.converges {
2162        lines.push(
2163            "WARNING: plan does not reproduce the proposed content exactly — will not apply."
2164                .to_owned(),
2165        );
2166    }
2167    lines.join("\n")
2168}
2169
2170#[cfg(test)]
2171mod tests {
2172    use super::*;
2173    use omgbase_store::{MemDocStore, SequentialMinter};
2174
2175    fn surface() -> Surface {
2176        let mut store =
2177            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2178        let repo = store.create_repo("fixture").unwrap();
2179        let mut n = 0;
2180        Surface::new(store, &repo, None)
2181            .with_doc_store(Box::new(MemDocStore::new()))
2182            .with_clock(move || {
2183                n += 1;
2184                format!("2026-09-26T10:{n:02}:00.000Z")
2185            })
2186    }
2187
2188    #[test]
2189    fn catalog_lists_every_tool_of_the_table() {
2190        let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2191        for want in [
2192            "docs_outline",
2193            "docs_read",
2194            "docs_get_many",
2195            "nodes_get",
2196            "nodes_get_many",
2197            "read_ref",
2198            "docs_tree",
2199            "docs_list",
2200            "query_syntax",
2201            "query",
2202            "graph",
2203            "text_search",
2204            "resolve",
2205            "apply",
2206            "blocks_insert",
2207            "blocks_update",
2208            "blocks_move",
2209            "blocks_remove",
2210            "blocks_split",
2211            "blocks_merge",
2212            "tasks_complete",
2213            "node_set",
2214            "sections_append",
2215            "docs_append",
2216            "links_retarget",
2217            "links_stale",
2218            "links_repair",
2219            "docs_create",
2220            "docs_move",
2221            "docs_delete",
2222            "docs_set_meta",
2223            "docs_plan_update",
2224            "docs_update",
2225            "observe",
2226            "observe_many",
2227            "observe_delete",
2228            "history_node",
2229            "diff",
2230            "diff_unified",
2231            "docs_read_at",
2232            "docs_history",
2233            "changes_since",
2234            "repos_status",
2235            "sync_status",
2236            "repos",
2237        ] {
2238            assert!(names.contains(&want), "missing {want}");
2239        }
2240        assert_eq!(names.len(), 45);
2241        for t in tools() {
2242            assert_eq!(t.input_schema["type"], "object");
2243        }
2244    }
2245
2246    #[test]
2247    fn observe_read_and_query_round_trip() {
2248        let mut s = surface();
2249        let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2250        assert!(!out.is_error, "{}", out.body);
2251        assert_eq!(out.body["docId"], "d_0");
2252        assert_eq!(out.body["echo"], false);
2253        let read = s.call("docs_read", json!({ "doc": "a.md", "include_ids": true }));
2254        assert!(!read.is_error);
2255        assert_eq!(read.body["path"], "a.md");
2256        assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2257        assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2258        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" }));
2259        assert!(!q.is_error, "{}", q.body);
2260        assert_eq!(q.body["hits"][0]["$title"], "Title");
2261        assert_eq!(q.body["hits"][0]["layer"], "canon");
2262        assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2263        assert_eq!(q.body["hits"][0]["id"], "d_0");
2264        assert_eq!(q.body["consumer"], "collect");
2265        let c = s.call(
2266            "query",
2267            json!({ "query": "$repo.blocks count { where text(\"hello\") }" }),
2268        );
2269        assert_eq!(c.body["count"], 1);
2270        let bad = s.call(
2271            "query",
2272            json!({ "query": "from docs where path == \"a.md\"" }),
2273        );
2274        assert!(bad.is_error);
2275        assert_eq!(bad.body["error"], "filter_invalid");
2276        assert!(
2277            bad.body["message"]
2278                .as_str()
2279                .unwrap()
2280                .contains("did you mean the intrinsic $path")
2281        );
2282        let sem = s.call(
2283            "query",
2284            json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2285        );
2286        assert_eq!(sem.body["error"], "semantic_unavailable");
2287        let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2288        let text = outline.body["text"].as_str().unwrap();
2289        assert!(text.starts_with("b_0 h1   Title  §"), "{text}");
2290        assert!(text.contains("☐ task one"));
2291        let missing = s.call("docs_read", json!({ "doc": "nope.md" }));
2292        assert_eq!(missing.body["error"], "doc_missing");
2293        let repos = s.call("repos", json!({}));
2294        assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2295        let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2296        assert_eq!(unknown.body["error"], "repo_not_found");
2297    }
2298
2299    #[test]
2300    fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2301        let mut s = surface();
2302        s.call(
2303            "observe",
2304            json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2305        );
2306        s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2307        // §4 (1.2): a stale `expect.parent_children_hash` on blocks_insert is
2308        // the kernel's destination-parent CAS: `stale_expectation` carrying the
2309        // current hash, from which the caller retries without a read.
2310        let stale = s.call(
2311            "blocks_insert",
2312            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2313        );
2314        assert!(stale.is_error, "{}", stale.body);
2315        assert_eq!(stale.body["error"], "stale_expectation");
2316        assert_eq!(stale.body["retriable"], true);
2317        let current = stale.body["data"]["current"]["parent_children_hash"]
2318            .as_str()
2319            .expect("current hash")
2320            .to_owned();
2321        assert_eq!(current.len(), 64, "{current}");
2322        let ok = s.call(
2323            "blocks_insert",
2324            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2325        );
2326        assert!(!ok.is_error, "{}", ok.body);
2327        assert_eq!(ok.body["committed"], true);
2328        let read = s.call("docs_read", json!({ "doc": "a.md" }));
2329        assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2330        // blocks_move: the destination is checked once for the whole run.
2331        let stale = s.call(
2332            "blocks_move",
2333            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2334        );
2335        assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2336        let current = stale.body["data"]["current"]["parent_children_hash"]
2337            .as_str()
2338            .expect("current hash")
2339            .to_owned();
2340        let ok = s.call(
2341            "blocks_move",
2342            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2343        );
2344        assert!(!ok.is_error, "{}", ok.body);
2345        let read = s.call("docs_read", json!({ "doc": "a.md" }));
2346        assert_eq!(read.body["content"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2347        // A `dry_run` still runs the CAS.
2348        let dry = s.call(
2349            "blocks_move",
2350            json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "dry_run": true, "expect": { "parent_children_hash": "00" } }),
2351        );
2352        assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2353        // §2 (1.2): the budget is applied from the second item on — one item
2354        // always comes back, `truncated` says more remained.
2355        let many = s.call(
2356            "docs_get_many",
2357            json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2358        );
2359        assert_eq!(
2360            many.body["items"].as_array().unwrap().len(),
2361            1,
2362            "{}",
2363            many.body
2364        );
2365        assert_eq!(many.body["items"][0]["path"], "a.md");
2366        assert_eq!(many.body["truncated"], true);
2367        let one = s.call(
2368            "docs_get_many",
2369            json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2370        );
2371        assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2372        assert_eq!(one.body["truncated"], false);
2373        let nodes = s.call(
2374            "nodes_get_many",
2375            json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2376        );
2377        assert_eq!(
2378            nodes.body["nodes"].as_array().unwrap().len(),
2379            1,
2380            "{}",
2381            nodes.body
2382        );
2383        assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2384        assert_eq!(nodes.body["truncated"], true);
2385        assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2386    }
2387
2388    #[test]
2389    fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2390        use std::cell::Cell;
2391        use std::rc::Rc;
2392        let fired = Rc::new(Cell::new(0));
2393        let f2 = Rc::clone(&fired);
2394        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2395        s.call(
2396            "observe",
2397            json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
2398        );
2399        assert_eq!(fired.get(), 1);
2400        let dry = s.call(
2401            "blocks_insert",
2402            json!({ "to": "a.md", "markdown": "Two.", "dry_run": true }),
2403        );
2404        assert!(!dry.is_error, "{}", dry.body);
2405        assert_eq!(dry.body["committed"], false);
2406        assert_eq!(fired.get(), 1, "a dry run never fires the hook");
2407        let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
2408        assert!(!wet.is_error, "{}", wet.body);
2409        assert_eq!(fired.get(), 2);
2410        let read = s.call("docs_read", json!({ "doc": "d_0" }));
2411        assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n");
2412        let upd = s.call(
2413            "blocks_update",
2414            json!({ "block": "b_1", "markdown": "One, edited." }),
2415        );
2416        assert!(!upd.is_error, "{}", upd.body);
2417        assert_eq!(upd.body["id"], "b_1");
2418        let app = s.call(
2419            "sections_append",
2420            json!({ "heading": "T", "markdown": "Three." }),
2421        );
2422        assert!(!app.is_error, "{}", app.body);
2423        let amb = s.call(
2424            "sections_append",
2425            json!({ "heading": "Nope", "markdown": "x" }),
2426        );
2427        assert_eq!(amb.body["error"], "parent_missing");
2428        let hist = s.call("history_node", json!({ "id": "b_1" }));
2429        let entries = hist.body.as_array().unwrap();
2430        assert!(entries.len() >= 2, "{}", hist.body);
2431        assert_eq!(entries[0]["origin"], "api", "newest first");
2432        let du = s.call("diff_unified", json!({ "doc": "a.md" }));
2433        assert!(!du.is_error, "{}", du.body);
2434        assert!(du.body["diff"].as_str().unwrap().contains("+Three."));
2435    }
2436}