@omgbase/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/cli/eval-matcher.d.ts +13 -0
  3. package/dist/src/cli/eval-matcher.js +36 -0
  4. package/dist/src/core/attach.d.ts +5 -0
  5. package/dist/src/core/attach.js +38 -0
  6. package/dist/src/core/cursor.d.ts +11 -0
  7. package/dist/src/core/cursor.js +35 -0
  8. package/dist/src/core/hash.d.ts +23 -0
  9. package/dist/src/core/hash.js +82 -0
  10. package/dist/src/core/ids.d.ts +6 -0
  11. package/dist/src/core/ids.js +26 -0
  12. package/dist/src/core/index.d.ts +1 -0
  13. package/dist/src/core/index.js +2 -0
  14. package/dist/src/core/ingest.d.ts +43 -0
  15. package/dist/src/core/ingest.js +287 -0
  16. package/dist/src/core/order-key.d.ts +8 -0
  17. package/dist/src/core/order-key.js +72 -0
  18. package/dist/src/core/parse/parse.d.ts +3 -0
  19. package/dist/src/core/parse/parse.js +104 -0
  20. package/dist/src/core/parse/render.d.ts +3 -0
  21. package/dist/src/core/parse/render.js +20 -0
  22. package/dist/src/core/parse/tree.d.ts +8 -0
  23. package/dist/src/core/parse/tree.js +43 -0
  24. package/dist/src/core/parse/types.d.ts +52 -0
  25. package/dist/src/core/parse/types.js +9 -0
  26. package/dist/src/core/read/document.d.ts +107 -0
  27. package/dist/src/core/read/document.js +191 -0
  28. package/dist/src/core/read/nodes.d.ts +47 -0
  29. package/dist/src/core/read/nodes.js +102 -0
  30. package/dist/src/core/read/outline.d.ts +12 -0
  31. package/dist/src/core/read/outline.js +76 -0
  32. package/dist/src/core/read/reader.d.ts +125 -0
  33. package/dist/src/core/read/reader.js +206 -0
  34. package/dist/src/core/read/refs.d.ts +15 -0
  35. package/dist/src/core/read/refs.js +51 -0
  36. package/dist/src/core/store/edges.d.ts +26 -0
  37. package/dist/src/core/store/edges.js +101 -0
  38. package/dist/src/core/store/fts.d.ts +10 -0
  39. package/dist/src/core/store/fts.js +22 -0
  40. package/dist/src/core/store/gc.d.ts +18 -0
  41. package/dist/src/core/store/gc.js +69 -0
  42. package/dist/src/core/store/nodes.d.ts +5 -0
  43. package/dist/src/core/store/nodes.js +57 -0
  44. package/dist/src/core/store/properties.d.ts +38 -0
  45. package/dist/src/core/store/properties.js +177 -0
  46. package/dist/src/core/store/rebuild.d.ts +3 -0
  47. package/dist/src/core/store/rebuild.js +29 -0
  48. package/dist/src/core/store/schema.d.ts +9 -0
  49. package/dist/src/core/store/schema.js +386 -0
  50. package/dist/src/core/store/sections.d.ts +2 -0
  51. package/dist/src/core/store/sections.js +29 -0
  52. package/dist/src/core/store/store.d.ts +24 -0
  53. package/dist/src/core/store/store.js +164 -0
  54. package/dist/src/core/store/writers.d.ts +52 -0
  55. package/dist/src/core/store/writers.js +89 -0
  56. package/dist/src/core/vec.d.ts +10 -0
  57. package/dist/src/core/vec.js +33 -0
  58. package/dist/src/format/adapter.d.ts +97 -0
  59. package/dist/src/format/adapter.js +14 -0
  60. package/dist/src/format/index.d.ts +6 -0
  61. package/dist/src/format/index.js +15 -0
  62. package/dist/src/format/json.d.ts +3 -0
  63. package/dist/src/format/json.js +324 -0
  64. package/dist/src/format/markdown.d.ts +5 -0
  65. package/dist/src/format/markdown.js +217 -0
  66. package/dist/src/format/registry.d.ts +6 -0
  67. package/dist/src/format/registry.js +24 -0
  68. package/dist/src/format/yaml.d.ts +5 -0
  69. package/dist/src/format/yaml.js +413 -0
  70. package/dist/src/graph/extract.d.ts +38 -0
  71. package/dist/src/graph/extract.js +242 -0
  72. package/dist/src/graph/history.d.ts +103 -0
  73. package/dist/src/graph/history.js +210 -0
  74. package/dist/src/graph/inbound-links.d.ts +33 -0
  75. package/dist/src/graph/inbound-links.js +130 -0
  76. package/dist/src/graph/link-destinations.d.ts +22 -0
  77. package/dist/src/graph/link-destinations.js +84 -0
  78. package/dist/src/graph/link-health.d.ts +79 -0
  79. package/dist/src/graph/link-health.js +146 -0
  80. package/dist/src/graph/links.d.ts +23 -0
  81. package/dist/src/graph/links.js +42 -0
  82. package/dist/src/index.d.ts +60 -0
  83. package/dist/src/index.js +70 -0
  84. package/dist/src/mcp/errors.d.ts +17 -0
  85. package/dist/src/mcp/errors.js +18 -0
  86. package/dist/src/mcp/graph.d.ts +63 -0
  87. package/dist/src/mcp/graph.js +212 -0
  88. package/dist/src/mcp/reference.d.ts +1 -0
  89. package/dist/src/mcp/reference.js +227 -0
  90. package/dist/src/mcp/server.d.ts +30 -0
  91. package/dist/src/mcp/server.js +1076 -0
  92. package/dist/src/mcp/stdio.d.ts +13 -0
  93. package/dist/src/mcp/stdio.js +24 -0
  94. package/dist/src/migrate/mrplex.d.ts +27 -0
  95. package/dist/src/migrate/mrplex.js +29 -0
  96. package/dist/src/mutate/apply.d.ts +83 -0
  97. package/dist/src/mutate/apply.js +302 -0
  98. package/dist/src/mutate/doc-store.d.ts +49 -0
  99. package/dist/src/mutate/doc-store.js +71 -0
  100. package/dist/src/mutate/docs.d.ts +60 -0
  101. package/dist/src/mutate/docs.js +232 -0
  102. package/dist/src/mutate/known-ids.d.ts +12 -0
  103. package/dist/src/mutate/known-ids.js +154 -0
  104. package/dist/src/mutate/load.d.ts +3 -0
  105. package/dist/src/mutate/load.js +66 -0
  106. package/dist/src/mutate/lower.d.ts +32 -0
  107. package/dist/src/mutate/lower.js +232 -0
  108. package/dist/src/mutate/macros.d.ts +91 -0
  109. package/dist/src/mutate/macros.js +230 -0
  110. package/dist/src/mutate/ops.d.ts +40 -0
  111. package/dist/src/mutate/ops.js +513 -0
  112. package/dist/src/mutate/opset.d.ts +92 -0
  113. package/dist/src/mutate/opset.js +79 -0
  114. package/dist/src/mutate/plan-update.d.ts +51 -0
  115. package/dist/src/mutate/plan-update.js +187 -0
  116. package/dist/src/mutate/tree.d.ts +46 -0
  117. package/dist/src/mutate/tree.js +167 -0
  118. package/dist/src/oqx/run.d.ts +1 -0
  119. package/dist/src/oqx/run.js +6 -0
  120. package/dist/src/oqx-js/context.d.ts +23 -0
  121. package/dist/src/oqx-js/context.js +564 -0
  122. package/dist/src/oqx-js/planner.d.ts +10 -0
  123. package/dist/src/oqx-js/planner.js +91 -0
  124. package/dist/src/oqx-js/run.d.ts +41 -0
  125. package/dist/src/oqx-js/run.js +332 -0
  126. package/dist/src/oqx-js/sql/translate.d.ts +27 -0
  127. package/dist/src/oqx-js/sql/translate.js +290 -0
  128. package/dist/src/reconcile/crossdoc.d.ts +34 -0
  129. package/dist/src/reconcile/crossdoc.js +76 -0
  130. package/dist/src/reconcile/eval/generator.d.ts +21 -0
  131. package/dist/src/reconcile/eval/generator.js +129 -0
  132. package/dist/src/reconcile/eval/metrics.d.ts +32 -0
  133. package/dist/src/reconcile/eval/metrics.js +74 -0
  134. package/dist/src/reconcile/flatten.d.ts +12 -0
  135. package/dist/src/reconcile/flatten.js +37 -0
  136. package/dist/src/reconcile/phase5.d.ts +8 -0
  137. package/dist/src/reconcile/phase5.js +167 -0
  138. package/dist/src/reconcile/phase6a.d.ts +2 -0
  139. package/dist/src/reconcile/phase6a.js +159 -0
  140. package/dist/src/reconcile/phases.d.ts +28 -0
  141. package/dist/src/reconcile/phases.js +0 -0
  142. package/dist/src/reconcile/reconcile.d.ts +21 -0
  143. package/dist/src/reconcile/reconcile.js +115 -0
  144. package/dist/src/reconcile/similarity.d.ts +7 -0
  145. package/dist/src/reconcile/similarity.js +41 -0
  146. package/dist/src/reconcile/types.d.ts +46 -0
  147. package/dist/src/reconcile/types.js +19 -0
  148. package/dist/src/search/cel/ast.d.ts +74 -0
  149. package/dist/src/search/cel/ast.js +3 -0
  150. package/dist/src/search/cel/compile.d.ts +60 -0
  151. package/dist/src/search/cel/compile.js +836 -0
  152. package/dist/src/search/cel/lexer.d.ts +9 -0
  153. package/dist/src/search/cel/lexer.js +139 -0
  154. package/dist/src/search/cel/parser.d.ts +7 -0
  155. package/dist/src/search/cel/parser.js +219 -0
  156. package/dist/src/search/drain.d.ts +39 -0
  157. package/dist/src/search/drain.js +97 -0
  158. package/dist/src/search/embeddings.d.ts +131 -0
  159. package/dist/src/search/embeddings.js +263 -0
  160. package/dist/src/search/external.d.ts +13 -0
  161. package/dist/src/search/external.js +177 -0
  162. package/dist/src/search/fts-query.d.ts +1 -0
  163. package/dist/src/search/fts-query.js +64 -0
  164. package/dist/src/search/provider.d.ts +17 -0
  165. package/dist/src/search/provider.js +13 -0
  166. package/dist/src/search/resolve.d.ts +18 -0
  167. package/dist/src/search/resolve.js +23 -0
  168. package/dist/src/search/rrf.d.ts +36 -0
  169. package/dist/src/search/rrf.js +84 -0
  170. package/dist/src/search/tasks.d.ts +12 -0
  171. package/dist/src/search/tasks.js +131 -0
  172. package/dist/src/search/text.d.ts +16 -0
  173. package/dist/src/search/text.js +21 -0
  174. package/dist/src/search/vector.d.ts +18 -0
  175. package/dist/src/search/vector.js +49 -0
  176. package/dist/src/sync/admin.d.ts +59 -0
  177. package/dist/src/sync/admin.js +53 -0
  178. package/dist/src/sync/attach.d.ts +13 -0
  179. package/dist/src/sync/attach.js +29 -0
  180. package/dist/src/sync/checkpoint.d.ts +21 -0
  181. package/dist/src/sync/checkpoint.js +52 -0
  182. package/dist/src/sync/driver.d.ts +25 -0
  183. package/dist/src/sync/driver.js +80 -0
  184. package/dist/src/sync/external-source.d.ts +14 -0
  185. package/dist/src/sync/external-source.js +137 -0
  186. package/dist/src/sync/freshness.d.ts +42 -0
  187. package/dist/src/sync/freshness.js +152 -0
  188. package/dist/src/sync/fs-util.d.ts +12 -0
  189. package/dist/src/sync/fs-util.js +55 -0
  190. package/dist/src/sync/git-heuristics.d.ts +6 -0
  191. package/dist/src/sync/git-heuristics.js +16 -0
  192. package/dist/src/sync/observe.d.ts +63 -0
  193. package/dist/src/sync/observe.js +84 -0
  194. package/dist/src/sync/plugin.d.ts +54 -0
  195. package/dist/src/sync/plugin.js +12 -0
  196. package/dist/src/sync/reconciling-ingest.d.ts +12 -0
  197. package/dist/src/sync/reconciling-ingest.js +209 -0
  198. package/dist/src/sync/recovery.d.ts +6 -0
  199. package/dist/src/sync/recovery.js +34 -0
  200. package/dist/src/sync/settings.d.ts +18 -0
  201. package/dist/src/sync/settings.js +58 -0
  202. package/dist/src/sync/sources.d.ts +37 -0
  203. package/dist/src/sync/sources.js +75 -0
  204. package/dist/src/sync/tombstone.d.ts +9 -0
  205. package/dist/src/sync/tombstone.js +55 -0
  206. package/dist/src/sync/watch-lease.d.ts +14 -0
  207. package/dist/src/sync/watch-lease.js +91 -0
  208. package/dist/src/sync/watcher.d.ts +19 -0
  209. package/dist/src/sync/watcher.js +34 -0
  210. package/dist/src/sync/workspace.d.ts +34 -0
  211. package/dist/src/sync/workspace.js +120 -0
  212. package/dist/src/sync/writer-lock.d.ts +16 -0
  213. package/dist/src/sync/writer-lock.js +121 -0
  214. package/package.json +80 -0
