@aitne/daemon 0.1.4 → 0.1.6

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 (268) hide show
  1. package/dist/adapters/notification-manager.d.ts +12 -0
  2. package/dist/adapters/notification-manager.d.ts.map +1 -1
  3. package/dist/adapters/notification-manager.js +39 -1
  4. package/dist/adapters/notification-manager.js.map +1 -1
  5. package/dist/api/routes/agent.d.ts.map +1 -1
  6. package/dist/api/routes/agent.js +7 -0
  7. package/dist/api/routes/agent.js.map +1 -1
  8. package/dist/api/routes/commands.d.ts.map +1 -1
  9. package/dist/api/routes/commands.js +16 -13
  10. package/dist/api/routes/commands.js.map +1 -1
  11. package/dist/api/routes/context.d.ts.map +1 -1
  12. package/dist/api/routes/context.js +13 -2
  13. package/dist/api/routes/context.js.map +1 -1
  14. package/dist/api/routes/dashboard.d.ts.map +1 -1
  15. package/dist/api/routes/dashboard.js +28 -0
  16. package/dist/api/routes/dashboard.js.map +1 -1
  17. package/dist/api/routes/fs.d.ts +23 -0
  18. package/dist/api/routes/fs.d.ts.map +1 -0
  19. package/dist/api/routes/fs.js +156 -0
  20. package/dist/api/routes/fs.js.map +1 -0
  21. package/dist/api/routes/fs.logic.d.ts +62 -0
  22. package/dist/api/routes/fs.logic.d.ts.map +1 -0
  23. package/dist/api/routes/fs.logic.js +137 -0
  24. package/dist/api/routes/fs.logic.js.map +1 -0
  25. package/dist/api/routes/health.d.ts.map +1 -1
  26. package/dist/api/routes/health.js +4 -2
  27. package/dist/api/routes/health.js.map +1 -1
  28. package/dist/api/routes/integrations.d.ts.map +1 -1
  29. package/dist/api/routes/integrations.js +8 -6
  30. package/dist/api/routes/integrations.js.map +1 -1
  31. package/dist/api/routes/metrics.d.ts +1 -0
  32. package/dist/api/routes/metrics.d.ts.map +1 -1
  33. package/dist/api/routes/metrics.js +24 -0
  34. package/dist/api/routes/metrics.js.map +1 -1
  35. package/dist/api/routes/observations.d.ts.map +1 -1
  36. package/dist/api/routes/observations.js +538 -25
  37. package/dist/api/routes/observations.js.map +1 -1
  38. package/dist/api/routes/skills.d.ts +9 -1
  39. package/dist/api/routes/skills.d.ts.map +1 -1
  40. package/dist/api/routes/skills.js +38 -16
  41. package/dist/api/routes/skills.js.map +1 -1
  42. package/dist/api/routes/wiki.d.ts +4 -0
  43. package/dist/api/routes/wiki.d.ts.map +1 -0
  44. package/dist/api/routes/wiki.js +1075 -0
  45. package/dist/api/routes/wiki.js.map +1 -0
  46. package/dist/api/server.d.ts +13 -0
  47. package/dist/api/server.d.ts.map +1 -1
  48. package/dist/api/server.js +27 -1
  49. package/dist/api/server.js.map +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +26 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/core/agent-core.d.ts +25 -0
  54. package/dist/core/agent-core.d.ts.map +1 -1
  55. package/dist/core/agent-core.js.map +1 -1
  56. package/dist/core/backends/backend-router.d.ts +5 -1
  57. package/dist/core/backends/backend-router.d.ts.map +1 -1
  58. package/dist/core/backends/backend-router.js +10 -1
  59. package/dist/core/backends/backend-router.js.map +1 -1
  60. package/dist/core/backends/claude-code-core.d.ts.map +1 -1
  61. package/dist/core/backends/claude-code-core.js +62 -4
  62. package/dist/core/backends/claude-code-core.js.map +1 -1
  63. package/dist/core/backends/claude-tool-collection.d.ts +1 -1
  64. package/dist/core/backends/claude-tool-collection.d.ts.map +1 -1
  65. package/dist/core/backends/claude-tool-collection.js +327 -65
  66. package/dist/core/backends/claude-tool-collection.js.map +1 -1
  67. package/dist/core/backends/codex-core.d.ts.map +1 -1
  68. package/dist/core/backends/codex-core.js +36 -0
  69. package/dist/core/backends/codex-core.js.map +1 -1
  70. package/dist/core/backends/gemini-cli-core.d.ts +24 -5
  71. package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
  72. package/dist/core/backends/gemini-cli-core.js +62 -30
  73. package/dist/core/backends/gemini-cli-core.js.map +1 -1
  74. package/dist/core/backends/plan-presets.d.ts +3 -1
  75. package/dist/core/backends/plan-presets.d.ts.map +1 -1
  76. package/dist/core/backends/plan-presets.js +42 -2
  77. package/dist/core/backends/plan-presets.js.map +1 -1
  78. package/dist/core/bang-commands/commands-help.d.ts +5 -0
  79. package/dist/core/bang-commands/commands-help.d.ts.map +1 -0
  80. package/dist/core/bang-commands/commands-help.js +69 -0
  81. package/dist/core/bang-commands/commands-help.js.map +1 -0
  82. package/dist/core/bang-commands/commands-wiki.d.ts +75 -0
  83. package/dist/core/bang-commands/commands-wiki.d.ts.map +1 -0
  84. package/dist/core/bang-commands/commands-wiki.js +574 -0
  85. package/dist/core/bang-commands/commands-wiki.js.map +1 -0
  86. package/dist/core/bang-commands/index.d.ts +4 -2
  87. package/dist/core/bang-commands/index.d.ts.map +1 -1
  88. package/dist/core/bang-commands/index.js +15 -1
  89. package/dist/core/bang-commands/index.js.map +1 -1
  90. package/dist/core/bang-commands/registry.d.ts +47 -4
  91. package/dist/core/bang-commands/registry.d.ts.map +1 -1
  92. package/dist/core/bang-commands/registry.js +85 -15
  93. package/dist/core/bang-commands/registry.js.map +1 -1
  94. package/dist/core/context-builder.d.ts +17 -0
  95. package/dist/core/context-builder.d.ts.map +1 -1
  96. package/dist/core/context-builder.js +64 -6
  97. package/dist/core/context-builder.js.map +1 -1
  98. package/dist/core/daemon-api-cli.d.ts.map +1 -1
  99. package/dist/core/daemon-api-cli.js +50 -2
  100. package/dist/core/daemon-api-cli.js.map +1 -1
  101. package/dist/core/dispatcher-message-handler.d.ts.map +1 -1
  102. package/dist/core/dispatcher-message-handler.js +10 -0
  103. package/dist/core/dispatcher-message-handler.js.map +1 -1
  104. package/dist/core/dispatcher-morning-routine.d.ts.map +1 -1
  105. package/dist/core/dispatcher-morning-routine.js +17 -2
  106. package/dist/core/dispatcher-morning-routine.js.map +1 -1
  107. package/dist/core/dispatcher-result-processor.d.ts +23 -0
  108. package/dist/core/dispatcher-result-processor.d.ts.map +1 -1
  109. package/dist/core/dispatcher-result-processor.js +124 -5
  110. package/dist/core/dispatcher-result-processor.js.map +1 -1
  111. package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -1
  112. package/dist/core/dispatcher-scheduled-tasks.js +114 -80
  113. package/dist/core/dispatcher-scheduled-tasks.js.map +1 -1
  114. package/dist/core/dispatcher-types.d.ts +116 -1
  115. package/dist/core/dispatcher-types.d.ts.map +1 -1
  116. package/dist/core/dispatcher-types.js.map +1 -1
  117. package/dist/core/dispatcher.d.ts +36 -0
  118. package/dist/core/dispatcher.d.ts.map +1 -1
  119. package/dist/core/dispatcher.js +94 -1
  120. package/dist/core/dispatcher.js.map +1 -1
  121. package/dist/core/integration-lifecycle.d.ts.map +1 -1
  122. package/dist/core/integration-lifecycle.js +6 -8
  123. package/dist/core/integration-lifecycle.js.map +1 -1
  124. package/dist/core/metrics.d.ts +127 -0
  125. package/dist/core/metrics.d.ts.map +1 -1
  126. package/dist/core/metrics.js +256 -1
  127. package/dist/core/metrics.js.map +1 -1
  128. package/dist/core/prompts.d.ts +2 -1
  129. package/dist/core/prompts.d.ts.map +1 -1
  130. package/dist/core/prompts.js +40 -0
  131. package/dist/core/prompts.js.map +1 -1
  132. package/dist/core/roadmap-validate.js +13 -1
  133. package/dist/core/roadmap-validate.js.map +1 -1
  134. package/dist/core/routine-acquisition-plan.d.ts +51 -0
  135. package/dist/core/routine-acquisition-plan.d.ts.map +1 -1
  136. package/dist/core/routine-acquisition-plan.js +111 -12
  137. package/dist/core/routine-acquisition-plan.js.map +1 -1
  138. package/dist/core/routine-fetch-window-retry.d.ts +109 -0
  139. package/dist/core/routine-fetch-window-retry.d.ts.map +1 -0
  140. package/dist/core/routine-fetch-window-retry.js +210 -0
  141. package/dist/core/routine-fetch-window-retry.js.map +1 -0
  142. package/dist/core/routine-fetch-window-runner.d.ts +258 -32
  143. package/dist/core/routine-fetch-window-runner.d.ts.map +1 -1
  144. package/dist/core/routine-fetch-window-runner.js +1115 -185
  145. package/dist/core/routine-fetch-window-runner.js.map +1 -1
  146. package/dist/core/routine-windows.d.ts +19 -4
  147. package/dist/core/routine-windows.d.ts.map +1 -1
  148. package/dist/core/routine-windows.js +47 -0
  149. package/dist/core/routine-windows.js.map +1 -1
  150. package/dist/core/scheduler.d.ts +50 -2
  151. package/dist/core/scheduler.d.ts.map +1 -1
  152. package/dist/core/scheduler.js +88 -7
  153. package/dist/core/scheduler.js.map +1 -1
  154. package/dist/core/skill-curation/declarations.d.ts.map +1 -1
  155. package/dist/core/skill-curation/declarations.js +11 -12
  156. package/dist/core/skill-curation/declarations.js.map +1 -1
  157. package/dist/core/skill-source-paths.d.ts +14 -0
  158. package/dist/core/skill-source-paths.d.ts.map +1 -0
  159. package/dist/core/skill-source-paths.js +82 -0
  160. package/dist/core/skill-source-paths.js.map +1 -0
  161. package/dist/core/skills-compiler.d.ts +18 -0
  162. package/dist/core/skills-compiler.d.ts.map +1 -1
  163. package/dist/core/skills-compiler.js +65 -18
  164. package/dist/core/skills-compiler.js.map +1 -1
  165. package/dist/core/skills-manifest.d.ts.map +1 -1
  166. package/dist/core/skills-manifest.js +46 -0
  167. package/dist/core/skills-manifest.js.map +1 -1
  168. package/dist/core/system-reset.d.ts +25 -0
  169. package/dist/core/system-reset.d.ts.map +1 -1
  170. package/dist/core/system-reset.js +47 -0
  171. package/dist/core/system-reset.js.map +1 -1
  172. package/dist/core/wiki/approval-queue.d.ts +31 -0
  173. package/dist/core/wiki/approval-queue.d.ts.map +1 -0
  174. package/dist/core/wiki/approval-queue.js +44 -0
  175. package/dist/core/wiki/approval-queue.js.map +1 -0
  176. package/dist/core/wiki/bridge.d.ts +74 -0
  177. package/dist/core/wiki/bridge.d.ts.map +1 -0
  178. package/dist/core/wiki/bridge.js +405 -0
  179. package/dist/core/wiki/bridge.js.map +1 -0
  180. package/dist/core/wiki/compile-lock.d.ts +42 -0
  181. package/dist/core/wiki/compile-lock.d.ts.map +1 -0
  182. package/dist/core/wiki/compile-lock.js +55 -0
  183. package/dist/core/wiki/compile-lock.js.map +1 -0
  184. package/dist/core/wiki/compile-preview.d.ts +8 -0
  185. package/dist/core/wiki/compile-preview.d.ts.map +1 -0
  186. package/dist/core/wiki/compile-preview.js +200 -0
  187. package/dist/core/wiki/compile-preview.js.map +1 -0
  188. package/dist/core/wiki/cost-estimate.d.ts +30 -0
  189. package/dist/core/wiki/cost-estimate.d.ts.map +1 -0
  190. package/dist/core/wiki/cost-estimate.js +243 -0
  191. package/dist/core/wiki/cost-estimate.js.map +1 -0
  192. package/dist/core/wiki/dispatcher.d.ts +48 -0
  193. package/dist/core/wiki/dispatcher.d.ts.map +1 -0
  194. package/dist/core/wiki/dispatcher.js +92 -0
  195. package/dist/core/wiki/dispatcher.js.map +1 -0
  196. package/dist/core/wiki/git-precompile.d.ts +86 -0
  197. package/dist/core/wiki/git-precompile.d.ts.map +1 -0
  198. package/dist/core/wiki/git-precompile.js +96 -0
  199. package/dist/core/wiki/git-precompile.js.map +1 -0
  200. package/dist/core/wiki/import-migrate.d.ts +38 -0
  201. package/dist/core/wiki/import-migrate.d.ts.map +1 -0
  202. package/dist/core/wiki/import-migrate.js +310 -0
  203. package/dist/core/wiki/import-migrate.js.map +1 -0
  204. package/dist/core/wiki/import-probe.d.ts +76 -0
  205. package/dist/core/wiki/import-probe.d.ts.map +1 -0
  206. package/dist/core/wiki/import-probe.js +245 -0
  207. package/dist/core/wiki/import-probe.js.map +1 -0
  208. package/dist/core/wiki/index-cache.d.ts +39 -0
  209. package/dist/core/wiki/index-cache.d.ts.map +1 -0
  210. package/dist/core/wiki/index-cache.js +152 -0
  211. package/dist/core/wiki/index-cache.js.map +1 -0
  212. package/dist/core/wiki/multi-url-dispatch.d.ts +52 -0
  213. package/dist/core/wiki/multi-url-dispatch.d.ts.map +1 -0
  214. package/dist/core/wiki/multi-url-dispatch.js +72 -0
  215. package/dist/core/wiki/multi-url-dispatch.js.map +1 -0
  216. package/dist/core/wiki/wiki-fts.d.ts +75 -0
  217. package/dist/core/wiki/wiki-fts.d.ts.map +1 -0
  218. package/dist/core/wiki/wiki-fts.js +265 -0
  219. package/dist/core/wiki/wiki-fts.js.map +1 -0
  220. package/dist/core/wiki/workspaces.d.ts +101 -0
  221. package/dist/core/wiki/workspaces.d.ts.map +1 -0
  222. package/dist/core/wiki/workspaces.js +352 -0
  223. package/dist/core/wiki/workspaces.js.map +1 -0
  224. package/dist/core/wiki/write-strategy.d.ts +70 -0
  225. package/dist/core/wiki/write-strategy.d.ts.map +1 -0
  226. package/dist/core/wiki/write-strategy.js +112 -0
  227. package/dist/core/wiki/write-strategy.js.map +1 -0
  228. package/dist/core/workdir.d.ts +8 -1
  229. package/dist/core/workdir.d.ts.map +1 -1
  230. package/dist/core/workdir.js +4 -1
  231. package/dist/core/workdir.js.map +1 -1
  232. package/dist/db/schema.d.ts.map +1 -1
  233. package/dist/db/schema.js +122 -0
  234. package/dist/db/schema.js.map +1 -1
  235. package/dist/db/wiki-store.d.ts +3 -0
  236. package/dist/db/wiki-store.d.ts.map +1 -0
  237. package/dist/db/wiki-store.js +7 -0
  238. package/dist/db/wiki-store.js.map +1 -0
  239. package/dist/index.js +80 -4
  240. package/dist/index.js.map +1 -1
  241. package/dist/messaging/url-extract.d.ts +8 -0
  242. package/dist/messaging/url-extract.d.ts.map +1 -0
  243. package/dist/messaging/url-extract.js +41 -0
  244. package/dist/messaging/url-extract.js.map +1 -0
  245. package/dist/observers/delegated-sync-worker.d.ts +33 -25
  246. package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
  247. package/dist/observers/delegated-sync-worker.js +38 -31
  248. package/dist/observers/delegated-sync-worker.js.map +1 -1
  249. package/dist/observers/imminent-event-scheduler.d.ts +20 -7
  250. package/dist/observers/imminent-event-scheduler.d.ts.map +1 -1
  251. package/dist/observers/imminent-event-scheduler.js +134 -29
  252. package/dist/observers/imminent-event-scheduler.js.map +1 -1
  253. package/dist/safety/always-disallowed.d.ts +65 -0
  254. package/dist/safety/always-disallowed.d.ts.map +1 -1
  255. package/dist/safety/always-disallowed.js +106 -10
  256. package/dist/safety/always-disallowed.js.map +1 -1
  257. package/dist/safety/audit.d.ts +46 -1
  258. package/dist/safety/audit.d.ts.map +1 -1
  259. package/dist/safety/audit.js +79 -16
  260. package/dist/safety/audit.js.map +1 -1
  261. package/dist/safety/risk-classifier.d.ts.map +1 -1
  262. package/dist/safety/risk-classifier.js +29 -0
  263. package/dist/safety/risk-classifier.js.map +1 -1
  264. package/dist/settings/runtime-settings.d.ts +12 -1
  265. package/dist/settings/runtime-settings.d.ts.map +1 -1
  266. package/dist/settings/runtime-settings.js +59 -1
  267. package/dist/settings/runtime-settings.js.map +1 -1
  268. package/package.json +2 -2
