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