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