@@ -0,0 +1,1075 @@
1
+ import { Hono } from "hono";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { wikiBridgeProposalSchema, wikiFilePatchSchema, wikiFilePostSchema, wikiImportDecisionSchema, wikiWorkspaceCreateSchema, wikiWorkspacePatchSchema, wikiWorkspaceProbeSchema, } from "@aitne/shared";
5
+ import { readJsonBody } from "../json-body.js";
6
+ import { writeFileAtomically } from "../../core/atomic-write.js";
7
+ import { buildWikiWorkspaceStats, createExternalWikiWorkspace, ensureDefaultWikiWorkspace, listWikiWorkspaces, readWikiWorkspaceByName, validateWikiRootPath, } from "../../core/wiki/workspaces.js";
8
+ import { probeExistingWikiVault } from "../../core/wiki/import-probe.js";
9
+ import { applyImportMigration, planImportMigration, } from "../../core/wiki/import-migrate.js";
10
+ import { estimateFullCompileCost } from "../../core/wiki/cost-estimate.js";
11
+ import { buildCompilePreview } from "../../core/wiki/compile-preview.js";
12
+ import { isGitRepo, previewGitPreCompile, } from "../../core/wiki/git-precompile.js";
13
+ import { WikiWriteStrategyResolver, probeWikiWriteStrategyHealth, } from "../../core/wiki/write-strategy.js";
14
+ import { WikiIndexCache } from "../../core/wiki/index-cache.js";
15
+ import { BRIDGE_FILE_RE, processBridgeProposal } from "../../core/wiki/bridge.js";
16
+ import { deleteWikiFulltextWorkspace, reindexWikiWorkspace, searchWikiFulltext, upsertWikiFulltextRow, } from "../../core/wiki/wiki-fts.js";
17
+ import { z } from "zod";
18
+ const WIKI_BODY_MAX_BYTES = 512 * 1024;
19
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
20
+ const OUTPUT_RE = /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
21
+ const importApplyBodySchema = z.object({
22
+ // §7 — `adopt` keeps existing schema and layout verbatim (no flatten,
23
+ // no frontmatter rename — the wizard relies on `wiki-vault-rules` to
24
+ // teach the agent the existing layout). `migrate` runs the full
25
+ // flatten + rename pipeline. `split` is deferred to the multi-workspace
26
+ // phase (§P5.C) and currently aborts with `import_split_unsupported`.
27
+ // Body-less requests default to `migrate` so existing P2 callers
28
+ // (dashboard wizard pre-decision) keep their current behaviour.
29
+ decision: wikiImportDecisionSchema.optional(),
30
+ allowConflicts: z.boolean().optional(),
31
+ dateStamp: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
32
+ });
33
+ export function createWikiRoutes(deps) {
34
+ const app = new Hono();
35
+ // WIKI_BUILDER_DESIGN.md §14 Q6 — single per-route-set cache so all
36
+ // `/index` reads (and writes that invalidate it) share the same
37
+ // watcher/TTL state. Internal-mode reads bypass the cache inside
38
+ // `WikiIndexCache.get`; external-mode reads register a chokidar
39
+ // watcher on first access.
40
+ const indexCache = new WikiIndexCache();
41
+ // The strategy resolver is constructed lazily: the obsidian service is
42
+ // only present once setup is complete, and a wiki workspace might exist
43
+ // before it. The closure reads `deps.services.obsidian` per call so a
44
+ // re-init that swaps the service does not strand a stale reference.
45
+ function getStrategyResolver() {
46
+ const obs = deps.services.obsidian;
47
+ if (!obs)
48
+ return null;
49
+ return new WikiWriteStrategyResolver({ db: deps.db, obsidian: obs });
50
+ }
51
+ app.get("/wiki/workspaces", (c) => {
52
+ return c.json({
53
+ defaultWorkspace: "default",
54
+ defaultInternalRoot: join(deps.config.dataDir, "wiki"),
55
+ workspaces: listWikiWorkspaces(deps.db).map((row) => serializeWorkspace(row, deps.db)),
56
+ });
57
+ });
58
+ // P2.C / §P5.C — workspace create. Empty body still defaults to the
59
+ // internal-mode default workspace (the P1 quick path / wizard's
60
+ // first-run hop). Phase 5 lifts the "one active workspace at a time"
61
+ // ceiling: when the caller supplies an external `rootPath` AND a
62
+ // distinct `name`, we add a second active workspace alongside the
63
+ // first. Re-posting the same `name` is idempotent (re-activates an
64
+ // archived row); re-posting without a name still resolves to the
65
+ // default. Path-collision is enforced by `validateWikiRootPath` so
66
+ // two external workspaces cannot overlap on disk.
67
+ app.post("/wiki/workspaces", async (c) => {
68
+ // Empty body — quick-path: the dashboard "Enable Wiki" CTA hits this
69
+ // endpoint without payload. We pre-check whether the default already
70
+ // exists so we don't accidentally re-seed an archived row's tree.
71
+ let body = null;
72
+ try {
73
+ const text = await c.req.text();
74
+ if (text.trim().length > 0) {
75
+ body = JSON.parse(text);
76
+ }
77
+ }
78
+ catch {
79
+ return c.json({ error: "invalid_json", message: "Body is not valid JSON." }, 400);
80
+ }
81
+ if (!body) {
82
+ // No payload — re-emit the existing default (idempotent) or seed it.
83
+ const workspace = ensureDefaultWikiWorkspace(deps.db, deps.config);
84
+ const status = workspace.id > 0 && workspace.created_at !== workspace.updated_at ? 200 : 201;
85
+ return c.json({ workspace: serializeWorkspace(workspace, deps.db) }, status);
86
+ }
87
+ const parsed = wikiWorkspaceCreateSchema.safeParse(body);
88
+ if (!parsed.success) {
89
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
90
+ }
91
+ if (parsed.data.kind === "internal") {
92
+ const workspace = ensureDefaultWikiWorkspace(deps.db, deps.config);
93
+ return c.json({ workspace: serializeWorkspace(workspace, deps.db) }, 201);
94
+ }
95
+ // External — validate the root path before creating the row.
96
+ const validation = validateWikiRootPath(parsed.data.rootPath, deps.db, deps.config, { selfWorkspaceName: parsed.data.name });
97
+ if (!validation.ok) {
98
+ return c.json({
99
+ error: validation.error ?? "invalid_root_path",
100
+ message: validation.message ?? "Wiki root path failed validation.",
101
+ }, 400);
102
+ }
103
+ const workspace = createExternalWikiWorkspace(deps.db, deps.config, {
104
+ name: parsed.data.name,
105
+ rootPath: validation.resolvedPath ?? parsed.data.rootPath,
106
+ language: parsed.data.language,
107
+ });
108
+ return c.json({ workspace: serializeWorkspace(workspace, deps.db) }, 201);
109
+ });
110
+ // P2.C — pre-create probe. Returns the import-probe result without
111
+ // touching the DB so the wizard can render the Adopt / Migrate / Split
112
+ // branching and the path-collision diagnostics.
113
+ app.post("/wiki/workspaces/probe", async (c) => {
114
+ const parsedBody = await readJsonBody(c, { maxBytes: 8 * 1024 });
115
+ if (!parsedBody.ok)
116
+ return parsedBody.response;
117
+ const parsed = wikiWorkspaceProbeSchema.safeParse(parsedBody.body);
118
+ if (!parsed.success) {
119
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
120
+ }
121
+ const validation = validateWikiRootPath(parsed.data.rootPath, deps.db, deps.config);
122
+ if (!validation.ok) {
123
+ return c.json({
124
+ ok: false,
125
+ error: validation.error,
126
+ message: validation.message,
127
+ }, 400);
128
+ }
129
+ const probe = probeExistingWikiVault(validation.resolvedPath);
130
+ return c.json({ ok: true, validation, probe });
131
+ });
132
+ app.patch("/wiki/workspaces/:workspace", async (c) => {
133
+ const workspaceName = c.req.param("workspace");
134
+ const workspace = readWikiWorkspaceByName(deps.db, workspaceName);
135
+ if (!workspace) {
136
+ return c.json({ error: "not_found", message: "Wiki workspace not found" }, 404);
137
+ }
138
+ const parsedBody = await readJsonBody(c, { maxBytes: 16 * 1024 });
139
+ if (!parsedBody.ok)
140
+ return parsedBody.response;
141
+ const parsed = wikiWorkspacePatchSchema.safeParse(parsedBody.body);
142
+ if (!parsed.success) {
143
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
144
+ }
145
+ deps.db
146
+ .prepare(`UPDATE wiki_workspaces
147
+ SET language = COALESCE(?, language),
148
+ dispatch_mode = COALESCE(?, dispatch_mode),
149
+ concurrency_cap = COALESCE(?, concurrency_cap),
150
+ dm_agent_write_enabled = COALESCE(?, dm_agent_write_enabled),
151
+ bridge_enabled = COALESCE(?, bridge_enabled),
152
+ bridge_measurement_only = COALESCE(?, bridge_measurement_only),
153
+ bridge_min_confidence = COALESCE(?, bridge_min_confidence),
154
+ full_compile_approval_threshold_usd = COALESCE(?, full_compile_approval_threshold_usd),
155
+ write_strategy = COALESCE(?, write_strategy),
156
+ git_pre_compile_enabled = COALESCE(?, git_pre_compile_enabled),
157
+ active = COALESCE(?, active),
158
+ updated_at = CURRENT_TIMESTAMP
159
+ WHERE name = ?`)
160
+ .run(parsed.data.language ?? null, parsed.data.dispatchMode ?? null, parsed.data.concurrencyCap ?? null, parsed.data.dmAgentWriteEnabled === undefined
161
+ ? null
162
+ : Number(parsed.data.dmAgentWriteEnabled), parsed.data.bridgeEnabled === undefined ? null : Number(parsed.data.bridgeEnabled), parsed.data.bridgeMeasurementOnly === undefined
163
+ ? null
164
+ : Number(parsed.data.bridgeMeasurementOnly), parsed.data.bridgeMinConfidence ?? null, parsed.data.fullCompileApprovalThresholdUsd ?? null, parsed.data.writeStrategy ?? null, parsed.data.gitPreCompileEnabled === undefined
165
+ ? null
166
+ : Number(parsed.data.gitPreCompileEnabled), parsed.data.active === undefined ? null : Number(parsed.data.active), workspaceName);
167
+ const updated = readWikiWorkspaceByName(deps.db, workspaceName);
168
+ return c.json({ workspace: serializeWorkspace(updated ?? workspace, deps.db) });
169
+ });
170
+ app.post("/wiki/workspaces/:workspace/archive", (c) => {
171
+ const workspaceName = c.req.param("workspace");
172
+ const workspace = readWikiWorkspaceByName(deps.db, workspaceName);
173
+ if (!workspace) {
174
+ return c.json({ error: "not_found", message: "Wiki workspace not found" }, 404);
175
+ }
176
+ deps.db
177
+ .prepare(`UPDATE wiki_workspaces
178
+ SET active = 0, updated_at = CURRENT_TIMESTAMP
179
+ WHERE name = ?`)
180
+ .run(workspaceName);
181
+ // §P4.A — archived workspaces drop out of `/search` results because
182
+ // `resolveRequestWorkspace` rejects active=0, but the FTS rows stay
183
+ // accessible to any direct caller that knows the workspace_id. Clear
184
+ // them eagerly so a re-enable on the same id pulls fresh content from
185
+ // disk via the boot backfill or the `/reindex` endpoint.
186
+ deleteWikiFulltextWorkspace(deps.db, workspace.id);
187
+ return c.json({ ok: true });
188
+ });
189
+ app.delete("/wiki/workspaces/:workspace", (c) => {
190
+ const workspaceName = c.req.param("workspace");
191
+ const workspace = readWikiWorkspaceByName(deps.db, workspaceName);
192
+ if (!workspace) {
193
+ return c.json({ error: "not_found", message: "Wiki workspace not found" }, 404);
194
+ }
195
+ deleteWikiFulltextWorkspace(deps.db, workspace.id);
196
+ deps.db.prepare(`DELETE FROM wiki_workspaces WHERE name = ?`).run(workspaceName);
197
+ return c.json({ ok: true, rootPathPreserved: workspace.root_path });
198
+ });
199
+ // P2.E — cost estimate endpoint. Pure JS, no agent session. The dashboard
200
+ // banner and the bang-handler approval gate both read from this so the
201
+ // numbers cannot drift. P4.C — now token-level (per-file char-based
202
+ // scaling) by default; legacy flat-heuristic is reachable via
203
+ // `?strategy=flat`.
204
+ app.get("/wiki/:workspace/estimate", (c) => {
205
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
206
+ if ("response" in workspace)
207
+ return workspace.response;
208
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
209
+ if (auth)
210
+ return auth;
211
+ const strategy = (c.req.query("strategy") ?? "per-file").toLowerCase();
212
+ const estimate = strategy === "flat"
213
+ ? estimateFullCompileCost(workspace.row, { avgInputTokensPerRaw: 1500 })
214
+ : estimateFullCompileCost(workspace.row);
215
+ return c.json({ workspace: workspace.row.name, estimate });
216
+ });
217
+ // §P4.B — compile diff preview. Mirrors `!compile --preview` in HTTP form
218
+ // so the dashboard can render the touch set / cost / duration before
219
+ // the operator approves the real compile. Pure JS — no agent session.
220
+ app.get("/wiki/:workspace/compile/preview", (c) => {
221
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
222
+ if ("response" in workspace)
223
+ return workspace.response;
224
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
225
+ if (auth)
226
+ return auth;
227
+ const modeParam = c.req.query("mode") ?? "incremental";
228
+ const mode = modeParam === "full" ? "full" : "incremental";
229
+ const preview = buildCompilePreview({ workspace: workspace.row, mode });
230
+ return c.json({ workspace: workspace.row.name, preview });
231
+ });
232
+ // P2.D — existing-vault import flow. Two-step: GET /import/plan inspects
233
+ // the vault, POST /import/apply commits the migration. The plan is also
234
+ // exposed via the wizard's /probe response, but a dedicated endpoint
235
+ // matches the dashboard's "review plan → apply" UX.
236
+ app.get("/wiki/:workspace/import/plan", (c) => {
237
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
238
+ if ("response" in workspace)
239
+ return workspace.response;
240
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
241
+ if (auth)
242
+ return auth;
243
+ const probe = probeExistingWikiVault(workspace.row.root_path);
244
+ const plan = planImportMigration(workspace.row.root_path);
245
+ return c.json({ workspace: workspace.row.name, probe, plan });
246
+ });
247
+ app.post("/wiki/:workspace/import/apply", async (c) => {
248
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
249
+ if ("response" in workspace)
250
+ return workspace.response;
251
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "POST", null);
252
+ if (auth)
253
+ return auth;
254
+ const parsedBody = await readJsonBody(c, { maxBytes: 4 * 1024 });
255
+ if (!parsedBody.ok)
256
+ return parsedBody.response;
257
+ const parsed = importApplyBodySchema.safeParse(parsedBody.body ?? {});
258
+ if (!parsed.success) {
259
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
260
+ }
261
+ const decision = parsed.data.decision ?? "migrate";
262
+ if (decision === "split") {
263
+ return c.json({
264
+ error: "import_split_unsupported",
265
+ message: "Split is deferred until the multi-workspace phase. Run the import-apply against the existing workspace with `decision: \"adopt\"` or `\"migrate\"`.",
266
+ }, 501);
267
+ }
268
+ const plan = planImportMigration(workspace.row.root_path);
269
+ if (decision === "adopt") {
270
+ // Adopt = no flatten, no frontmatter rename. The agent learns the
271
+ // existing layout from `wiki-vault-rules`. We still emit a probe
272
+ // snapshot for the wizard, plus a degenerate "no-op" plan so the
273
+ // dashboard can render a consistent shape.
274
+ return c.json({
275
+ workspace: workspace.row.name,
276
+ decision,
277
+ plan: {
278
+ ...plan,
279
+ flattenMoves: [],
280
+ frontmatterMigrations: [],
281
+ },
282
+ outcome: {
283
+ backupDir: null,
284
+ filesWritten: 0,
285
+ filesMoved: 0,
286
+ },
287
+ });
288
+ }
289
+ try {
290
+ const outcome = applyImportMigration(plan, {
291
+ dateStamp: parsed.data.dateStamp,
292
+ allowConflicts: parsed.data.allowConflicts,
293
+ });
294
+ // §P4.A — the migration flattens/renames files on disk without going
295
+ // through the wiki write chokepoint, so the FTS index is stale until
296
+ // we rebuild it. The boot-time backfill only fires when the per-
297
+ // workspace row count is zero; for workspaces that already had FTS
298
+ // rows before the migration we need an explicit reindex so search
299
+ // results match the new on-disk shape immediately. Boot-time backfill
300
+ // alone would only help after a daemon restart.
301
+ const ftsOutcome = reindexWikiWorkspace(deps.db, workspace.row);
302
+ return c.json({
303
+ workspace: workspace.row.name,
304
+ decision,
305
+ plan,
306
+ outcome,
307
+ ftsReindex: ftsOutcome,
308
+ });
309
+ }
310
+ catch (err) {
311
+ const code = err.code;
312
+ if (code === "EWIKI_IMPORT_CONFLICT") {
313
+ return c.json({
314
+ error: "import_conflict",
315
+ message: err instanceof Error ? err.message : "Conflict",
316
+ conflicts: plan.conflicts,
317
+ }, 409);
318
+ }
319
+ throw err;
320
+ }
321
+ });
322
+ // P2.E — pre-compile git status surface. Lets the dashboard render the
323
+ // commit/stash hint before the operator runs `!compile full`. This is a
324
+ // strict GET: `previewGitPreCompile` reads `git status` only, never
325
+ // runs `add`/`commit`, so dashboard polling cannot create empty commits.
326
+ app.get("/wiki/:workspace/git/status", async (c) => {
327
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
328
+ if ("response" in workspace)
329
+ return workspace.response;
330
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
331
+ if (auth)
332
+ return auth;
333
+ const isRepo = isGitRepo(workspace.row.root_path);
334
+ const preview = await previewGitPreCompile(workspace.row);
335
+ return c.json({
336
+ workspace: workspace.row.name,
337
+ kind: workspace.row.kind,
338
+ isGitRepo: isRepo,
339
+ gitPreCompileEnabled: workspace.row.git_pre_compile_enabled === 1,
340
+ preview,
341
+ });
342
+ });
343
+ // P2.B — health probe for the resolved write strategy. Surfaced under
344
+ // /api/health.wiki via `/health` aggregation when it lands; for now it
345
+ // is also reachable directly for the dashboard's strategy badge.
346
+ app.get("/wiki/:workspace/health", async (c) => {
347
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
348
+ if ("response" in workspace)
349
+ return workspace.response;
350
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
351
+ if (auth)
352
+ return auth;
353
+ const obs = deps.services.obsidian;
354
+ if (workspace.row.kind === "external" && !obs) {
355
+ return c.json({
356
+ workspace: workspace.row.name,
357
+ kind: workspace.row.kind,
358
+ strategy: workspace.row.write_strategy,
359
+ cliAvailable: false,
360
+ notes: "Obsidian service not configured; CLI fallback unavailable.",
361
+ });
362
+ }
363
+ if (!obs) {
364
+ // Internal workspace, no obsidian service — still safe: no fallback needed.
365
+ return c.json({
366
+ workspace: workspace.row.name,
367
+ kind: workspace.row.kind,
368
+ strategy: "fs",
369
+ cliAvailable: null,
370
+ });
371
+ }
372
+ const health = await probeWikiWriteStrategyHealth(workspace.row, obs);
373
+ return c.json(health);
374
+ });
375
+ app.get("/wiki/:workspace/search", (c) => {
376
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
377
+ if ("response" in workspace)
378
+ return workspace.response;
379
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
380
+ if (auth)
381
+ return auth;
382
+ const q = c.req.query("q")?.trim() ?? "";
383
+ const limit = Math.max(1, Math.min(50, Number(c.req.query("limit") ?? 20)));
384
+ // §P4.A — `kind` selects the backend. `fts` (default) queries the
385
+ // SQLite FTS5 virtual table maintained by wiki-fts.ts. `grep` is the
386
+ // legacy substring fallback retained for callers that want a literal
387
+ // case-insensitive match or when FTS5 returns zero hits for terms
388
+ // the tokenizer split (e.g. trailing/embedded punctuation).
389
+ const requestedKind = (c.req.query("kind") ?? "fts").toLowerCase();
390
+ const kind = requestedKind === "grep" ? "grep" : "fts";
391
+ const layerParam = c.req.query("layer");
392
+ const layer = isWikiFtsLayer(layerParam) ? layerParam : undefined;
393
+ if (kind === "fts") {
394
+ const results = searchWikiFulltext(deps.db, workspace.row.id, q, { layer, limit });
395
+ // Empty-query is a valid "list-everything" UX in the previous
396
+ // implementation but FTS5 rejects empty MATCH. Fall back to grep
397
+ // for that single case so the route remains backward-compatible
398
+ // with callers that issue `/search?q=` to enumerate the vault.
399
+ if (results.length === 0 && q.length === 0) {
400
+ return c.json({
401
+ workspace: workspace.row.name,
402
+ kind: "grep",
403
+ results: searchWikiFiles(workspace.row.root_path, q.toLowerCase(), limit),
404
+ });
405
+ }
406
+ return c.json({ workspace: workspace.row.name, kind: "fts", results });
407
+ }
408
+ return c.json({
409
+ workspace: workspace.row.name,
410
+ kind: "grep",
411
+ results: searchWikiFiles(workspace.row.root_path, q.toLowerCase(), limit),
412
+ });
413
+ });
414
+ // §P4.A — operator escape hatch. Walks the workspace tree and rebuilds
415
+ // the FTS5 index from disk. Used when the index drifts (e.g. external
416
+ // vault edited outside the daemon, or after the schema is rebuilt by
417
+ // `aitne reinstall`). Requires a wiki-tier process key so it cannot be
418
+ // triggered from a DM session.
419
+ app.post("/wiki/:workspace/reindex", (c) => {
420
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
421
+ if ("response" in workspace)
422
+ return workspace.response;
423
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "POST", null);
424
+ if (auth)
425
+ return auth;
426
+ const outcome = reindexWikiWorkspace(deps.db, workspace.row);
427
+ return c.json({ workspace: workspace.row.name, ...outcome });
428
+ });
429
+ app.get("/wiki/:workspace/index", (c) => {
430
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
431
+ if ("response" in workspace)
432
+ return workspace.response;
433
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
434
+ if (auth)
435
+ return auth;
436
+ // WIKI_BUILDER_DESIGN.md §8 — `/index` is the `_index.md` catalog
437
+ // (not a generic file listing). The file tree is still useful for the
438
+ // DM-agent's `Bash(curl)` workflow (§9.4), so we keep it alongside
439
+ // the cached catalog. External-mode reads go through `WikiIndexCache`
440
+ // (§14 Q6); internal mode short-circuits to disk inside `get()`.
441
+ const snapshot = indexCache.get(workspace.row);
442
+ return c.json({
443
+ workspace: workspace.row.name,
444
+ rootPath: workspace.row.root_path,
445
+ indexFile: snapshot,
446
+ files: listWikiIndex(workspace.row.root_path),
447
+ });
448
+ });
449
+ // WIKI_BUILDER_DESIGN.md §P5.A / §P5.B — bridge proposal endpoint.
450
+ //
451
+ // The DM agent's `wiki-bridge` skill (and any future in-process
452
+ // caller) POSTs a proposal here. The route enforces the two-key
453
+ // safety (`bridge_enabled` AND `dm_agent_write_enabled`) for DM-tier
454
+ // callers, defers the trigger-confidence-dedup-loopguard cascade to
455
+ // the pure `processBridgeProposal` helper, and surfaces the outcome
456
+ // back to the caller for reply phrasing.
457
+ //
458
+ // Auth shape:
459
+ // - DM-tier process keys are accepted (the agent is the proposer);
460
+ // they must come paired with both workspace toggles on.
461
+ // - Wiki-tier process keys are accepted unconditionally — internal
462
+ // callers (a hypothetical `wiki.bridge_propose` background task)
463
+ // do not need owner consent because the dispatcher already gates
464
+ // them on Approve tier.
465
+ app.post("/wiki/:workspace/bridge", async (c) => {
466
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
467
+ if ("response" in workspace)
468
+ return workspace.response;
469
+ const processKey = c.req.header("x-process-key");
470
+ if (!processKey) {
471
+ return c.json({ error: "forbidden", code: "missing_process_key", message: "x-process-key is required" }, 403);
472
+ }
473
+ const isDmTier = isDmReadProcess(processKey);
474
+ const isWikiTier = processKey.startsWith("wiki.");
475
+ if (!isDmTier && !isWikiTier) {
476
+ return c.json({ error: "forbidden", code: "bridge_write_denied" }, 403);
477
+ }
478
+ if (isDmTier) {
479
+ if (workspace.row.dm_agent_write_enabled !== 1) {
480
+ return c.json({
481
+ error: "forbidden",
482
+ code: "dm_write_disabled",
483
+ message: "Enable `Allow DM agent bridge writes` in /settings/wiki.",
484
+ }, 403);
485
+ }
486
+ if (workspace.row.bridge_enabled !== 1) {
487
+ return c.json({
488
+ error: "forbidden",
489
+ code: "bridge_feature_disabled",
490
+ message: "Enable the Bridge feature in /settings/wiki.",
491
+ }, 403);
492
+ }
493
+ }
494
+ const parsedBody = await readJsonBody(c, { maxBytes: 32 * 1024 });
495
+ if (!parsedBody.ok)
496
+ return parsedBody.response;
497
+ const parsed = wikiBridgeProposalSchema.safeParse(parsedBody.body);
498
+ if (!parsed.success) {
499
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
500
+ }
501
+ // Inject a strategy-aware writer so bridge files on external-mode
502
+ // vaults (iCloud-sandboxed) fall back to the Obsidian CLI rather
503
+ // than EPERM-ing out of the raw write path. Internal workspaces
504
+ // still take the local-fs path inside the resolver. When the
505
+ // obsidian service is unavailable we omit the override entirely
506
+ // and the processor falls back to `writeFileAtomically` (correct
507
+ // for internal mode; for external mode this matches the
508
+ // pre-fix behaviour and lets the EPERM surface to the caller).
509
+ const bridgeStrategyResolver = workspace.row.kind === "external" ? getStrategyResolver() : null;
510
+ const writeFile = bridgeStrategyResolver
511
+ ? async (relPath, content) => {
512
+ await bridgeStrategyResolver.writeFile({
513
+ workspace: workspace.row,
514
+ relPath,
515
+ content,
516
+ });
517
+ }
518
+ : undefined;
519
+ const result = await processBridgeProposal({
520
+ db: deps.db,
521
+ workspace: workspace.row,
522
+ proposal: parsed.data,
523
+ actor: isWikiTier ? "wiki-agent" : "dm-agent",
524
+ ...(writeFile ? { writeFile } : {}),
525
+ });
526
+ const status = result.outcome === "written" ? 201 : 200;
527
+ return c.json({ result }, status);
528
+ });
529
+ // GET /wiki/:ws/bridge — list recent bridge audit rows for the
530
+ // dashboard's "observation log" view. `since` (ISO) windows results;
531
+ // `limit` clamps to 200. Wiki-tier and DM-tier callers can read.
532
+ app.get("/wiki/:workspace/bridge", (c) => {
533
+ const workspace = resolveRequestWorkspace(deps, c.req.param("workspace"));
534
+ if ("response" in workspace)
535
+ return workspace.response;
536
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", null);
537
+ if (auth)
538
+ return auth;
539
+ const since = c.req.query("since");
540
+ const limit = Math.max(1, Math.min(200, Number(c.req.query("limit") ?? 50)));
541
+ const rows = deps.db
542
+ .prepare(`SELECT id, action_type, result, detail, started_at
543
+ FROM agent_actions
544
+ WHERE source_kind = 'wiki' AND source_ref = ?
545
+ AND (action_type = 'wiki.bridge'
546
+ OR action_type = 'wiki.bridge.candidate'
547
+ OR action_type = 'wiki.bridge.dedup'
548
+ OR action_type = 'wiki.bridge.skip')
549
+ AND (? IS NULL OR started_at >= ?)
550
+ ORDER BY started_at DESC LIMIT ?`)
551
+ .all(workspace.row.name, since ?? null, since ?? null, limit);
552
+ const entries = rows.map((row) => {
553
+ let detail = {};
554
+ try {
555
+ detail = JSON.parse(row.detail);
556
+ }
557
+ catch {
558
+ detail = {};
559
+ }
560
+ return {
561
+ id: row.id,
562
+ actionType: row.action_type,
563
+ result: row.result,
564
+ detectedAt: row.started_at,
565
+ outcome: detail.outcome ?? null,
566
+ trigger: detail.trigger ?? null,
567
+ confidence: detail.confidence ?? null,
568
+ contentHash: detail.content_hash ?? null,
569
+ targets: Array.isArray(detail.targets) ? detail.targets : [],
570
+ existingPath: detail.existing_path ?? null,
571
+ sourceKindOf: detail.source_kind_of ?? null,
572
+ sourceRefOf: detail.source_ref_of ?? null,
573
+ };
574
+ });
575
+ return c.json({ workspace: workspace.row.name, entries });
576
+ });
577
+ // Hono 4.x does not surface bare `*` captures via `c.req.param("*")` (it
578
+ // always returns `undefined`), so the wildcard is declared as a named
579
+ // parameter with a regex constraint instead. `:path{.+}` matches a single
580
+ // segment OR a `/`-joined chain, and `c.req.param("path")` is already
581
+ // percent-decoded by Hono — no manual `decodeURIComponent` needed.
582
+ app.get("/wiki/:workspace/files/:path{.+}", (c) => {
583
+ const workspaceName = c.req.param("workspace");
584
+ const workspace = resolveRequestWorkspace(deps, workspaceName);
585
+ if ("response" in workspace)
586
+ return workspace.response;
587
+ const resolved = resolveWikiFileTarget(workspace.row, c.req.param("path"));
588
+ if ("response" in resolved)
589
+ return resolved.response;
590
+ const auth = authorizeWikiRequest(workspace.row, c.req.header("x-process-key"), "GET", resolved.classified);
591
+ if (auth)
592
+ return auth;
593
+ if (!existsSync(resolved.fullPath)) {
594
+ return c.json({ error: "not_found", path: resolved.classified.relPath }, 404);
595
+ }
596
+ const stat = statSync(resolved.fullPath);
597
+ if (!stat.isFile()) {
598
+ return c.json({ error: "not_file", path: resolved.classified.relPath }, 400);
599
+ }
600
+ return c.json({
601
+ path: resolved.classified.relPath,
602
+ content: readFileSync(resolved.fullPath, "utf-8"),
603
+ mtime: stat.mtime.toISOString(),
604
+ sizeBytes: stat.size,
605
+ });
606
+ });
607
+ app.post("/wiki/:workspace/files/:path{.+}", async (c) => {
608
+ const workspaceName = c.req.param("workspace");
609
+ const workspace = resolveRequestWorkspace(deps, workspaceName);
610
+ if ("response" in workspace)
611
+ return workspace.response;
612
+ const resolved = resolveWikiFileTarget(workspace.row, c.req.param("path"));
613
+ if ("response" in resolved)
614
+ return resolved.response;
615
+ const processKey = c.req.header("x-process-key");
616
+ const auth = authorizeWikiRequest(workspace.row, processKey, "POST", resolved.classified);
617
+ if (auth)
618
+ return auth;
619
+ const parsedBody = await readJsonBody(c, { maxBytes: WIKI_BODY_MAX_BYTES });
620
+ if (!parsedBody.ok)
621
+ return parsedBody.response;
622
+ const parsed = wikiFilePostSchema.safeParse(parsedBody.body);
623
+ if (!parsed.success) {
624
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
625
+ }
626
+ if (resolved.classified.layer === "raw" && existsSync(resolved.fullPath)) {
627
+ return c.json({ error: "append_only", message: "10_raw files are create-only" }, 409);
628
+ }
629
+ if (resolved.classified.layer === "log" && existsSync(resolved.fullPath)) {
630
+ return c.json({ error: "append_only", message: "Use PATCH to append log.md" }, 409);
631
+ }
632
+ snapshotIfNeeded(workspace.row, resolved.classified.relPath, resolved.fullPath);
633
+ const normalizedPostBody = ensureTrailingNewline(parsed.data.content);
634
+ await writeWikiFile(workspace.row, resolved.classified.relPath, normalizedPostBody);
635
+ syncWikiFts(deps.db, workspace.row.id, resolved.classified, normalizedPostBody);
636
+ recordWikiWrite(deps.db, workspace.row, processKey ?? "unknown", "post", resolved.classified.relPath, Buffer.byteLength(normalizedPostBody, "utf8"));
637
+ updateWikiProcessTimestamp(deps.db, workspace.row.name, processKey);
638
+ await appendWikiLog(workspace.row, processKey ?? "unknown", "post", resolved.classified.relPath);
639
+ return c.json({ ok: true, path: resolved.classified.relPath });
640
+ });
641
+ app.patch("/wiki/:workspace/files/:path{.+}", async (c) => {
642
+ const workspaceName = c.req.param("workspace");
643
+ const workspace = resolveRequestWorkspace(deps, workspaceName);
644
+ if ("response" in workspace)
645
+ return workspace.response;
646
+ const resolved = resolveWikiFileTarget(workspace.row, c.req.param("path"));
647
+ if ("response" in resolved)
648
+ return resolved.response;
649
+ const processKey = c.req.header("x-process-key");
650
+ const auth = authorizeWikiRequest(workspace.row, processKey, "PATCH", resolved.classified);
651
+ if (auth)
652
+ return auth;
653
+ if (resolved.classified.layer === "raw") {
654
+ return c.json({ error: "append_only", message: "10_raw files cannot be patched" }, 409);
655
+ }
656
+ const parsedBody = await readJsonBody(c, { maxBytes: WIKI_BODY_MAX_BYTES });
657
+ if (!parsedBody.ok)
658
+ return parsedBody.response;
659
+ const parsed = wikiFilePatchSchema.safeParse(parsedBody.body);
660
+ if (!parsed.success) {
661
+ return c.json({ error: "invalid_body", issues: parsed.error.issues }, 400);
662
+ }
663
+ const previous = existsSync(resolved.fullPath)
664
+ ? readFileSync(resolved.fullPath, "utf-8")
665
+ : "";
666
+ snapshotIfNeeded(workspace.row, resolved.classified.relPath, resolved.fullPath);
667
+ const content = parsed.data.mode === "prepend"
668
+ ? `${ensureTrailingNewline(parsed.data.content)}${previous}`
669
+ : `${previous}${ensureLeadingNewline(parsed.data.content)}`;
670
+ await writeWikiFile(workspace.row, resolved.classified.relPath, content);
671
+ syncWikiFts(deps.db, workspace.row.id, resolved.classified, content);
672
+ recordWikiWrite(deps.db, workspace.row, processKey ?? "unknown", "patch", resolved.classified.relPath, Buffer.byteLength(content, "utf8"));
673
+ updateWikiProcessTimestamp(deps.db, workspace.row.name, processKey);
674
+ if (resolved.classified.layer !== "log") {
675
+ await appendWikiLog(workspace.row, processKey ?? "unknown", "patch", resolved.classified.relPath);
676
+ }
677
+ return c.json({ ok: true, path: resolved.classified.relPath });
678
+ });
679
+ return app;
680
+ async function writeWikiFile(workspace, relPath, content) {
681
+ // External workspaces try fs first then fall back to the Obsidian CLI
682
+ // when the OS rejects the write — typical for iCloud-sandboxed vaults.
683
+ // Internal workspaces always take the local-fs path. The resolver
684
+ // persists the resolved strategy back into the row so the probe
685
+ // is amortised across daemon restarts.
686
+ try {
687
+ if (workspace.kind === "external") {
688
+ const resolver = getStrategyResolver();
689
+ if (resolver) {
690
+ await resolver.writeFile({ workspace, relPath, content });
691
+ return;
692
+ }
693
+ }
694
+ writeFileAtomically(resolve(workspace.root_path, relPath), content);
695
+ }
696
+ finally {
697
+ // WIKI_BUILDER_DESIGN.md §14 Q6 — invalidate the cached catalog when
698
+ // `_index.md` is rewritten. We do this in `finally` so a failed
699
+ // partial write still invalidates rather than serving stale.
700
+ if (relPath === "20_wiki/_index.md") {
701
+ indexCache.invalidate(workspace.id);
702
+ }
703
+ }
704
+ }
705
+ async function appendWikiLog(workspace, processKey, operation, relPath) {
706
+ const logRelPath = "log.md";
707
+ const fullPath = resolve(workspace.root_path, logRelPath);
708
+ const previous = existsSync(fullPath) ? readFileSync(fullPath, "utf-8") : "# Wiki Log\n\n";
709
+ const line = `- ${new Date().toISOString()} ${processKey} ${operation} ${relPath}\n`;
710
+ const next = `${previous}${previous.endsWith("\n") ? "" : "\n"}${line}`;
711
+ await writeWikiFile(workspace, logRelPath, next);
712
+ }
713
+ }
714
+ function resolveRequestWorkspace(deps, name) {
715
+ const row = readWikiWorkspaceByName(deps.db, name);
716
+ // WIKI_BUILDER_DESIGN.md §0 / §8 — opt-in invariant. A missing row OR an
717
+ // archived row (active=0) means the wiki is disabled for callers. Return
718
+ // the design-prescribed `wiki_not_enabled` shape so the dashboard / agent
719
+ // sessions can react with the enable hint instead of treating it as a
720
+ // generic 404.
721
+ if (!row || row.active !== 1) {
722
+ return {
723
+ response: new Response(JSON.stringify({
724
+ error: "wiki_not_enabled",
725
+ hint: "Open /settings/wiki to enable",
726
+ }), { status: 404, headers: { "content-type": "application/json" } }),
727
+ };
728
+ }
729
+ return { row };
730
+ }
731
+ function resolveWikiFileTarget(workspace, rawPath) {
732
+ // `c.req.param("path")` is already percent-decoded by Hono; the route
733
+ // regex (`:path{.+}`) also guarantees a non-empty match before this
734
+ // function runs. The `?? ""` is purely defensive against a future
735
+ // refactor that bypasses the route param.
736
+ const relPath = normalizeRelativeWikiPath(rawPath ?? "");
737
+ if (!relPath) {
738
+ return jsonResponse({ error: "invalid_path", message: "Invalid wiki path" }, 400);
739
+ }
740
+ const classified = classifyWikiPath(relPath);
741
+ if (!classified) {
742
+ return jsonResponse({ error: "invalid_layer", message: "Path is outside the wiki layer contract" }, 400);
743
+ }
744
+ const fullPath = resolve(workspace.root_path, classified.relPath);
745
+ const rel = relative(workspace.root_path, fullPath);
746
+ if (rel.startsWith("..") || isAbsolute(rel)) {
747
+ return jsonResponse({ error: "invalid_path", message: "Path escapes workspace root" }, 400);
748
+ }
749
+ return { classified, fullPath };
750
+ }
751
+ function normalizeRelativeWikiPath(input) {
752
+ if (!input || input.includes("\\") || input.includes("\0"))
753
+ return null;
754
+ if (isAbsolute(input))
755
+ return null;
756
+ const parts = input.split("/").filter(Boolean);
757
+ if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
758
+ return null;
759
+ }
760
+ return parts.join("/");
761
+ }
762
+ function classifyWikiPath(relPath) {
763
+ if (relPath === "log.md")
764
+ return { layer: "log", relPath };
765
+ const [root, ...rest] = relPath.split("/");
766
+ const leaf = rest.at(-1) ?? "";
767
+ const stem = leaf.endsWith(".md") ? leaf.slice(0, -3) : leaf;
768
+ switch (root) {
769
+ case "00_inbox":
770
+ return rest.length > 0 ? { layer: "inbox", relPath } : null;
771
+ case "10_raw":
772
+ if (rest.length === 1 && leaf.endsWith(".md") && SLUG_RE.test(stem)) {
773
+ return { layer: "raw", relPath };
774
+ }
775
+ if (rest.length === 3 && rest[0] === "images" && SLUG_RE.test(rest[1])) {
776
+ return { layer: "raw", relPath };
777
+ }
778
+ return null;
779
+ case "20_wiki":
780
+ if (rest.length !== 1 || !leaf.endsWith(".md"))
781
+ return null;
782
+ if (stem === "_index" || SLUG_RE.test(stem))
783
+ return { layer: "wiki", relPath };
784
+ return null;
785
+ case "30_outputs":
786
+ if (rest.length === 1 && leaf.endsWith(".md") && OUTPUT_RE.test(stem)) {
787
+ return { layer: "output", relPath };
788
+ }
789
+ return null;
790
+ case "90_meta":
791
+ if (relPath === "90_meta/taxonomy.md")
792
+ return { layer: "meta", relPath };
793
+ if (rest.length === 2 &&
794
+ (rest[0] === "schemas" || rest[0] === "health") &&
795
+ leaf.endsWith(".md") &&
796
+ SLUG_RE.test(stem)) {
797
+ return { layer: "meta", relPath };
798
+ }
799
+ return null;
800
+ default:
801
+ return null;
802
+ }
803
+ }
804
+ function authorizeWikiRequest(workspace, processKey, method, target) {
805
+ if (!processKey) {
806
+ return rawJson({ error: "forbidden", code: "missing_process_key", message: "x-process-key is required" }, 403);
807
+ }
808
+ if (method === "GET") {
809
+ if (processKey.startsWith("wiki.") || isDmReadProcess(processKey))
810
+ return null;
811
+ return rawJson({ error: "forbidden", code: "read_denied" }, 403);
812
+ }
813
+ if (!target) {
814
+ // Non-file POST routes (import/apply) still require a wiki-tier
815
+ // process key; the matrix's "writes" semantics extend to them.
816
+ return processKey.startsWith("wiki.")
817
+ ? null
818
+ : rawJson({ error: "forbidden", code: "write_target_required" }, 403);
819
+ }
820
+ if (target.layer === "inbox") {
821
+ return rawJson({ error: "forbidden", code: "human_only_layer" }, 403);
822
+ }
823
+ if (target.layer === "log") {
824
+ return processKey.startsWith("wiki.") ? null : rawJson({ error: "forbidden", code: "log_write_denied" }, 403);
825
+ }
826
+ if (target.layer === "raw") {
827
+ if (processKey === "wiki.ingest_url")
828
+ return null;
829
+ // WIKI_BUILDER_DESIGN.md §P5.B — two-key safety for DM-agent
830
+ // bridge writes. BOTH `dm_agent_write_enabled` AND `bridge_enabled`
831
+ // must be on. The `bridge_enabled` gate is the feature switch;
832
+ // `dm_agent_write_enabled` is the per-workspace "I've reviewed the
833
+ // bridge surface" consent. Either toggle off → 403.
834
+ if (workspace.dm_agent_write_enabled === 1 &&
835
+ workspace.bridge_enabled === 1 &&
836
+ isDmReadProcess(processKey) &&
837
+ BRIDGE_FILE_RE.test(target.relPath)) {
838
+ return null;
839
+ }
840
+ return rawJson({ error: "forbidden", code: "raw_write_denied" }, 403);
841
+ }
842
+ if (target.layer === "wiki") {
843
+ return processKey === "wiki.compile"
844
+ ? null
845
+ : rawJson({ error: "forbidden", code: "wiki_write_denied" }, 403);
846
+ }
847
+ if (target.layer === "output") {
848
+ // WIKI_BUILDER_DESIGN.md Phase 3 — `wiki.trace` and `wiki.connect`
849
+ // both write `30_outputs/<YYYY-MM-DD>-<kind>-<slug>.md`. The
850
+ // path-level OUTPUT_RE already enforces the date-prefix shape; this
851
+ // layer auth just widens write eligibility from `wiki.ask` to the
852
+ // P3 triad's two output-writers.
853
+ if (processKey === "wiki.ask" || processKey === "wiki.trace" || processKey === "wiki.connect") {
854
+ return null;
855
+ }
856
+ return rawJson({ error: "forbidden", code: "output_write_denied" }, 403);
857
+ }
858
+ if (target.layer === "meta") {
859
+ // `wiki.compile` is the original meta writer (taxonomy + schemas).
860
+ // Phase 3's `wiki.lint` writes `90_meta/health/<date>.md` and may
861
+ // PATCH `90_meta/taxonomy.md` to append its `# Candidates` section
862
+ // — same layer permission, narrower in spirit (lint never rewrites
863
+ // schemas). Both keys land here.
864
+ if (processKey === "wiki.compile" || processKey === "wiki.lint")
865
+ return null;
866
+ return rawJson({ error: "forbidden", code: "meta_write_denied" }, 403);
867
+ }
868
+ return rawJson({ error: "forbidden", code: "write_denied" }, 403);
869
+ }
870
+ function isDmReadProcess(processKey) {
871
+ return ["message.dm", "message.mention", "dashboard.chat"].includes(processKey);
872
+ }
873
+ function snapshotIfNeeded(workspace, relPath, fullPath) {
874
+ // WIKI_BUILDER_DESIGN.md §14 Q3 — external-mode workspaces are excluded
875
+ // from `md_file_snapshots`. The user's git / cloud sync is the recovery
876
+ // surface; the daemon does not duplicate it.
877
+ if (workspace.kind !== "internal" || !existsSync(fullPath))
878
+ return;
879
+ const stat = statSync(fullPath);
880
+ if (!stat.isFile())
881
+ return;
882
+ const snapshotPath = join(workspace.root_path, ".snapshots", `${Date.now()}`, relPath);
883
+ mkdirSync(dirname(snapshotPath), { recursive: true });
884
+ writeFileAtomically(snapshotPath, readFileSync(fullPath, "utf-8"));
885
+ }
886
+ function recordWikiWrite(db, workspace, processKey, operation, relPath, bytesWritten) {
887
+ // WIKI_BUILDER_DESIGN.md §11.1 — `action_type = 'wiki.<command>'` so the
888
+ // existing `idx_agent_actions_source` lookup pivots on the same process
889
+ // key the dispatcher already records for the agent session. A generic
890
+ // `wiki.file_write` row would force every dashboard timeline query to
891
+ // re-derive the originating command from `detail` JSON.
892
+ const actionType = processKey.startsWith("wiki.") ? processKey : "wiki.file_write";
893
+ db.prepare(`INSERT INTO agent_actions
894
+ (event_id, action_type, trigger, result, detail, started_at, completed_at, source_kind, source_ref)
895
+ VALUES (?, ?, 'autonomous', 'success', json(?), datetime('now'), datetime('now'), 'wiki', ?)`).run(`${processKey}:${workspace.name}:${relPath}`, actionType, JSON.stringify({
896
+ processKey,
897
+ operation,
898
+ workspace: workspace.name,
899
+ workspace_id: workspace.id,
900
+ targets: [relPath],
901
+ bytes_written: bytesWritten,
902
+ }), workspace.name);
903
+ }
904
+ function updateWikiProcessTimestamp(db, workspaceName, processKey) {
905
+ if (processKey === "wiki.ingest_url") {
906
+ db.prepare(`UPDATE wiki_workspaces SET last_ingest_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE name = ?`).run(workspaceName);
907
+ }
908
+ else if (processKey === "wiki.compile") {
909
+ db.prepare(`UPDATE wiki_workspaces SET last_compile_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE name = ?`).run(workspaceName);
910
+ }
911
+ }
912
+ function serializeWorkspace(row, db) {
913
+ return {
914
+ id: row.id,
915
+ name: row.name,
916
+ kind: row.kind,
917
+ rootPath: row.root_path,
918
+ language: row.language,
919
+ dispatchMode: row.dispatch_mode,
920
+ concurrencyCap: row.concurrency_cap,
921
+ dmAgentWriteEnabled: row.dm_agent_write_enabled === 1,
922
+ bridgeEnabled: row.bridge_enabled === 1,
923
+ // WIKI_BUILDER_DESIGN.md §P5.A / §P5.B — surface the measurement
924
+ // gate and confidence threshold so the dashboard can render the
925
+ // "Bridge — observation mode" badge during the 2-week measurement
926
+ // window. The toggle stays hidden when `bridge_enabled = 0`.
927
+ bridgeMeasurementOnly: row.bridge_measurement_only === 1,
928
+ bridgeMinConfidence: row.bridge_min_confidence,
929
+ fullCompileApprovalThresholdUsd: row.full_compile_approval_threshold_usd,
930
+ writeStrategy: row.write_strategy,
931
+ gitPreCompileEnabled: row.git_pre_compile_enabled === 1,
932
+ isGitRepo: row.kind === "external" ? isGitRepo(row.root_path) : undefined,
933
+ schemaVersion: row.schema_version,
934
+ active: row.active === 1,
935
+ lastIngestAt: row.last_ingest_at,
936
+ lastCompileAt: row.last_compile_at,
937
+ stats: buildWikiWorkspaceStats(row),
938
+ bridgeStats: readBridgeStats(db, row.id),
939
+ recentCosts: readRecentWikiCosts(db),
940
+ };
941
+ }
942
+ function readBridgeStats(db, workspaceId) {
943
+ const row = db
944
+ .prepare(`SELECT
945
+ SUM(CASE WHEN accepted = 1 AND bridge_path IS NOT NULL THEN 1 ELSE 0 END) AS written,
946
+ SUM(CASE WHEN accepted = 0 THEN 1 ELSE 0 END) AS candidates,
947
+ COUNT(*) AS total,
948
+ MAX(detected_at) AS last_at
949
+ FROM wiki_bridge_dedup WHERE workspace_id = ?`)
950
+ .get(workspaceId);
951
+ const written = row?.written ?? 0;
952
+ const candidates = row?.candidates ?? 0;
953
+ const total = row?.total ?? 0;
954
+ return {
955
+ candidates,
956
+ written,
957
+ deduplicated: Math.max(0, total - written - candidates),
958
+ lastDetectedAt: row?.last_at ?? null,
959
+ };
960
+ }
961
+ function readRecentWikiCosts(db) {
962
+ return db
963
+ .prepare(`SELECT action_type AS processKey,
964
+ COUNT(*) AS count,
965
+ COALESCE(SUM(cost_usd), 0) AS totalCostUsd,
966
+ AVG(cost_usd) AS avgCostUsd,
967
+ MAX(cost_usd) AS lastCostUsd
968
+ FROM agent_actions
969
+ WHERE started_at >= datetime('now', '-7 days')
970
+ AND (source_kind = 'wiki' OR action_type LIKE 'wiki.%')
971
+ GROUP BY action_type
972
+ ORDER BY action_type`)
973
+ .all();
974
+ }
975
+ function searchWikiFiles(rootPath, query, limit) {
976
+ const files = listWikiIndex(rootPath);
977
+ const results = [];
978
+ for (const file of files) {
979
+ if (!file.path.endsWith(".md"))
980
+ continue;
981
+ const full = join(rootPath, file.path);
982
+ const content = readFileSync(full, "utf-8");
983
+ const lower = content.toLowerCase();
984
+ const idx = query ? lower.indexOf(query) : 0;
985
+ if (idx < 0)
986
+ continue;
987
+ results.push({
988
+ path: file.path,
989
+ title: firstHeading(content) ?? file.path,
990
+ snippet: content.slice(Math.max(0, idx - 80), idx + 180),
991
+ mtime: file.mtime,
992
+ });
993
+ if (results.length >= limit)
994
+ break;
995
+ }
996
+ return results;
997
+ }
998
+ function listWikiIndex(rootPath) {
999
+ const out = [];
1000
+ for (const rel of walkFiles(rootPath)) {
1001
+ if (rel.startsWith(".snapshots/"))
1002
+ continue;
1003
+ const full = join(rootPath, rel);
1004
+ const stat = statSync(full);
1005
+ out.push({ path: rel, sizeBytes: stat.size, mtime: stat.mtime.toISOString() });
1006
+ }
1007
+ return out.sort((a, b) => a.path.localeCompare(b.path));
1008
+ }
1009
+ function walkFiles(rootPath, relDir = "") {
1010
+ const dir = join(rootPath, relDir);
1011
+ if (!existsSync(dir))
1012
+ return [];
1013
+ const out = [];
1014
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1015
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
1016
+ if (entry.isDirectory()) {
1017
+ out.push(...walkFiles(rootPath, rel));
1018
+ }
1019
+ else if (entry.isFile()) {
1020
+ out.push(rel);
1021
+ }
1022
+ }
1023
+ return out;
1024
+ }
1025
+ function firstHeading(content) {
1026
+ return content
1027
+ .split("\n")
1028
+ .find((line) => line.startsWith("# "))
1029
+ ?.slice(2)
1030
+ .trim() ?? null;
1031
+ }
1032
+ function ensureTrailingNewline(content) {
1033
+ return content.endsWith("\n") ? content : `${content}\n`;
1034
+ }
1035
+ function ensureLeadingNewline(content) {
1036
+ const trailing = ensureTrailingNewline(content);
1037
+ return trailing.startsWith("\n") ? trailing : `\n${trailing}`;
1038
+ }
1039
+ function jsonResponse(body, status) {
1040
+ return { response: rawJson(body, status) };
1041
+ }
1042
+ function rawJson(body, status) {
1043
+ return new Response(JSON.stringify(body), {
1044
+ status,
1045
+ headers: { "content-type": "application/json" },
1046
+ });
1047
+ }
1048
+ // WIKI_BUILDER_DESIGN.md §P4.A — keep fts_wiki in sync with disk
1049
+ // writes. The mail FTS pattern uses DB triggers (schema.ts line 692)
1050
+ // because mail rows live in `mail_messages_index`. Wiki content does
1051
+ // not — the canonical store is the filesystem — so this helper sits at
1052
+ // the write chokepoint instead. The §2.3 layer classifier in wiki.ts
1053
+ // uses a slightly different vocabulary ("log", "inbox") than the FTS
1054
+ // layer enum ("log", "inbox" too), so the mapping is a pass-through;
1055
+ // guarded by `isWikiFtsLayer` so an unexpected layer (added later but
1056
+ // not registered with the FTS) does not silently skip indexing.
1057
+ function syncWikiFts(db, workspaceId, classified, content) {
1058
+ if (!isWikiFtsLayer(classified.layer))
1059
+ return;
1060
+ upsertWikiFulltextRow(db, {
1061
+ workspaceId,
1062
+ path: classified.relPath,
1063
+ layer: classified.layer,
1064
+ content,
1065
+ });
1066
+ }
1067
+ function isWikiFtsLayer(value) {
1068
+ return (value === "raw" ||
1069
+ value === "wiki" ||
1070
+ value === "output" ||
1071
+ value === "meta" ||
1072
+ value === "log" ||
1073
+ value === "inbox");
1074
+ }
1075
+ //# sourceMappingURL=wiki.js.map