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