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