@@ -0,0 +1,1076 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { docsOutline } from "../core/read/outline.js";
4
+ import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
5
+ import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
6
+ import { findDoc, findDocByRef, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
7
+ import { CursorInvalid } from "../core/cursor.js";
8
+ import { resolveRef } from "../core/read/refs.js";
9
+ import { isValidId } from "../core/ids.js";
10
+ import { VERSION } from "../core/index.js";
11
+ import { normalizeText, normalizeVisibleText } from "../core/hash.js";
12
+ import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
13
+ import { graphNeighborhood } from "./graph.js";
14
+ import { textSearch } from "../search/text.js";
15
+ import { FilterInvalid } from "../search/cel/parser.js";
16
+ import { EngineError } from "./errors.js";
17
+ import { apply } from "../mutate/apply.js";
18
+ import { MutationError } from "../mutate/tree.js";
19
+ import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair, nodeSet } from "../mutate/macros.js";
20
+ import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
21
+ import { planUpdate, docsUpdate } from "../mutate/plan-update.js";
22
+ import { renderOpsetPlan } from "../mutate/opset.js";
23
+ import { historyNode, diffBlocks, diffUnified, changesSince, docHistory } from "../graph/history.js";
24
+ import { linksStale, linksStaleSummary } from "../graph/link-health.js";
25
+ import { resolve as resolveThing } from "../search/resolve.js";
26
+ import { reposStatus, syncStatus } from "../sync/admin.js";
27
+ import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
28
+ import { QUERY_SYNTAX } from "./reference.js";
29
+ // Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
30
+ // each op a real schema (rather than z.any()) makes the required fields
31
+ // discoverable in the tool's JSON schema and turns a malformed op — e.g. an
32
+ // `update` missing its `block` id — into a boundary validation error naming the
33
+ // field, instead of a downstream `block_missing` for "block undefined".
34
+ const atSchema = z.union([
35
+ z.literal("start"),
36
+ z.literal("end"),
37
+ z.object({ before: z.string() }),
38
+ z.object({ after: z.string() }),
39
+ ]);
40
+ const toSchema = z.object({
41
+ parent: z.union([
42
+ z.string().describe("a block id to nest under"),
43
+ z.object({ doc: z.literal(true) }).describe("the document's top level"),
44
+ z.object({ heading: z.string(), scope: z.literal("section") }).describe("a heading's section range"),
45
+ ]),
46
+ at: atSchema,
47
+ });
48
+ const expectSchema = z.object({
49
+ content_hash: z.string().optional(),
50
+ parent_children_hash: z.string().optional(),
51
+ });
52
+ const opSchema = z.discriminatedUnion("op", [
53
+ z.object({ op: z.literal("insert"), doc: z.string().optional(), to: toSchema, markdown: z.string() }),
54
+ z.object({ op: z.literal("update"), block: z.string(), markdown: z.string().optional(), attrs: z.record(z.string(), z.unknown()).optional(), expect: expectSchema.optional() }),
55
+ z.object({ op: z.literal("move"), blocks: z.array(z.string()), to: toSchema }),
56
+ z.object({ op: z.literal("remove"), blocks: z.array(z.string()), expect: z.record(z.string(), expectSchema).optional() }),
57
+ z.object({ op: z.literal("split"), block: z.string(), at: z.array(z.number().int()), expect: expectSchema.optional() }),
58
+ z.object({ op: z.literal("merge"), blocks: z.array(z.string()), separator: z.string().optional(), expect: z.record(z.string(), expectSchema).optional() }),
59
+ ]);
60
+ function ok(payload) {
61
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
62
+ }
63
+ function fail(err) {
64
+ let body;
65
+ if (err instanceof EngineError)
66
+ body = err.body();
67
+ else if (err instanceof FilterInvalid)
68
+ body = { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
69
+ else if (err instanceof CursorInvalid)
70
+ body = { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
71
+ else if (err instanceof MutationError)
72
+ body = { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
73
+ else
74
+ body = { error: "repo_not_found", message: String(err), retriable: false };
75
+ return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
76
+ }
77
+ export function buildServer(ctx) {
78
+ const server = new McpServer({ name: "omgbase", version: VERSION });
79
+ const { store } = ctx;
80
+ // Multi-repo (ADR-014): every tool accepts an optional `repo` slug. Resolve it
81
+ // to a repo id + its (derived) working-tree root per call; omitting it uses the
82
+ // server's bound default repo, so single-repo clients are unchanged. rootPath
83
+ // is derived from the repo's attached `fs` source (the root_path column is
84
+ // gone). An unknown slug is a loud repo_not_found.
85
+ function repoScope(slug) {
86
+ if (slug === undefined)
87
+ return { repoId: ctx.repoId, rootPath: ctx.rootPath };
88
+ const row = store.db.prepare("SELECT repo_id FROM repos WHERE slug = ?").get(slug);
89
+ if (!row)
90
+ throw new EngineError("repo_not_found", `no repo '${slug}' in this workspace`, { data: { repo: slug } });
91
+ const cfg = store.db
92
+ .prepare("SELECT s.config AS config FROM sources s JOIN attachments a ON a.source_id = s.source_id WHERE a.repo_id = ? AND s.adapter = 'fs' LIMIT 1")
93
+ .get(row.repo_id);
94
+ let rootPath;
95
+ if (cfg) {
96
+ try {
97
+ const r = JSON.parse(cfg.config).root;
98
+ if (typeof r === "string" && r)
99
+ rootPath = r;
100
+ }
101
+ catch { /* malformed config → no root */ }
102
+ }
103
+ return { repoId: row.repo_id, rootPath };
104
+ }
105
+ // Every repo-scoped tool adds `...REPO_ARG` to its inputSchema and opens with
106
+ // `const { repoId, rootPath } = repoScope(args.repo);` — omitting `repo` uses
107
+ // the bound default repo (single-repo clients unchanged).
108
+ const REPO_ARG = { repo: z.string().optional() };
109
+ // Wrap a successful write's result: notify the host so it can schedule a
110
+ // background embed drain. onMutation must be cheap/non-blocking (see
111
+ // ServerContext); we never await it, keeping it off the response path.
112
+ function okMutated(payload) {
113
+ ctx.onMutation?.();
114
+ return ok(payload);
115
+ }
116
+ // Resolve the owning document id from an explicit doc/path, or — when neither
117
+ // is given — infer it from a block id. The MCP schemas mark `doc`/`path`
118
+ // optional precisely so a caller holding only a block id (e.g. from
119
+ // docs_outline or query) can hydrate it without a separate lookup.
120
+ //
121
+ // `doc` is id-OR-path: an agent holding a path from a prior query/outline hit
122
+ // routinely reaches for the most obvious field, so it must accept both (via
123
+ // the shared findDocByRef). `path` stays explicit-path; `block` infers the
124
+ // owning doc. An unresolvable ref throws doc_missing (loud), never a silent
125
+ // empty — including a d_-shaped id that doesn't exist (findDocByRef won't fall
126
+ // through to a path lookup for it).
127
+ function resolveDocId(repoId, ref) {
128
+ if (ref.doc) {
129
+ const info = findDocByRef(store, repoId, ref.doc);
130
+ if (info)
131
+ return info.docId;
132
+ }
133
+ else if (ref.path) {
134
+ const info = findDoc(store, { repoId, path: ref.path });
135
+ if (info)
136
+ return info.docId;
137
+ }
138
+ else if (ref.block) {
139
+ const row = store.db
140
+ .prepare("SELECT doc_id FROM blocks WHERE block_id = ?")
141
+ .get(ref.block);
142
+ if (row)
143
+ return row.doc_id;
144
+ }
145
+ throw new EngineError("doc_missing", `no document for ${JSON.stringify(ref)}`, { data: ref });
146
+ }
147
+ // Resolve `heading` (a block id or heading text) to a heading block id, for
148
+ // macros that address a section. A value that already names a live heading
149
+ // block is returned as-is; otherwise it's matched as heading text (normalized
150
+ // the same way the parser stores block.text), scoped to a doc/path when given.
151
+ // Repo-wide text that isn't unique fails ambiguous_heading with candidate ids.
152
+ function resolveHeadingId(repoId, heading, scope) {
153
+ const asBlock = store.db
154
+ .prepare("SELECT block_id FROM blocks WHERE block_id = ? AND type = 'heading' AND deleted_commit IS NULL")
155
+ .get(heading);
156
+ if (asBlock)
157
+ return asBlock.block_id;
158
+ const wantDoc = scope.doc || scope.path ? resolveDocId(repoId, scope) : undefined;
159
+ const needle = normalizeVisibleText(heading, "heading");
160
+ const rows = (wantDoc
161
+ ? store.db.prepare("SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ? AND type = 'heading' AND doc_id = ? AND deleted_commit IS NULL")
162
+ .all(repoId, wantDoc)
163
+ : store.db.prepare("SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ? AND type = 'heading' AND deleted_commit IS NULL")
164
+ .all(repoId));
165
+ const matches = rows.filter((r) => normalizeText(r.text) === needle);
166
+ if (matches.length === 0)
167
+ throw new EngineError("parent_missing", `no heading matching ${JSON.stringify(heading)}`, { data: { heading, ...(wantDoc ? { doc: wantDoc } : {}) } });
168
+ if (matches.length > 1) {
169
+ throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(heading)} matches ${matches.length} headings; pass its block id or a doc/path scope`, {
170
+ data: { heading, candidates: matches.map((m) => ({ block: m.block_id, doc: m.doc_id })) },
171
+ });
172
+ }
173
+ return matches[0].block_id;
174
+ }
175
+ // Resolve a human ref (block id, locator, node id, …) to a live block id — the
176
+ // server-side equal of the CLI's local `block()`. The block-level tools accept
177
+ // refs (not pre-resolved ids) so a remote `omg insert/move/split/…` behaves
178
+ // exactly like local: ref resolution and CAS pinning happen HERE, next to the
179
+ // store, instead of on a client that has none.
180
+ function resolveBlockRef(repoId, ref) {
181
+ const r = resolveRef(store, repoId, ref);
182
+ if (!r || r.kind !== "block" || !r.blockId)
183
+ throw new EngineError("block_missing", `not a block: ${ref}`, { data: { ref } });
184
+ return r.blockId;
185
+ }
186
+ // Resolve a PARENT ref for insert/move: a block ref names that block as the
187
+ // parent; a document ref (id or path) names the document's top level — the
188
+ // kernel's `{ doc: true }` parent — so "append a new section to this doc" is
189
+ // one blocks_insert call, not a detour through docs_append. Returns the doc
190
+ // the parent lives in so the caller can pin the op to it.
191
+ function resolveParentRef(repoId, ref) {
192
+ const r = resolveRef(store, repoId, ref);
193
+ if (!r)
194
+ throw new EngineError("block_missing", `not a block or document: ${ref}`, { data: { ref } });
195
+ if (r.kind === "document")
196
+ return { parent: { doc: true }, docId: r.docId };
197
+ return { parent: r.blockId, docId: r.docId };
198
+ }
199
+ function docIdOfBlock(blockId) {
200
+ const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
201
+ return row?.doc_id;
202
+ }
203
+ // Current raw-content hash (hex) of a block, for CAS `expect` pinning — mirrors
204
+ // the CLI's rawHashOf. Undefined for an unknown/deleted block (the op then runs
205
+ // unpinned, exactly as the CLI does when it can't read a hash).
206
+ function pinHash(blockId) {
207
+ const row = store.db.prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
208
+ return row?.h;
209
+ }
210
+ function resolveAt(repoId, at) {
211
+ if (!at || at === "start" || at === "end")
212
+ return at ?? "end";
213
+ if ("before" in at)
214
+ return { before: resolveBlockRef(repoId, at.before) };
215
+ return { after: resolveBlockRef(repoId, at.after) };
216
+ }
217
+ // Apply an already-built op list, honoring dry_run (preview, no drain) — the
218
+ // shared tail of every block-level tool below.
219
+ function applyOps(repoId, rootPath, ops, reason, dryRun) {
220
+ const res = apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason }, ...(dryRun !== undefined ? { dryRun } : {}) });
221
+ return dryRun ? ok(res) : okMutated(res);
222
+ }
223
+ // Guard shared by every mutating tool: a sourceless (headless-only) repo has no
224
+ // working tree to write through, so mutation is disabled with a loud error.
225
+ function requireRoot(rootPath) {
226
+ if (!rootPath)
227
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
228
+ return rootPath;
229
+ }
230
+ server.registerTool("docs_outline", {
231
+ description: "Orientation call. Returns a document's compact indented outline (alias/type/label per line, § marks section headings). IDs are stable; prefer them in follow-ups. Args take a doc id or path. For the whole document body in one call, use docs_read.",
232
+ inputSchema: {
233
+ doc: z.string().optional(),
234
+ path: z.string().optional(),
235
+ resolution: z.enum(["skeleton", "outline"]).optional(),
236
+ depth: z.number().int().optional(),
237
+ budget_tokens: z.number().int().optional(),
238
+ ...REPO_ARG,
239
+ },
240
+ }, async (args) => {
241
+ try {
242
+ const { repoId } = repoScope(args.repo);
243
+ const docId = resolveDocId(repoId, args);
244
+ const res = docsOutline(store, docId, {
245
+ ...(args.resolution ? { resolution: args.resolution } : {}),
246
+ ...(args.depth !== undefined ? { depth: args.depth } : {}),
247
+ ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
248
+ });
249
+ return ok(res);
250
+ }
251
+ catch (e) {
252
+ return fail(e);
253
+ }
254
+ });
255
+ server.registerTool("docs_read", {
256
+ description: "Read a whole document in one call: `content` is the complete file bytes verbatim (fences/tables/list markers preserved), `properties` is the document's property bag grouped by source — `{ frontmatter, inline, computed }` — plus `path`/`docId`/`rev`. What `properties.frontmatter` holds is format-dependent: for markdown it's the parsed frontmatter fence; for YAML/JSON it's the parsed object the file represents; other adapters extract per their format. `inline` holds dataview-style `key:: value` fields from the body; `computed` holds engine intrinsics (`$title`, `$tags`). The cold-start 'read the guide before doing anything' call. Args take a doc id or path. Pass include_ids:true to also get the document's block ids in order (`ids`) AND `hashes` — a {block id → content hash} map giving the exact `expect.content_hash` value the raw `apply` kernel needs, so one read yields both the ids and the CAS tokens to mutate them (no follow-up nodes_get_many hydration) — AND `parents`, a {block id → parent block id | null} map (null = top level), because `ids` is a flat pre-order walk and a list is otherwise indistinguishable from its items; filter to `parents[id] === null` for the top-level blocks. For structure-only orientation use docs_outline; to hydrate a single block use nodes_get.",
257
+ inputSchema: {
258
+ doc: z.string().optional(),
259
+ path: z.string().optional(),
260
+ include_ids: z.boolean().optional(),
261
+ ...REPO_ARG,
262
+ },
263
+ }, async (args) => {
264
+ try {
265
+ const { repoId } = repoScope(args.repo);
266
+ const docId = resolveDocId(repoId, args);
267
+ const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
268
+ if (!res)
269
+ throw new EngineError("doc_missing", `no document for ${JSON.stringify(args)}`);
270
+ return ok(res);
271
+ }
272
+ catch (e) {
273
+ return fail(e);
274
+ }
275
+ });
276
+ server.registerTool("docs_get_many", {
277
+ description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a doc id OR a path, same id-or-path symmetry docs_read accepts in its `doc` field), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`content` = complete file bytes verbatim, `properties` grouped by source, plus `path`/`docId`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block id → content hash} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it. Use this after query/text_search/resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline.",
278
+ inputSchema: {
279
+ docs: z.array(z.string()),
280
+ include_ids: z.boolean().optional(),
281
+ budget_tokens: z.number().int().optional(),
282
+ ...REPO_ARG,
283
+ },
284
+ }, async (args) => {
285
+ try {
286
+ const { repoId } = repoScope(args.repo);
287
+ const res = docsReadMany(store, repoId, args.docs, {
288
+ ...(args.include_ids ? { includeIds: true } : {}),
289
+ ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
290
+ });
291
+ return ok(res);
292
+ }
293
+ catch (e) {
294
+ return fail(e);
295
+ }
296
+ });
297
+ server.registerTool("nodes_get", {
298
+ description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full). Pass a block `id`; `doc`/`path` are optional — the owning document is inferred from the block id when omitted. The raw and full resolutions include the block's `content_hash` (its own raw hash) — the value update/split need in expect.content_hash, so you can fetch it before editing rather than reading it back from a conflict. Use this to expand the lean ids returned by query/resolve. To read a whole document in one call, use docs_read (nodes_get on a doc/heading id returns only that block, not the document).",
299
+ inputSchema: {
300
+ doc: z.string().optional(),
301
+ path: z.string().optional(),
302
+ id: z.string(),
303
+ resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
304
+ ...REPO_ARG,
305
+ },
306
+ }, async (args) => {
307
+ try {
308
+ const { repoId } = repoScope(args.repo);
309
+ const docId = resolveDocId(repoId, { doc: args.doc, path: args.path, block: args.id });
310
+ const node = nodesGet(store, docId, args.id, args.resolution ? { resolution: args.resolution } : {});
311
+ if (!node)
312
+ throw new EngineError("block_missing", `no block ${args.id}`);
313
+ return ok(node);
314
+ }
315
+ catch (e) {
316
+ return fail(e);
317
+ }
318
+ });
319
+ server.registerTool("nodes_get_many", {
320
+ description: "Fetch up to 100 blocks by id (in request order) with budget truncation. Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side; `doc`/`path` is an optional SCOPE (ids from other docs then count as unresolved), not a requirement. Result carries `nodes`, `truncated` (cap or `budget_tokens` hit), and `unresolved` — the requested ids that name no live block (never silently dropped).",
321
+ inputSchema: {
322
+ doc: z.string().optional(),
323
+ path: z.string().optional(),
324
+ ids: z.array(z.string()),
325
+ resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
326
+ budget_tokens: z.number().int().optional(),
327
+ ...REPO_ARG,
328
+ },
329
+ }, async (args) => {
330
+ try {
331
+ const { repoId } = repoScope(args.repo);
332
+ // Only an explicit doc/path scopes the fetch; never infer a doc from
333
+ // ids[0] — that silently dropped every id owned by another document.
334
+ const docId = args.doc || args.path ? resolveDocId(repoId, { doc: args.doc, path: args.path }) : null;
335
+ const res = nodesGetMany(store, docId, args.ids, {
336
+ ...(args.resolution ? { resolution: args.resolution } : {}),
337
+ ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
338
+ });
339
+ return ok(res);
340
+ }
341
+ catch (e) {
342
+ return fail(e);
343
+ }
344
+ });
345
+ server.registerTool("read_ref", {
346
+ description: "Read ANY ref — a document (id or path) OR a block (id or locator like `foo.md#Heading/p[2]`) — and get its content, classified. Resolves the ref server-side (the polymorphic `omg cat`): a document ref returns `{ kind:\"document\", content, path, docId, … }` (complete file bytes); a block ref returns `{ kind:\"block\", … }` — the block subtree at `resolution` (raw|text|outline|skeleton|full, default raw). Use this when you hold a ref and want its bytes WITHOUT first knowing whether it names a document or a block. Contrast: docs_read needs a doc; nodes_get needs a block id.",
347
+ inputSchema: {
348
+ ref: z.string(),
349
+ resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
350
+ ...REPO_ARG,
351
+ },
352
+ }, async (args) => {
353
+ try {
354
+ const { repoId } = repoScope(args.repo);
355
+ const resolved = resolveRef(store, repoId, args.ref);
356
+ if (!resolved)
357
+ throw new EngineError("doc_missing", `no document or block for ${JSON.stringify(args.ref)}`);
358
+ if (resolved.kind === "document") {
359
+ const res = docsRead(store, resolved.docId);
360
+ if (!res)
361
+ throw new EngineError("doc_missing", `no document for ${JSON.stringify(args.ref)}`);
362
+ return ok({ kind: "document", ...res });
363
+ }
364
+ const node = nodesGet(store, resolved.docId, resolved.blockId, { resolution: args.resolution ?? "raw" });
365
+ if (!node)
366
+ throw new EngineError("block_missing", `no block ${resolved.blockId}`);
367
+ return ok({ kind: "block", ...node });
368
+ }
369
+ catch (e) {
370
+ return fail(e);
371
+ }
372
+ });
373
+ server.registerTool("docs_tree", {
374
+ description: "ORIENTATION: the path-separator-aware shape of a repo in ONE small call — start here to answer 'what does this repo contain?', NOT with docs_list. Like `tree -L <depth>` crossed with `du`: every live document under `path` (a directory prefix; omit for the repo root) is collapsed at `depth` path segments (default 1 = immediate children), giving one entry per directory and one per shallow document. Returns `{ prefix, depth, total: { docs, blocks }, entries: [{ path, kind: \"dir\"|\"doc\", docs, blocks, ts }], truncated, cursor }`: a `dir` entry's path ends in `/` and its `docs`/`blocks`/`ts` are the totals + latest last-commit timestamp UNDER it; a `doc` entry is a single document (docs = 1). `total` always covers everything under `prefix` regardless of paging, so you see the true size of what you're looking at. On an 800-document repo `depth:1` at the root is ~10 rows where docs_list would be ~800; descend by calling again with `path` set to a dir entry (or raise `depth`). Entries are ordered by path and paged: `limit` (default " + DOCS_TREE_DEFAULT_LIMIT + ") / `cursor` / `budget_tokens` with an honest `truncated`. For ranked or attribute-based discovery use `resolve`/`query`; to enumerate the documents themselves once you know where to look, use docs_list with a `path_glob`.",
375
+ inputSchema: {
376
+ path: z.string().optional(),
377
+ depth: z.number().int().optional(),
378
+ limit: z.number().int().optional(),
379
+ cursor: z.string().nullable().optional(),
380
+ budget_tokens: z.number().int().optional(),
381
+ ...REPO_ARG,
382
+ },
383
+ }, async (args) => {
384
+ try {
385
+ const { repoId } = repoScope(args.repo);
386
+ return ok(docsTree(store, repoId, {
387
+ ...(args.path !== undefined ? { path: args.path } : {}),
388
+ ...(args.depth !== undefined ? { depth: args.depth } : {}),
389
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
390
+ ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
391
+ ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
392
+ }));
393
+ }
394
+ catch (e) {
395
+ return fail(e);
396
+ }
397
+ });
398
+ server.registerTool("docs_list", {
399
+ description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ path, blocks, ts }], truncated, cursor }` — repo-relative path, live block count, last-commit timestamp (ISO, or null) — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `resolve`.",
400
+ inputSchema: {
401
+ path_glob: z.string().optional(),
402
+ limit: z.number().int().optional(),
403
+ cursor: z.string().nullable().optional(),
404
+ budget_tokens: z.number().int().optional(),
405
+ ...REPO_ARG,
406
+ },
407
+ }, async (args) => {
408
+ try {
409
+ const { repoId } = repoScope(args.repo);
410
+ return ok(docsList(store, repoId, {
411
+ ...(args.path_glob !== undefined ? { pathGlob: args.path_glob } : {}),
412
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
413
+ ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
414
+ ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
415
+ }));
416
+ }
417
+ catch (e) {
418
+ return fail(e);
419
+ }
420
+ });
421
+ server.registerTool("query_syntax", {
422
+ description: "Reference: the full `query` syntax — targets, the CEL filter subset, absence semantics, structural functions, link-graph traversal (OQX `follow`), `select` projection, and worked examples. Call this before writing a non-trivial filter. No arguments.",
423
+ inputSchema: {},
424
+ }, async () => ok({ syntax: QUERY_SYNTAX }));
425
+ server.registerTool("query", {
426
+ description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { where kind == \"md:section\" select h: name, items: section.blocks collect { where type == \"list_item\" } }`). Inside a query block (and after a top-level `from`) the `where`/`select` keyword may be omitted: a leading PREDICATE-shaped expression is an implicit `where` (`nodes exists { kind == \"md:task\" }` ≡ `{ where kind == \"md:task\" }`), and a leading reference / `name: value` list is an implicit `select` (`nodes collect { attrs.text }` projects `attrs.text`). The choice is by SYNTAX, not type — a bare boolean-valued property still PROJECTS (`nodes collect { active }`), so to filter by it write `where active` or the predicate-shaped `active == true`. On the nodes/blocks targets a bare identifier that is not a structural field (kind/name/value; type/text) or `$`-intrinsic reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `from docs where layer == \"canon\" select $path values` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $value values }` yields a plain array. `$value` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $value == \"pricing\" }`); inside a block `^$value` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" && !checked }` = no open tasks; also a whole-query directive (`$repo.docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$value` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $value }`, `from nodes where kind == \"md:task\" && entries(attrs) exists { where $key == \"checked\" && $value }`; works on `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `&&`, `||`, `!`, and grouping (`layer == \"canon\" || nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `$repo.docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `from docs where nodes collect { ^open: value where kind == \"md:task\" && !attrs.checked } select $path, open` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the explicit root relations `$repo.docs` / `$repo.nodes` / `$repo.blocks` (an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `repo.<t> exists { where … == ^k }` = semi-join, `!… exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `$repo.<target>` (a root scan, from any depth). Top-level consumers: the whole query can be a consumer directive over a root receiver to change its result shape — `$repo.docs count { … }`, `$repo.docs exists { … }` and `$repo.docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `$repo.docs first { … }` / `$repo.docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (The `$repo.<target>` receiver is the same root relation used inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" && text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider (else semantic_unavailable) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `$path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where $id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" && $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in the follow-local successor `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). Consumers compose over the walk (`$repo.blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `from docs select outline: nodes collect { where kind == \"md:section\" && name == \"Overview\" select n: name, d: $depth follow section.children }` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any scope — `$repo.docs` / `$repo.nodes` / `$repo.blocks`. Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
427
+ inputSchema: {
428
+ query: z.string(),
429
+ limit: z.number().int().optional(),
430
+ cursor: z.string().nullable().optional(),
431
+ ...REPO_ARG,
432
+ },
433
+ }, async (args) => {
434
+ try {
435
+ const { repoId } = repoScope(args.repo);
436
+ // A `semantic(...)` query with no provider is semantic_unavailable (not a
437
+ // generic filter error) — surface that specific code, mirroring resolve.
438
+ if (!ctx.embedQuery && collectSemanticPhrases(args.query).length > 0) {
439
+ throw new EngineError("semantic_unavailable", "no embedding provider configured for this server");
440
+ }
441
+ return ok(await oqxRunAsync(store, repoId, args.query, {
442
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
443
+ ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
444
+ }, ctx.embedQuery));
445
+ }
446
+ catch (e) {
447
+ return fail(e);
448
+ }
449
+ });
450
+ server.registerTool("graph", {
451
+ description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, edges, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow doc.out`/`doc.in` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from edges` — reach for the `query` tool directly when you need successor/frontier predicates, `by`-keyed identity, `$ordinal` budgets, cross-document correlation, or the edge scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow { depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"$path\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `id`/`path`/`degree` = min hops from a root/`frontier` bool + your projections), `edges` (the traversed edges with full provenance — `predicate`/`provenance`/`dst_kind`/`anchor`/`src_field` + `src`/`dst`/`dst_path`/`dst_uri`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from edges` surfaces them), and `frontier` (the documents on the outer boundary — min degree == degrees; at degrees 0 that is the roots themselves).",
452
+ inputSchema: {
453
+ roots: z.array(z.string()).min(1),
454
+ degrees: z.number().int().optional(),
455
+ direction: z.enum(["in", "out", "both"]).optional(),
456
+ predicate: z.string().optional(),
457
+ select: z.array(z.string()).optional(),
458
+ max_documents: z.number().int().optional(),
459
+ ...REPO_ARG,
460
+ },
461
+ }, async (args) => {
462
+ try {
463
+ const { repoId } = repoScope(args.repo);
464
+ // A `select` using semantic(...) needs a provider — surface the specific
465
+ // code, mirroring the `query` tool.
466
+ if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`from docs select x: ${s}`).length > 0)) {
467
+ throw new EngineError("semantic_unavailable", "no embedding provider configured for this server");
468
+ }
469
+ return ok(await graphNeighborhood(store, repoId, args, ctx.embedQuery));
470
+ }
471
+ catch (e) {
472
+ return fail(e);
473
+ }
474
+ });
475
+ server.registerTool("text_search", {
476
+ description: "Full-text (FTS5, bm25-ranked) keyword search over block text. Input is treated as a search box — plain words are ANDed, \"quoted phrases\" match adjacency, punctuation like / is safe (no query DSL). For structured filtering or frontmatter projection use `query` instead (see query_syntax).",
477
+ inputSchema: { q: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
478
+ }, async (args) => {
479
+ try {
480
+ const { repoId } = repoScope(args.repo);
481
+ return ok(textSearch(store, repoId, args.q, args.limit !== undefined ? { limit: args.limit } : {}));
482
+ }
483
+ catch (e) {
484
+ return fail(e);
485
+ }
486
+ });
487
+ server.registerTool("resolve", {
488
+ description: "Resolve a name/title/concept to the concrete blocks it refers to — 'the id of the thing I mean'. Hybrid ranker (FTS + semantic when a provider is configured); best on short, entity-shaped inputs (a title, term, or phrase). Returns ranked {id, locator, preview, evidence}; prefer the returned ids in follow-up calls. For open natural-language questions or passage retrieval, use `query` with `semantic` instead.",
489
+ inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
490
+ }, async (args) => {
491
+ try {
492
+ const { repoId } = repoScope(args.repo);
493
+ const input = { repoId, query: args.query, ...(args.limit !== undefined ? { limit: args.limit } : {}) };
494
+ // Hybrid: fuse the query vector into the ranking when a provider is
495
+ // configured (mirrors CLI `omg find`). Absent ⇒ FTS-only.
496
+ if (ctx.embedQuery)
497
+ input.vector = await ctx.embedQuery(args.query);
498
+ return ok(resolveThing(store, input));
499
+ }
500
+ catch (e) {
501
+ return fail(e);
502
+ }
503
+ });
504
+ server.registerTool("apply", {
505
+ description: "The only real writer. Applies a changeset of kernel ops (insert/update/move/remove/split/merge) atomically — all apply or none. Each op is a tagged object keyed by \"op\"; the block an op targets is named by its `block` (update/split) or `blocks` (move/remove/merge) field, never `id`/`target`. Ops apply in order; later ops see earlier effects; minted ids are referenceable via \"$n.ids[i]\". Pass dry_run:true first for multi-doc changes to preview diffs. update/split/merge/remove take a CAS `expect.content_hash` — get it from docs_read include_ids (its `hashes` map) in the same read that gave you the ids, or use the ref-accepting block_* sugar (blocks_update/blocks_split/…) which pins it server-side. Conflicts carry current truth — including a missing/omitted expect.content_hash, whose error now returns the current hash — so retry from the error, don't re-read.",
506
+ inputSchema: {
507
+ ops: z.array(opSchema),
508
+ reason: z.string().optional(),
509
+ dry_run: z.boolean().optional(),
510
+ ...REPO_ARG,
511
+ },
512
+ }, async (args) => {
513
+ try {
514
+ const { repoId, rootPath } = repoScope(args.repo);
515
+ if (!rootPath)
516
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
517
+ const res = apply(store, {
518
+ repoId, rootPath,
519
+ ops: args.ops,
520
+ origin: { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) },
521
+ ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
522
+ });
523
+ // A dry run previews without writing — don't schedule a drain for it.
524
+ return args.dry_run ? ok(res) : okMutated(res);
525
+ }
526
+ catch (e) {
527
+ return fail(e);
528
+ }
529
+ });
530
+ // ---- block-level sugar (ref-accepting peers of the `apply` primitive) -------
531
+ // These mirror the CLI verbs `insert`/`update`/`move`/`rm`/`split`/`merge`
532
+ // 1:1. Each takes human REFS (a block id or anything resolveRef accepts),
533
+ // resolves them + pins CAS server-side, builds exactly the op the CLI would,
534
+ // and applies it. This is what lets `omg <verb> --server` feel identical to
535
+ // local: the resolution that used to live in the CLI now lives next to the
536
+ // store, so a remote client needs no local database.
537
+ server.registerTool("blocks_insert", {
538
+ description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block, or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Expands to one insert op through the kernel writer.",
539
+ inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
540
+ }, async (args) => {
541
+ try {
542
+ const { repoId, rootPath } = repoScope(args.repo);
543
+ const root = requireRoot(rootPath);
544
+ const { parent, docId } = resolveParentRef(repoId, args.to);
545
+ const to = { parent, at: resolveAt(repoId, args.at) };
546
+ // A document parent carries no block to infer the doc from; pin it.
547
+ const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown }];
548
+ return applyOps(repoId, root, ops, "blocks_insert", args.dry_run);
549
+ }
550
+ catch (e) {
551
+ return fail(e);
552
+ }
553
+ });
554
+ server.registerTool("blocks_update", {
555
+ description: "Replace a block's markdown (and/or set attrs) with compare-and-swap. `block` is a ref; `expect.content_hash` is pinned to the block's current bytes server-side when omitted (protects against a concurrent edit). One update op through the kernel writer. `markdown` may parse to SEVERAL sibling blocks (e.g. a paragraph followed by a list): the target keeps its id and takes the first, the rest are inserted right after it with fresh ids — the response carries `id` (the target) and `ids` (all resulting blocks, in order). A list item may likewise be replaced by a multi-item list (`- a\\n- b`), yielding sibling items. Task state is settable flat as `checked` (the same name reads flatten to — `from blocks where checked`), folded into attrs and synced to the `[ ]`/`[x]` in the raw. The general `attrs` bag is still accepted; note every OTHER block attr (heading `level`, code `lang`, list `ordered`) is derived from the markdown, so set those by editing `markdown`, not attrs.",
556
+ inputSchema: { block: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), attrs: z.record(z.string(), z.unknown()).optional(), expect: expectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
557
+ }, async (args) => {
558
+ try {
559
+ const { repoId, rootPath } = repoScope(args.repo);
560
+ const root = requireRoot(rootPath);
561
+ const block = resolveBlockRef(repoId, args.block);
562
+ const expect = args.expect ?? (() => { const h = pinHash(block); return h ? { content_hash: h } : undefined; })();
563
+ // Flat `checked` sugar folds into the attrs bag (explicit attrs wins on
564
+ // collision), so a task reads and writes under the same bare name.
565
+ const attrs = (args.checked !== undefined || args.attrs)
566
+ ? { ...(args.checked !== undefined ? { checked: args.checked } : {}), ...(args.attrs ?? {}) }
567
+ : undefined;
568
+ const ops = [{ op: "update", block, ...(args.markdown !== undefined ? { markdown: args.markdown } : {}), ...(attrs ? { attrs } : {}), ...(expect ? { expect } : {}) }];
569
+ const res = apply(store, { repoId, rootPath: root, ops, origin: { actor: "agent:mcp", reason: "blocks_update" }, ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}) });
570
+ // `ids` lists every resulting block (the target first, then any siblings
571
+ // minted from multi-block content); `id` keeps the target for convenience.
572
+ const ids = res.results[0]?.ids ?? [block];
573
+ const payload = { id: ids[0] ?? block, ids, ...res };
574
+ return args.dry_run ? ok(payload) : okMutated(payload);
575
+ }
576
+ catch (e) {
577
+ return fail(e);
578
+ }
579
+ });
580
+ server.registerTool("blocks_move", {
581
+ description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref, or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. One move op through the kernel writer.",
582
+ inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
583
+ }, async (args) => {
584
+ try {
585
+ const { repoId, rootPath } = repoScope(args.repo);
586
+ const root = requireRoot(rootPath);
587
+ const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
588
+ const { parent, docId } = resolveParentRef(repoId, args.to);
589
+ // The kernel reads `{ doc: true }` + start/end as "the SOURCE doc's top
590
+ // level", so a document parent is only honest when it IS the source doc.
591
+ if (typeof parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== docId) {
592
+ throw new EngineError("target_missing", `blocks_move cannot target another document's root (${args.to}); anchor on a block in that document with at.before/at.after`, { data: { to: args.to } });
593
+ }
594
+ const to = { parent, at: resolveAt(repoId, args.at) };
595
+ const ops = [{ op: "move", blocks, to }];
596
+ return applyOps(repoId, root, ops, "blocks_move", args.dry_run);
597
+ }
598
+ catch (e) {
599
+ return fail(e);
600
+ }
601
+ });
602
+ server.registerTool("blocks_remove", {
603
+ description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks and `removed` lists everything that left. One remove op through the kernel writer. To delete a whole document use docs_delete.",
604
+ inputSchema: { blocks: z.array(z.string()), dry_run: z.boolean().optional(), ...REPO_ARG },
605
+ }, async (args) => {
606
+ try {
607
+ const { repoId, rootPath } = repoScope(args.repo);
608
+ const root = requireRoot(rootPath);
609
+ const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
610
+ const ops = [{ op: "remove", blocks }];
611
+ return applyOps(repoId, root, ops, "blocks_remove", args.dry_run);
612
+ }
613
+ catch (e) {
614
+ return fail(e);
615
+ }
616
+ });
617
+ server.registerTool("blocks_split", {
618
+ description: "Split a block at character offset(s) into consecutive blocks. `block` is a ref; `at` is a list of integer offsets. CAS is pinned to the block's current bytes server-side. One split op through the kernel writer.",
619
+ inputSchema: { block: z.string(), at: z.array(z.number().int()), dry_run: z.boolean().optional(), ...REPO_ARG },
620
+ }, async (args) => {
621
+ try {
622
+ const { repoId, rootPath } = repoScope(args.repo);
623
+ const root = requireRoot(rootPath);
624
+ const block = resolveBlockRef(repoId, args.block);
625
+ const ops = [{ op: "split", block, at: args.at, expect: { content_hash: pinHash(block) ?? "" } }];
626
+ return applyOps(repoId, root, ops, "blocks_split", args.dry_run);
627
+ }
628
+ catch (e) {
629
+ return fail(e);
630
+ }
631
+ });
632
+ server.registerTool("blocks_merge", {
633
+ description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). One merge op through the kernel writer.",
634
+ inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
635
+ }, async (args) => {
636
+ try {
637
+ const { repoId, rootPath } = repoScope(args.repo);
638
+ const root = requireRoot(rootPath);
639
+ const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
640
+ const ops = [{ op: "merge", blocks, ...(args.separator !== undefined ? { separator: args.separator } : {}) }];
641
+ return applyOps(repoId, root, ops, "blocks_merge", args.dry_run);
642
+ }
643
+ catch (e) {
644
+ return fail(e);
645
+ }
646
+ });
647
+ server.registerTool("tasks_complete", {
648
+ description: "Macro: check (or, with checked:false, uncheck) the given task blocks. `blocks` are refs. Expands to update(attrs:{checked}) per block, CAS-pinned, applied via the same changeset machinery.",
649
+ inputSchema: { blocks: z.array(z.string()), checked: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
650
+ }, async (args) => {
651
+ try {
652
+ const { repoId, rootPath } = repoScope(args.repo);
653
+ const root = requireRoot(rootPath);
654
+ const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
655
+ const checked = args.checked ?? true;
656
+ const ops = checked
657
+ ? tasksComplete(store, blocks)
658
+ : blocks.map((block) => ({ op: "update", block, attrs: { checked: false }, ...(() => { const h = pinHash(block); return h ? { expect: { content_hash: h } } : {}; })() }));
659
+ return applyOps(repoId, root, ops, "tasks_complete", args.dry_run);
660
+ }
661
+ catch (e) {
662
+ return fail(e);
663
+ }
664
+ });
665
+ server.registerTool("node_set", {
666
+ description: "Macro: surgically set one editable property of a projected node (e.g. a link's `name` text or `value` target, a task's `checked`). Takes the node id (from query from:nodes), a property, and its new value. Resolves the node to its block and rewrites only that node's span, applying one update op. Nodes are read-only projections — this is the affordance to edit what a node represents without hand-rewriting the block. Errors node_not_editable (with the editable prop list) when the kind/prop has no editor.",
667
+ inputSchema: { node: z.string(), prop: z.string(), value: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
668
+ }, async (args) => {
669
+ try {
670
+ const { repoId, rootPath } = repoScope(args.repo);
671
+ const root = requireRoot(rootPath);
672
+ const ops = nodeSet(store, args.node, args.prop, args.value);
673
+ return applyOps(repoId, root, ops, "node_set", args.dry_run);
674
+ }
675
+ catch (e) {
676
+ return fail(e);
677
+ }
678
+ });
679
+ server.registerTool("sections_append", {
680
+ description: "Macro: append markdown at the end of a heading's section range. `heading` accepts the heading's block id (preferred, from docs_outline) OR its text — text is resolved to an id, scoped to `doc`/`path` when given. Heading text without a doc scope is repo-wide and errors ambiguous_heading (with candidate ids) when it isn't unique. Expands to a single insert op.",
681
+ inputSchema: { heading: z.string(), markdown: z.string(), doc: z.string().optional(), path: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
682
+ }, async (args) => {
683
+ try {
684
+ const { repoId, rootPath } = repoScope(args.repo);
685
+ const root = requireRoot(rootPath);
686
+ const headingId = resolveHeadingId(repoId, args.heading, { ...(args.doc ? { doc: args.doc } : {}), ...(args.path ? { path: args.path } : {}) });
687
+ const ops = sectionsAppend(headingId, args.markdown);
688
+ return applyOps(repoId, root, ops, "sections_append", args.dry_run);
689
+ }
690
+ catch (e) {
691
+ return fail(e);
692
+ }
693
+ });
694
+ server.registerTool("docs_append", {
695
+ description: "Macro: append markdown at the END of a whole document — the journal/log/running-note primitive (the document-root peer of sections_append, which appends inside a heading's section). ADDITIVE and identity-preserving, NOT a whole-body replace: the `text` is parsed into blocks and inserted as NEW top-level blocks after the document's existing ones, so every existing block keeps its stable `b_` id (omgbase deliberately has no whole-body `docs_put` — see mcp-api). Expands to a single insert op at the document top level (position end); commits atomically through the same kernel path as `apply`, returning the new revision(s) and the inserted block ids. Target the document with `doc` (id OR path) or `path`. The document must already exist — a missing doc errors `doc_missing` (creating one is docs_create's job, never this). To append inside a specific heading section instead, use sections_append.",
696
+ inputSchema: { doc: z.string().optional(), path: z.string().optional(), text: z.string(), ...REPO_ARG },
697
+ }, async (args) => {
698
+ try {
699
+ const { repoId, rootPath } = repoScope(args.repo);
700
+ if (!rootPath)
701
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
702
+ // Resolve the ref to a live doc id FIRST — a missing doc is doc_missing
703
+ // (resolveDocId throws it), never an auto-create. Then expand to the
704
+ // single top-level insert-at-end op and apply it (existing blocks keep
705
+ // their ids; only the appended blocks are minted).
706
+ const docId = resolveDocId(repoId, { ...(args.doc ? { doc: args.doc } : {}), ...(args.path ? { path: args.path } : {}) });
707
+ const ops = docsAppend(docId, args.text);
708
+ return okMutated(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "docs_append" } }));
709
+ }
710
+ catch (e) {
711
+ return fail(e);
712
+ }
713
+ });
714
+ server.registerTool("links_retarget", {
715
+ description: "Macro: rewrite ONE link destination everywhere it is linked — the single-pair form of links_repair (same matching rules: whole link destinations only, leading `/` optional, fragments preserved; prose/inline code/code fences untouched). Scope source docs with `path_glob`. ALWAYS call with dry_run:true first to preview `hits` + `pairs` (the dry run plans through the kernel, so it fails exactly where the apply would), then dry_run:false to apply.",
716
+ inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
717
+ }, async (args) => {
718
+ try {
719
+ const { repoId, rootPath } = repoScope(args.repo);
720
+ if (!rootPath)
721
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
722
+ const { ops, hits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
723
+ const dryRun = args.dry_run !== false;
724
+ const res = apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_retarget" }, dryRun });
725
+ if (dryRun)
726
+ return ok({ hits, pairs, applied: false, ...res });
727
+ return okMutated({ hits, pairs, applied: true, ...res });
728
+ }
729
+ catch (e) {
730
+ return fail(e);
731
+ }
732
+ });
733
+ server.registerTool("links_stale", {
734
+ description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each with srcPath, srcBlock, predicate, provenance, `target` = the canonical missing path WITHOUT a leading `/` (e.g. `guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{srcPath,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
735
+ inputSchema: { path_glob: z.string().optional(), limit: z.number().int().optional(), summary: z.boolean().optional(), ...REPO_ARG },
736
+ }, async (args) => {
737
+ try {
738
+ const { repoId } = repoScope(args.repo);
739
+ if (args.summary)
740
+ return ok(linksStaleSummary(store, repoId, args.path_glob ? { pathGlob: args.path_glob } : {}));
741
+ return ok(linksStale(store, repoId, {
742
+ ...(args.path_glob ? { pathGlob: args.path_glob } : {}),
743
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
744
+ }));
745
+ }
746
+ catch (e) {
747
+ return fail(e);
748
+ }
749
+ });
750
+ server.registerTool("links_repair", {
751
+ description: "Macro: BULK stale-link repair — rewrite one or MANY LINK DESTINATIONS in ONE changeset. Pass `repairs` (an array of {from,to}) to fix several dangling targets — e.g. those surfaced by links_stale — at once; a single {from_target,to_target} pair is also accepted. MATCHING: `from` must be the WHOLE destination of a Markdown link/image `[t](dest)`, a wikilink `[[dest]]`/`[[dest|alias]]`, or a bare-path inline field `key:: /dest` — a leading `/` is optional on either side (so links_stale's `target` or `authored` both work), and a trailing `#heading`/`^ref` fragment on the link is kept and re-appended to `to`. NOT rewritten: prose mentions, inline code, `code_fence` blocks, longer paths that merely contain `from`, and frontmatter values (use docs_set_meta). Scope source docs with `path_glob` (as in links_stale). Ops coalesce to one update per TOP-MOST block (a list and its items count once), so a batch never trips over its own re-minted children. Returns `hits[{block,path,oldRaw,newRaw}]` and `pairs[{from,to,hits}]` (per-pair destination counts; 0 = nothing matched). ALWAYS call with dry_run:true first — the dry run plans through the kernel (returns `diffs`), so it fails exactly where the apply would — then dry_run:false to apply.",
752
+ inputSchema: {
753
+ repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
754
+ from_target: z.string().optional(),
755
+ to_target: z.string().optional(),
756
+ path_glob: z.string().optional(),
757
+ dry_run: z.boolean().optional(),
758
+ ...REPO_ARG,
759
+ },
760
+ }, async (args) => {
761
+ try {
762
+ const { repoId, rootPath } = repoScope(args.repo);
763
+ if (!rootPath)
764
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
765
+ const repairs = args.repairs
766
+ ? args.repairs
767
+ : args.from_target !== undefined && args.to_target !== undefined
768
+ ? [{ from: args.from_target, to: args.to_target }]
769
+ : null;
770
+ if (!repairs || repairs.length === 0) {
771
+ throw new EngineError("target_missing", "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair");
772
+ }
773
+ const { ops, hits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
774
+ // The dry run plans through the same kernel path (dryRun: no write, no
775
+ // drain) so a preview surfaces exactly the failure an apply would hit.
776
+ const dryRun = args.dry_run !== false;
777
+ const res = apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_repair" }, dryRun });
778
+ if (dryRun)
779
+ return ok({ hits, pairs, applied: false, ...res });
780
+ return okMutated({ hits, pairs, applied: true, ...res });
781
+ }
782
+ catch (e) {
783
+ return fail(e);
784
+ }
785
+ });
786
+ // Doc-level operations (06 §API). The MCP server serializes writes in-process,
787
+ // so these pass no omgbaseDir (the flock is for cross-process CLI writers);
788
+ // MCP-originated writes are actor agent:mcp.
789
+ const docCtx = (scope) => {
790
+ if (!scope.rootPath)
791
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
792
+ return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp" };
793
+ };
794
+ server.registerTool("docs_create", {
795
+ description: "Create a new document at `path` from complete file bytes (`markdown`), with optional structured `frontmatter`. Fails path_taken if it already exists.",
796
+ inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
797
+ }, async (args) => {
798
+ try {
799
+ return okMutated(docsCreate(store, docCtx(repoScope(args.repo)), args.path, args.markdown, args.frontmatter));
800
+ }
801
+ catch (e) {
802
+ return fail(e);
803
+ }
804
+ });
805
+ server.registerTool("docs_move", {
806
+ description: "Rename a document to a new repo-relative path; block identity and history are preserved. Fails path_taken if the destination exists. Links follow the PATH, not the identity: inbound links written against the old path now dangle (their edges become `phantom:<old path>`, so links_stale reports them), and links already written against the new path start resolving to this doc. The result lists those `dangling` inbound links ({doc, path, block, target, anchor, field?} per occurrence; block null = a frontmatter relation). Pass `retarget_inbound:true` to rewrite them in the same call — a destination-aware rewrite (anchors/link text/code spans preserved, absolute vs relative style kept) applied as one CAS-checked changeset, after which `dangling` holds only what could not be rewritten (frontmatter relations) and `retargeted` lists the touched blocks/docs.",
807
+ inputSchema: { doc: z.string(), to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
808
+ }, async (args) => {
809
+ try {
810
+ return okMutated(docsMove(store, docCtx(repoScope(args.repo)), args.doc, args.to_path, { retargetInbound: args.retarget_inbound === true }));
811
+ }
812
+ catch (e) {
813
+ return fail(e);
814
+ }
815
+ });
816
+ server.registerTool("docs_delete", {
817
+ description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Requires the explicit doc id/path.",
818
+ inputSchema: { doc: z.string(), ...REPO_ARG },
819
+ }, async (args) => {
820
+ try {
821
+ return okMutated(docsDelete(store, docCtx(repoScope(args.repo)), args.doc));
822
+ }
823
+ catch (e) {
824
+ return fail(e);
825
+ }
826
+ });
827
+ server.registerTool("docs_set_meta", {
828
+ description: "Surgical frontmatter patch: set the given keys and/or unset named keys, re-ingesting the document. Other frontmatter is preserved.",
829
+ inputSchema: { doc: z.string(), set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
830
+ }, async (args) => {
831
+ try {
832
+ return okMutated(docsSetMeta(store, docCtx(repoScope(args.repo)), args.doc, {
833
+ ...(args.set ? { set: args.set } : {}),
834
+ ...(args.unset ? { unset: args.unset } : {}),
835
+ }));
836
+ }
837
+ catch (e) {
838
+ return fail(e);
839
+ }
840
+ });
841
+ server.registerTool("docs_plan_update", {
842
+ description: "Plan a whole-document update WITHOUT applying it. Given a doc (id or path) and the proposed complete `content`, reconciles the new representation against the current stable block tree and returns an executable *opset*: the exact kernel ops (insert/update/move/remove) it would run, each annotated with its identity consequence (disposition, confidence, reason), plus a summary (preserved/updated/moved/created/removed) and a human-readable plan. The opset carries preconditions (base revision + content hash); a stale plan is refused at apply. `converges` is verified by simulation (replaying the ops reproduces `content` byte-for-byte); when a lowering diverges, `diagnostics[]` names the first differing byte offset and the proposed block (index/type/byte range) that failed to round-trip. Use this to inspect identity effects before committing, or as the reviewable half of docs_update.",
843
+ inputSchema: { doc: z.string(), content: z.string(), ...REPO_ARG },
844
+ }, async (args) => {
845
+ try {
846
+ const { repoId, rootPath } = repoScope(args.repo);
847
+ if (!rootPath)
848
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
849
+ const opset = planUpdate(store, repoId, rootPath, args.doc, args.content);
850
+ return ok({ opset, plan: renderOpsetPlan(opset) });
851
+ }
852
+ catch (e) {
853
+ return fail(e);
854
+ }
855
+ });
856
+ server.registerTool("docs_update", {
857
+ description: "Whole-document update with smart identity preservation. Submit the complete proposed `content` for a doc (id or path); the engine reconciles it against the current tree, preserving stable block ids for structure that is recognizably the same (edits, moves, reorders), minting for new structure, and tombstoning removals — then commits the derived opset through the kernel write path. Frontmatter changes are applied too. dry_run:true returns the opset + plan without writing (identical to docs_plan_update). Conflicts (the doc changed since planning) fail stale_plan — re-run. A plan whose replay cannot reproduce `content` byte-for-byte fails plan_not_convergent; the message names the first divergent byte and the proposed block (index/type) that failed to round-trip (full diagnostics in `data.diagnostics`). This is docs_plan_update + apply(opset).",
858
+ inputSchema: { doc: z.string(), content: z.string(), reason: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
859
+ }, async (args) => {
860
+ try {
861
+ const { opset, result } = docsUpdate(store, docCtx(repoScope(args.repo)), args.doc, args.content, {
862
+ ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
863
+ ...(args.reason !== undefined ? { reason: args.reason } : {}),
864
+ });
865
+ const payload = { opset, plan: renderOpsetPlan(opset), result };
866
+ return args.dry_run ? ok(payload) : okMutated(payload);
867
+ }
868
+ catch (e) {
869
+ return fail(e);
870
+ }
871
+ });
872
+ server.registerTool("observe", {
873
+ description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use docs_delete. Returns doc/path/rev plus a disposition summary (how identity threaded).",
874
+ inputSchema: { path: z.string(), content: z.string(), ...REPO_ARG },
875
+ }, async (args) => {
876
+ try {
877
+ const { repoId } = repoScope(args.repo);
878
+ return okMutated(observeFile(store, repoId, args.path, args.content));
879
+ }
880
+ catch (e) {
881
+ return fail(e);
882
+ }
883
+ });
884
+ server.registerTool("observe_many", {
885
+ description: "BATCH sync ingest (ADR-014): observe several files at once — the batch form of `observe`. Give `files` as an array of `{path, content}`; each is echo-gated and reconciled independently, all under one timestamp and a single resurrection-pool sweep (cheaper than N `observe` calls for an initial walk or a large checkpoint). Returns one result per input file (same shape as `observe`). Mirror deletions with `docs_delete`.",
886
+ inputSchema: { files: z.array(z.object({ path: z.string(), content: z.string() })), ...REPO_ARG },
887
+ }, async (args) => {
888
+ try {
889
+ const { repoId } = repoScope(args.repo);
890
+ return okMutated(observeMany(store, repoId, args.files));
891
+ }
892
+ catch (e) {
893
+ return fail(e);
894
+ }
895
+ });
896
+ server.registerTool("observe_delete", {
897
+ description: "SYNC DELETE (ADR-014): record that `path` left the source scope — tombstone the live doc as an OBSERVED deletion (its blocks are pooled for resurrection if the path reappears; no file is removed, since it is already gone from the source). The deletion counterpart to `observe`, for a synchronizer mirroring an external delete. Idempotent: a path with no live doc is a no-op (`deleted:false`). Contrast `docs_delete` — an api-origin, intentional, non-pooled removal that also unlinks the working-tree file.",
898
+ inputSchema: { path: z.string(), ...REPO_ARG },
899
+ }, async (args) => {
900
+ try {
901
+ const { repoId } = repoScope(args.repo);
902
+ return okMutated(observeDelete(store, repoId, args.path));
903
+ }
904
+ catch (e) {
905
+ return fail(e);
906
+ }
907
+ });
908
+ server.registerTool("history_node", {
909
+ description: "A block's biography: the commits that touched it with disposition kind/confidence/reason, newest first.",
910
+ inputSchema: { id: z.string(), limit: z.number().int().optional() },
911
+ }, async (args) => {
912
+ try {
913
+ return ok(historyNode(store, args.id, args.limit !== undefined ? { limit: args.limit } : {}));
914
+ }
915
+ catch (e) {
916
+ return fail(e);
917
+ }
918
+ });
919
+ server.registerTool("diff", {
920
+ description: "Block-grain diff between two revisions of a document: added/removed/changed blocks.",
921
+ inputSchema: { doc: z.string(), from_rev: z.string(), to_rev: z.string(), ...REPO_ARG },
922
+ }, async (args) => {
923
+ try {
924
+ const { repoId } = repoScope(args.repo);
925
+ // Resolve `doc` id-or-path FIRST: diffBlocks keys its SQL on the doc id,
926
+ // so a raw path here would silently return an empty diff (no error) —
927
+ // the misleading-empty trap. resolveDocId throws doc_missing when the
928
+ // ref names no document.
929
+ const docId = resolveDocId(repoId, { doc: args.doc });
930
+ return ok(diffBlocks(store, docId, args.from_rev, args.to_rev));
931
+ }
932
+ catch (e) {
933
+ return fail(e);
934
+ }
935
+ });
936
+ server.registerTool("diff_unified", {
937
+ description: "Line-based unified diff between two revisions of a document (the `omg diff` rendering): returns `{ doc, path, from, to, diff }` where `diff` is the +/- unified text. `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `diff`, which returns block-grain added/removed/changed entries.",
938
+ inputSchema: { doc: z.string(), from_rev: z.string().optional(), to_rev: z.string().optional(), ...REPO_ARG },
939
+ }, async (args) => {
940
+ try {
941
+ const { repoId } = repoScope(args.repo);
942
+ const docId = resolveDocId(repoId, { doc: args.doc });
943
+ const revs = store.db
944
+ .prepare("SELECT rev_id FROM revisions WHERE doc_id = ? ORDER BY seq DESC LIMIT 2")
945
+ .all(docId);
946
+ const toRev = args.to_rev ?? revs[0]?.rev_id;
947
+ const fromRev = args.from_rev ?? revs[1]?.rev_id ?? revs[0]?.rev_id;
948
+ if (!toRev || !fromRev)
949
+ throw new EngineError("target_missing", `no revisions to diff for ${JSON.stringify(args.doc)}`);
950
+ const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path ?? "";
951
+ return ok({ doc: docId, path, from: fromRev, to: toRev, diff: diffUnified(store, docId, fromRev, toRev) });
952
+ }
953
+ catch (e) {
954
+ return fail(e);
955
+ }
956
+ });
957
+ server.registerTool("docs_read_at", {
958
+ description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `content` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `path`/`docId`/`rev`. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from diff, history_node, or changes_since. Contrast: docs_read = current bytes; diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. `properties` are CURRENT values (propertiesAreCurrent:true), since property history is not stored. Args take a doc id or path plus a rev id.",
959
+ inputSchema: { doc: z.string().optional(), path: z.string().optional(), rev: z.string(), ...REPO_ARG },
960
+ }, async (args) => {
961
+ try {
962
+ const { repoId } = repoScope(args.repo);
963
+ const docId = resolveDocId(repoId, args);
964
+ const res = readDocumentAtRevision(store, docId, args.rev);
965
+ if (!res)
966
+ throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
967
+ return ok(res);
968
+ }
969
+ catch (e) {
970
+ return fail(e);
971
+ }
972
+ });
973
+ server.registerTool("docs_history", {
974
+ description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc, its ordered revision list (oldest→newest) with `rev`/`seq`/`commit`/`ts`/`origin`/`actor`/`contentHash`/`isCurrent`. Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single `doc` (id or path); at least one is required. `contentHash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `rev` into `docs_read_at` (whole doc AT that rev) or `diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
975
+ inputSchema: {
976
+ path_glob: z.string().optional(),
977
+ doc: z.string().optional(),
978
+ include_deleted: z.boolean().optional(),
979
+ limit: z.number().int().optional(),
980
+ ...REPO_ARG,
981
+ },
982
+ }, async (args) => {
983
+ try {
984
+ const { repoId } = repoScope(args.repo);
985
+ if (!args.path_glob && !args.doc) {
986
+ throw new EngineError("target_missing", "docs_history requires one of path_glob or doc");
987
+ }
988
+ if (args.doc) {
989
+ // Validate the ref resolves (or, when include_deleted, a tombstoned doc
990
+ // exists). findDocByRef does the id-or-path dispatch (isValidId); the
991
+ // tombstone fallback uses the same dispatch.
992
+ const info = findDocByRef(store, repoId, args.doc);
993
+ const asId = isValidId(args.doc, "d");
994
+ const known = info || (args.include_deleted
995
+ ? store.db.prepare(asId ? "SELECT 1 FROM docs WHERE doc_id = ?" : "SELECT 1 FROM docs WHERE repo_id = ? AND path = ?").get(...(asId ? [args.doc] : [repoId, args.doc]))
996
+ : undefined);
997
+ if (!known)
998
+ throw new EngineError("doc_missing", `no document for ${JSON.stringify(args.doc)}`);
999
+ }
1000
+ return ok(docHistory(store, repoId, {
1001
+ ...(args.path_glob ? { pathGlob: args.path_glob } : {}),
1002
+ ...(args.doc ? { doc: args.doc } : {}),
1003
+ ...(args.include_deleted !== undefined ? { includeDeleted: args.include_deleted } : {}),
1004
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
1005
+ }));
1006
+ }
1007
+ catch (e) {
1008
+ return fail(e);
1009
+ }
1010
+ });
1011
+ server.registerTool("changes_since", {
1012
+ description: "The change feed: commit digests after a cursor (repo commit seq) with one-line summaries. Each digest's `revisions[]` carries `{doc, path, contentHash}` (contentHash = hex of the revision's rendered file hash), so a synchronizer can decide 'changed vs echo' without a follow-up docs_read. Filter by `origin` (api/observed/import) to ignore commits a given writer produced. Poll with your last cursor to cheaply re-orient after time away. Paging: `cursor` is the last digest's `seq` (a dense per-REPO order — a cursor is only meaningful against the same `repo` it came from); pass it back while `truncated` is true. `head` is the repo's current max seq: an empty page with cursor == head means nothing new, cursor > head means the cursor belongs to a different repo/server.",
1013
+ inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
1014
+ }, async (args) => {
1015
+ try {
1016
+ const { repoId } = repoScope(args.repo);
1017
+ return ok(changesSince(store, repoId, {
1018
+ ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
1019
+ ...(args.origin ? { origin: args.origin } : {}),
1020
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
1021
+ }));
1022
+ }
1023
+ catch (e) {
1024
+ return fail(e);
1025
+ }
1026
+ });
1027
+ server.registerTool("repos_status", {
1028
+ description: "Repo counts: docs, blocks, commits, open edges, unconverged doc count, and on-disk drift. " +
1029
+ "`disk` reports a READ-ONLY scan of the working tree vs the DB: `changed` (files edited on disk but not re-ingested), " +
1030
+ "`deleted` (docs whose file is gone from disk), `untracked` (new *.md not yet ingested), and `checked` " +
1031
+ "(false when the server has no working-tree path — disk agreement is then UNVERIFIED, not clean).",
1032
+ inputSchema: { ...REPO_ARG },
1033
+ }, async (args) => {
1034
+ try {
1035
+ const { repoId, rootPath } = repoScope(args.repo);
1036
+ return ok(reposStatus(store, repoId, rootPath));
1037
+ }
1038
+ catch (e) {
1039
+ return fail(e);
1040
+ }
1041
+ });
1042
+ server.registerTool("sync_status", {
1043
+ description: "Watcher/sync state: last commit seq, last checkpoint, and whether the repo is convergent. " +
1044
+ "`convergent` is true ONLY when the DB is internally converged AND a working-tree scan ran (`diskChecked`) AND found no drift " +
1045
+ "(`disk.changed`/`deleted`/`untracked` all 0). If the server has no working-tree path, `diskChecked` is false and `convergent` is " +
1046
+ "false — a green light is never shown while disk freshness is unverified, so an agent can trust `convergent: true` to mean not stale.",
1047
+ inputSchema: { ...REPO_ARG },
1048
+ }, async (args) => {
1049
+ try {
1050
+ const { repoId, rootPath } = repoScope(args.repo);
1051
+ return ok(syncStatus(store, repoId, rootPath));
1052
+ }
1053
+ catch (e) {
1054
+ return fail(e);
1055
+ }
1056
+ });
1057
+ // List the repos in this workspace (multi-repo discovery for a client): slug +
1058
+ // whether it has a filesystem source. The `repo` arg other tools take is a slug
1059
+ // from here. (repos_status gives per-repo counts.)
1060
+ server.registerTool("repos", {
1061
+ description: "List the repos in this workspace: `{ repos: [{ slug, hasSource }] }`. A client addresses any of them by passing that `slug` as the `repo` argument to another tool (omit `repo` ⇒ the server's default repo). Use repos_status for per-repo counts/convergence.",
1062
+ inputSchema: {},
1063
+ }, async () => {
1064
+ try {
1065
+ const rows = store.db
1066
+ .prepare("SELECT r.slug AS slug, MAX(CASE WHEN s.adapter = 'fs' THEN 1 ELSE 0 END) AS has_fs FROM repos r LEFT JOIN attachments a ON a.repo_id = r.repo_id LEFT JOIN sources s ON s.source_id = a.source_id GROUP BY r.repo_id, r.slug ORDER BY r.slug")
1067
+ .all();
1068
+ return ok({ repos: rows.map((r) => ({ slug: r.slug, hasSource: r.has_fs === 1 })) });
1069
+ }
1070
+ catch (e) {
1071
+ return fail(e);
1072
+ }
1073
+ });
1074
+ return server;
1075
+ }
1076
+ //# sourceMappingURL=server.js.map