@clauderecallhq/cli 0.0.1 → 0.11.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 (277) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +543 -3
  3. package/README.public.md +523 -0
  4. package/dist/cli.js +354 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/activate.js +69 -0
  7. package/dist/commands/activate.js.map +1 -0
  8. package/dist/commands/audit-secrets.js +103 -0
  9. package/dist/commands/audit-secrets.js.map +1 -0
  10. package/dist/commands/blame.js +35 -0
  11. package/dist/commands/blame.js.map +1 -0
  12. package/dist/commands/config-verification.js +18 -0
  13. package/dist/commands/config-verification.js.map +1 -0
  14. package/dist/commands/context.js +144 -0
  15. package/dist/commands/context.js.map +1 -0
  16. package/dist/commands/correlate.js +70 -0
  17. package/dist/commands/correlate.js.map +1 -0
  18. package/dist/commands/digest.js +78 -0
  19. package/dist/commands/digest.js.map +1 -0
  20. package/dist/commands/health.js +62 -0
  21. package/dist/commands/health.js.map +1 -0
  22. package/dist/commands/index.js +247 -0
  23. package/dist/commands/index.js.map +1 -0
  24. package/dist/commands/install-extension.js +138 -0
  25. package/dist/commands/install-extension.js.map +1 -0
  26. package/dist/commands/license.js +39 -0
  27. package/dist/commands/license.js.map +1 -0
  28. package/dist/commands/list.js +47 -0
  29. package/dist/commands/list.js.map +1 -0
  30. package/dist/commands/mcp.js +29 -0
  31. package/dist/commands/mcp.js.map +1 -0
  32. package/dist/commands/open.js +28 -0
  33. package/dist/commands/open.js.map +1 -0
  34. package/dist/commands/paste.js +154 -0
  35. package/dist/commands/paste.js.map +1 -0
  36. package/dist/commands/projects.js +36 -0
  37. package/dist/commands/projects.js.map +1 -0
  38. package/dist/commands/search.js +67 -0
  39. package/dist/commands/search.js.map +1 -0
  40. package/dist/commands/semantic.js +173 -0
  41. package/dist/commands/semantic.js.map +1 -0
  42. package/dist/commands/show.js +121 -0
  43. package/dist/commands/show.js.map +1 -0
  44. package/dist/commands/start.js +47 -0
  45. package/dist/commands/start.js.map +1 -0
  46. package/dist/commands/stats.js +133 -0
  47. package/dist/commands/stats.js.map +1 -0
  48. package/dist/commands/status.js +45 -0
  49. package/dist/commands/status.js.map +1 -0
  50. package/dist/commands/stop.js +29 -0
  51. package/dist/commands/stop.js.map +1 -0
  52. package/dist/commands/thread.js +396 -0
  53. package/dist/commands/thread.js.map +1 -0
  54. package/dist/context/formatter.js +103 -0
  55. package/dist/context/formatter.js.map +1 -0
  56. package/dist/daemon/auto-tag-config.js +103 -0
  57. package/dist/daemon/auto-tag-config.js.map +1 -0
  58. package/dist/daemon/auto-tag-config.test.js +72 -0
  59. package/dist/daemon/auto-tag-config.test.js.map +1 -0
  60. package/dist/daemon/auto-title-config.js +70 -0
  61. package/dist/daemon/auto-title-config.js.map +1 -0
  62. package/dist/daemon/bulk-title-jobs.js +170 -0
  63. package/dist/daemon/bulk-title-jobs.js.map +1 -0
  64. package/dist/daemon/correlator.js +320 -0
  65. package/dist/daemon/correlator.js.map +1 -0
  66. package/dist/daemon/discover.js +316 -0
  67. package/dist/daemon/discover.js.map +1 -0
  68. package/dist/daemon/editor-detection.js +186 -0
  69. package/dist/daemon/editor-detection.js.map +1 -0
  70. package/dist/daemon/entrypoint.js +55 -0
  71. package/dist/daemon/entrypoint.js.map +1 -0
  72. package/dist/daemon/git-correlator.js +256 -0
  73. package/dist/daemon/git-correlator.js.map +1 -0
  74. package/dist/daemon/mcp-installer.js +108 -0
  75. package/dist/daemon/mcp-installer.js.map +1 -0
  76. package/dist/daemon/onboarding-state.js +140 -0
  77. package/dist/daemon/onboarding-state.js.map +1 -0
  78. package/dist/daemon/pidfile.js +57 -0
  79. package/dist/daemon/pidfile.js.map +1 -0
  80. package/dist/daemon/ports.js +48 -0
  81. package/dist/daemon/ports.js.map +1 -0
  82. package/dist/daemon/scanProgressRegistry.js +62 -0
  83. package/dist/daemon/scanProgressRegistry.js.map +1 -0
  84. package/dist/daemon/server.js +2010 -0
  85. package/dist/daemon/server.js.map +1 -0
  86. package/dist/daemon/tag-scanner/anthropic-client.js +40 -0
  87. package/dist/daemon/tag-scanner/anthropic-client.js.map +1 -0
  88. package/dist/daemon/tag-scanner/autopilot.js +131 -0
  89. package/dist/daemon/tag-scanner/autopilot.js.map +1 -0
  90. package/dist/daemon/tag-scanner/claude-cli-driver.js +250 -0
  91. package/dist/daemon/tag-scanner/claude-cli-driver.js.map +1 -0
  92. package/dist/daemon/tag-scanner/orchestrator.js +88 -0
  93. package/dist/daemon/tag-scanner/orchestrator.js.map +1 -0
  94. package/dist/daemon/tag-scanner/prompt.js +46 -0
  95. package/dist/daemon/tag-scanner/prompt.js.map +1 -0
  96. package/dist/daemon/tag-scanner/prompt.test.js +48 -0
  97. package/dist/daemon/tag-scanner/prompt.test.js.map +1 -0
  98. package/dist/daemon/tag-scanner/scan-state.js +49 -0
  99. package/dist/daemon/tag-scanner/scan-state.js.map +1 -0
  100. package/dist/daemon/tag-scanner/session-fetcher.js +82 -0
  101. package/dist/daemon/tag-scanner/session-fetcher.js.map +1 -0
  102. package/dist/daemon/tag-scanner/session-fetcher.test.js +34 -0
  103. package/dist/daemon/tag-scanner/session-fetcher.test.js.map +1 -0
  104. package/dist/daemon/tag-scanner/validator.js +50 -0
  105. package/dist/daemon/tag-scanner/validator.js.map +1 -0
  106. package/dist/daemon/tag-scanner/validator.test.js +41 -0
  107. package/dist/daemon/tag-scanner/validator.test.js.map +1 -0
  108. package/dist/daemon/terminal-registry.js +443 -0
  109. package/dist/daemon/terminal-registry.js.map +1 -0
  110. package/dist/daemon/ui.js +64 -0
  111. package/dist/daemon/ui.js.map +1 -0
  112. package/dist/daemon/watcher.js +256 -0
  113. package/dist/daemon/watcher.js.map +1 -0
  114. package/dist/db/client.js +22 -0
  115. package/dist/db/client.js.map +1 -0
  116. package/dist/db/schema.js +496 -0
  117. package/dist/db/schema.js.map +1 -0
  118. package/dist/license/api-base.js +13 -0
  119. package/dist/license/api-base.js.map +1 -0
  120. package/dist/license/manager.js +43 -0
  121. package/dist/license/manager.js.map +1 -0
  122. package/dist/license/public-key.js +19 -0
  123. package/dist/license/public-key.js.map +1 -0
  124. package/dist/license/storage.js +27 -0
  125. package/dist/license/storage.js.map +1 -0
  126. package/dist/license/verify.js +23 -0
  127. package/dist/license/verify.js.map +1 -0
  128. package/dist/mcp/audit.js +126 -0
  129. package/dist/mcp/audit.js.map +1 -0
  130. package/dist/mcp/prompts.js +180 -0
  131. package/dist/mcp/prompts.js.map +1 -0
  132. package/dist/mcp/server.js +502 -0
  133. package/dist/mcp/server.js.map +1 -0
  134. package/dist/mcp/thread-tools.js +363 -0
  135. package/dist/mcp/thread-tools.js.map +1 -0
  136. package/dist/mcp/write-tools.js +239 -0
  137. package/dist/mcp/write-tools.js.map +1 -0
  138. package/dist/parser/jsonl.js +150 -0
  139. package/dist/parser/jsonl.js.map +1 -0
  140. package/dist/semantic/chunker.js +47 -0
  141. package/dist/semantic/chunker.js.map +1 -0
  142. package/dist/semantic/config.js +74 -0
  143. package/dist/semantic/config.js.map +1 -0
  144. package/dist/semantic/embedder.js +54 -0
  145. package/dist/semantic/embedder.js.map +1 -0
  146. package/dist/semantic/fusion.js +38 -0
  147. package/dist/semantic/fusion.js.map +1 -0
  148. package/dist/semantic/model-download.js +69 -0
  149. package/dist/semantic/model-download.js.map +1 -0
  150. package/dist/semantic/pipeline.js +375 -0
  151. package/dist/semantic/pipeline.js.map +1 -0
  152. package/dist/semantic/query.js +42 -0
  153. package/dist/semantic/query.js.map +1 -0
  154. package/dist/semantic/worker.js +78 -0
  155. package/dist/semantic/worker.js.map +1 -0
  156. package/dist/stats/backfill.js +151 -0
  157. package/dist/stats/backfill.js.map +1 -0
  158. package/dist/stats/health.js +102 -0
  159. package/dist/stats/health.js.map +1 -0
  160. package/dist/stats/query.js +385 -0
  161. package/dist/stats/query.js.map +1 -0
  162. package/dist/utils/aliases.js +107 -0
  163. package/dist/utils/aliases.js.map +1 -0
  164. package/dist/utils/autoCollections.js +635 -0
  165. package/dist/utils/autoCollections.js.map +1 -0
  166. package/dist/utils/autoTitle.js +348 -0
  167. package/dist/utils/autoTitle.js.map +1 -0
  168. package/dist/utils/collections.js +446 -0
  169. package/dist/utils/collections.js.map +1 -0
  170. package/dist/utils/format.js +46 -0
  171. package/dist/utils/format.js.map +1 -0
  172. package/dist/utils/notes.js +270 -0
  173. package/dist/utils/notes.js.map +1 -0
  174. package/dist/utils/paths.js +50 -0
  175. package/dist/utils/paths.js.map +1 -0
  176. package/dist/utils/pricing.js +257 -0
  177. package/dist/utils/pricing.js.map +1 -0
  178. package/dist/utils/secret-scanner.js +166 -0
  179. package/dist/utils/secret-scanner.js.map +1 -0
  180. package/dist/utils/sessionLabel.js +64 -0
  181. package/dist/utils/sessionLabel.js.map +1 -0
  182. package/dist/utils/tags.js +97 -0
  183. package/dist/utils/tags.js.map +1 -0
  184. package/dist/utils/thread-context.js +129 -0
  185. package/dist/utils/thread-context.js.map +1 -0
  186. package/dist/utils/threadFilter.js +18 -0
  187. package/dist/utils/threadFilter.js.map +1 -0
  188. package/dist/utils/threads-titler.js +298 -0
  189. package/dist/utils/threads-titler.js.map +1 -0
  190. package/dist/utils/threads.js +383 -0
  191. package/dist/utils/threads.js.map +1 -0
  192. package/dist/utils/usage.js +76 -0
  193. package/dist/utils/usage.js.map +1 -0
  194. package/dist/verification/compute.js +88 -0
  195. package/dist/verification/compute.js.map +1 -0
  196. package/dist/verification/config.js +34 -0
  197. package/dist/verification/config.js.map +1 -0
  198. package/dist/web/assets/index-CIr6J4Fw.js +1201 -0
  199. package/dist/web/assets/index-Ctc8g9Jw.css +1 -0
  200. package/dist/web/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  201. package/dist/web/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  202. package/dist/web/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  203. package/dist/web/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  204. package/dist/web/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  205. package/dist/web/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  206. package/dist/web/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
  207. package/dist/web/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
  208. package/dist/web/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  209. package/dist/web/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  210. package/dist/web/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  211. package/dist/web/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  212. package/dist/web/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  213. package/dist/web/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  214. package/dist/web/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
  215. package/dist/web/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
  216. package/dist/web/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  217. package/dist/web/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  218. package/dist/web/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  219. package/dist/web/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  220. package/dist/web/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  221. package/dist/web/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  222. package/dist/web/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
  223. package/dist/web/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
  224. package/dist/web/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  225. package/dist/web/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  226. package/dist/web/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  227. package/dist/web/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  228. package/dist/web/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  229. package/dist/web/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  230. package/dist/web/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
  231. package/dist/web/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
  232. package/dist/web/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  233. package/dist/web/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  234. package/dist/web/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  235. package/dist/web/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  236. package/dist/web/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  237. package/dist/web/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  238. package/dist/web/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  239. package/dist/web/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  240. package/dist/web/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  241. package/dist/web/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  242. package/dist/web/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  243. package/dist/web/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  244. package/dist/web/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  245. package/dist/web/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  246. package/dist/web/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
  247. package/dist/web/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
  248. package/dist/web/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  249. package/dist/web/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  250. package/dist/web/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  251. package/dist/web/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  252. package/dist/web/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  253. package/dist/web/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  254. package/dist/web/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
  255. package/dist/web/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
  256. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  257. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  258. package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  259. package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  260. package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  261. package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  262. package/dist/web/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  263. package/dist/web/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  264. package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  265. package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  266. package/dist/web/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  267. package/dist/web/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  268. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  269. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  270. package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  271. package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  272. package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  273. package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  274. package/dist/web/favicon.svg +9 -0
  275. package/dist/web/index.html +15 -0
  276. package/package.json +79 -9
  277. package/bin/cli.js +0 -12
@@ -0,0 +1,2010 @@
1
+ import { Hono } from 'hono';
2
+ import { serve } from '@hono/node-server';
3
+ import { getLicenseStatus } from '../license/manager.js';
4
+ import { serveStatic } from '@hono/node-server/serve-static';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { stat, readFile, realpath } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { getDb } from '../db/client.js';
10
+ import { placeholderHtml } from './ui.js';
11
+ import { formatSessionAsContext, } from '../context/formatter.js';
12
+ import { setAlias, clearAlias, getAlias } from '../utils/aliases.js';
13
+ import { deriveAgentNote, getNote, setAutoSynopsis, setNote } from '../utils/notes.js';
14
+ import { addTag, removeTag, tagsForSession, allTagsWithCounts, normalizeTag, } from '../utils/tags.js';
15
+ import { listCollections, getCollection, createCollection, patchCollection, archiveCollection, restoreCollection, addSessionToCollection, removeSessionFromCollection, sessionsInCollection, collectionsForSession, descendantIds, } from '../utils/collections.js';
16
+ import { listRules as listAutoRules, createRule as createAutoRule, patchRule as patchAutoRule, deleteRule as deleteAutoRule, listSuggestions as listAutoSuggestions, acceptSuggestion as acceptAutoSuggestion, dismissSuggestion as dismissAutoSuggestion, detectSuggestions as detectAutoSuggestions, previewMatches as previewAutoSuggestion, autoCollectionIdSet, } from '../utils/autoCollections.js';
17
+ import { createThread, listThreads, getThread, threadsForSession, addSessionToThread, removeSessionFromThread, setParent, renameThread, closeThread, reopenThread, archiveThread, mergeThreads, splitThread, } from '../utils/threads.js';
18
+ import { startBulkTitleJob, subscribeJob, cancelJob, getJobSnapshot, } from './bulk-title-jobs.js';
19
+ import { terminalRegistry, looksLikeClaudeAutoTitle, stripAutoTitlePrefix, } from './terminal-registry.js';
20
+ import { isGenericShellName, tryAutoAlias } from './correlator.js';
21
+ /**
22
+ * Propagate a terminal rename to the alias of every session that started in
23
+ * that shell. Returns the number of sessions whose alias was updated.
24
+ *
25
+ * Name resolution mirrors the correlator's:
26
+ * - Claude Code auto-titles ("⠐ Exploratory coding session", "✳ Claude
27
+ * Code", etc.) get the spinner / busy / version prefix stripped. The
28
+ * clean remainder (e.g. "Exploratory coding session") propagates.
29
+ * - Pure spinner ("✳") or version-only strings ("2.1.119") strip to null
30
+ * and are dropped — no usable content.
31
+ * - Generic shell names ("zsh", "bash", "/bin/zsh") are dropped.
32
+ * - Anything else propagates as-is.
33
+ *
34
+ * Idempotency: if the resolved name equals the session's current alias, we
35
+ * skip the SQLite write. So Claude oscillating its spinner glyph (⠐ ⠂ ⠐ …)
36
+ * produces zero churn — every tick strips to the same name and short-circuits.
37
+ */
38
+ function propagateRenameToSessions(shell_pid, tab_name) {
39
+ let trimmed = tab_name.trim();
40
+ if (!trimmed)
41
+ return 0;
42
+ if (looksLikeClaudeAutoTitle(trimmed)) {
43
+ const stripped = stripAutoTitlePrefix(trimmed);
44
+ if (!stripped)
45
+ return 0;
46
+ trimmed = stripped;
47
+ }
48
+ if (isGenericShellName(trimmed))
49
+ return 0;
50
+ const sessions = terminalRegistry.sessionsFor(shell_pid);
51
+ let updated = 0;
52
+ for (const sid of sessions) {
53
+ try {
54
+ const current = getAlias(sid);
55
+ if (current === trimmed)
56
+ continue;
57
+ setAlias(sid, trimmed);
58
+ updated++;
59
+ }
60
+ catch {
61
+ /* non-fatal */
62
+ }
63
+ }
64
+ if (updated > 0) {
65
+ // eslint-disable-next-line no-console
66
+ console.log(`[terminal] rename of pid ${shell_pid} → "${trimmed}" propagated to ${updated} session(s)`);
67
+ }
68
+ return updated;
69
+ }
70
+ import { readAutoTagConfig, writeAutoTagConfig, redactForApi, AutoTagConfigSchema, } from './auto-tag-config.js';
71
+ import { readAutoTitleConfig, writeAutoTitleConfig, AutoTitleConfigSchema, } from './auto-title-config.js';
72
+ import { backfillHeuristicTitles, deriveAgentTitle, getAutoTitle, setAutoTitle, } from '../utils/autoTitle.js';
73
+ import { streamSSE } from 'hono/streaming';
74
+ import { z } from 'zod';
75
+ import { listSessionsForScan } from './tag-scanner/session-fetcher.js';
76
+ import { createScan, getScan, subscribe, cancelScan, deleteScan, } from './tag-scanner/scan-state.js';
77
+ import { runScan, applyScanSelection } from './tag-scanner/orchestrator.js';
78
+ import { kickAutopilot, getAutopilotSnapshot, subscribeAutopilot } from './tag-scanner/autopilot.js';
79
+ import { getMcpInstallStatus, installMcp, uninstallMcp } from './mcp-installer.js';
80
+ import { readOnboardingState, writeOnboardingState, resetOnboardingState, OnboardingStateSchema, } from './onboarding-state.js';
81
+ import { isClaudeCliAvailable, runClaudeCliScan, spawnClaudePrompt, } from './tag-scanner/claude-cli-driver.js';
82
+ import { publish as publishScanProgress, subscribe as subscribeScanProgress, } from './scanProgressRegistry.js';
83
+ import { RECALL_PROMPTS, findPrompt } from '../mcp/prompts.js';
84
+ import { readSemanticConfig, writeSemanticConfig, SemanticConfigSchema } from '../semantic/config.js';
85
+ import { backfill as semanticBackfill, getSemanticStatus, processSession as processSemanticSession } from '../semantic/pipeline.js';
86
+ import { getOverviewStats, getProjectStats, getSessionStats, } from '../stats/query.js';
87
+ import { computeAllHealthScores, computeHealthScore } from '../stats/health.js';
88
+ import { getLastBackfillRun, isBackfillRunning, runBackfill, startBackgroundBackfill, } from '../stats/backfill.js';
89
+ import { correlateSession, findSessionsByCommit, getCommitsForSession, } from './git-correlator.js';
90
+ import { discoverToday } from './discover.js';
91
+ import { getEmbedderStatus, loadEmbedder } from '../semantic/embedder.js';
92
+ import { vectorSearch, findSimilarSessions } from '../semantic/query.js';
93
+ import { fuseResults } from '../semantic/fusion.js';
94
+ import { startWorker, getWorkerStatus } from '../semantic/worker.js';
95
+ import { isModelInstalled, downloadModel } from '../semantic/model-download.js';
96
+ import { getOrCompute as getOrComputeVerification } from '../verification/compute.js';
97
+ import { isVerificationEnabled, setVerificationEnabled } from '../verification/config.js';
98
+ const VERSION = '0.11.0';
99
+ const here = dirname(fileURLToPath(import.meta.url));
100
+ const DIST_WEB = join(here, '..', 'web');
101
+ const INDEX_HTML = join(DIST_WEB, 'index.html');
102
+ const HAS_BUNDLED_UI = existsSync(INDEX_HTML);
103
+ function readStats() {
104
+ const db = getDb();
105
+ return db
106
+ .prepare(`SELECT
107
+ (SELECT COUNT(*) FROM projects) AS projects,
108
+ (SELECT COUNT(*) FROM sessions) AS sessions,
109
+ (SELECT COUNT(*) FROM messages) AS messages,
110
+ (SELECT MIN(started_at) FROM sessions WHERE started_at IS NOT NULL) AS earliest,
111
+ (SELECT MAX(started_at) FROM sessions WHERE started_at IS NOT NULL) AS latest`)
112
+ .get();
113
+ }
114
+ /**
115
+ * Tier-1 localhost hardening. The daemon binds to 127.0.0.1, but a loopback
116
+ * socket alone is not enough — a malicious website the user visits could
117
+ * otherwise reach us via DNS rebinding (attacker domain re-resolves to
118
+ * 127.0.0.1; browser's same-origin policy is bypassed because the hostname
119
+ * "matches"). We defend with three layered checks:
120
+ *
121
+ * 1. Host header must be 127.0.0.1|localhost (optionally with port).
122
+ * DNS-rebinding attacks send the attacker's hostname; we reject those.
123
+ * 2. Origin header, when present, must be a loopback origin. Cross-origin
124
+ * browser requests always carry Origin; non-browser tools (curl, the
125
+ * CLI) omit it and are allowed.
126
+ * 3. Every response carries a strict CSP and related hardening headers so
127
+ * that even if some future bug lets attacker content render, it cannot
128
+ * phone home or execute inline scripts.
129
+ */
130
+ const HOST_ALLOWED_RE = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
131
+ const ORIGIN_ALLOWED_RE = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
132
+ async function requireProMiddleware(c, next) {
133
+ const status = await getLicenseStatus();
134
+ if (status.tier !== 'pro') {
135
+ return c.json({
136
+ error: 'pro_required',
137
+ message: 'This feature requires a Claude Recall Pro license.',
138
+ upgrade_url: 'https://clauderecall.com/pricing',
139
+ activate_command: 'recall activate <license-key>',
140
+ }, 402);
141
+ }
142
+ await next();
143
+ }
144
+ export function buildApp() {
145
+ const app = new Hono();
146
+ // Layer 1+2: Host and Origin validation. Runs before any route.
147
+ app.use('*', async (c, next) => {
148
+ const host = c.req.raw.headers.get('host') ?? '';
149
+ if (!HOST_ALLOWED_RE.test(host)) {
150
+ return c.text('Forbidden: invalid Host header', 403);
151
+ }
152
+ const origin = c.req.raw.headers.get('origin');
153
+ if (origin && !ORIGIN_ALLOWED_RE.test(origin)) {
154
+ return c.text('Forbidden: cross-origin request rejected', 403);
155
+ }
156
+ await next();
157
+ });
158
+ // Layer 3: security headers on every response.
159
+ app.use('*', async (c, next) => {
160
+ await next();
161
+ // CSP scoped for a local React SPA. 'unsafe-inline' on style is required
162
+ // by Tailwind's injected styles; scripts stay strict.
163
+ c.res.headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
164
+ c.res.headers.set('X-Content-Type-Options', 'nosniff');
165
+ c.res.headers.set('X-Frame-Options', 'DENY');
166
+ c.res.headers.set('Referrer-Policy', 'no-referrer');
167
+ c.res.headers.set('Cross-Origin-Resource-Policy', 'same-origin');
168
+ });
169
+ app.get('/api/health', (c) => c.json({
170
+ status: 'ok',
171
+ version: VERSION,
172
+ pid: process.pid,
173
+ uptimeSeconds: Math.round(process.uptime()),
174
+ }));
175
+ app.get('/api/stats', (c) => c.json(readStats()));
176
+ // v0.10a — cost / token analytics.
177
+ //
178
+ // All three endpoints derive dollar amounts at render time from the
179
+ // static pricing table in src/utils/pricing.ts — only raw token counts
180
+ // are persisted in message_usage + session rollups. Prices change; the
181
+ // source of truth shouldn't.
182
+ app.get('/api/stats/session/:id', (c) => {
183
+ const stats = getSessionStats(c.req.param('id'));
184
+ if (!stats)
185
+ return c.json({ error: 'session not found' }, 404);
186
+ return c.json(stats);
187
+ });
188
+ app.get('/api/stats/project/:name', (c) => {
189
+ const stats = getProjectStats(c.req.param('name'));
190
+ if (!stats)
191
+ return c.json({ error: 'project not found' }, 404);
192
+ return c.json(stats);
193
+ });
194
+ app.get('/api/stats/overview', (c) => {
195
+ const raw = c.req.query('range');
196
+ const range = raw === '7d' ? '7d' : raw === '30d' ? '30d' : 'all';
197
+ return c.json(getOverviewStats(range));
198
+ });
199
+ // Run a backfill pass. Synchronous up to ~5k messages so the UI gets
200
+ // accurate stats back in the same response (chunked txns keep locks
201
+ // short). Larger requests fall back to the background queue.
202
+ app.post('/api/stats/backfill', async (c) => {
203
+ const body = (await c.req.json().catch(() => ({})));
204
+ const limit = body.limit
205
+ ? Math.max(1, Math.min(100_000, Number(body.limit)))
206
+ : 5_000;
207
+ if (limit > 5_000) {
208
+ const started = startBackgroundBackfill({ limit });
209
+ return c.json({
210
+ mode: 'background',
211
+ started,
212
+ alreadyRunning: !started && isBackfillRunning(),
213
+ limit,
214
+ lastRun: getLastBackfillRun(),
215
+ });
216
+ }
217
+ const result = runBackfill({ limit });
218
+ return c.json({
219
+ mode: 'sync',
220
+ started: false,
221
+ alreadyRunning: false,
222
+ limit,
223
+ result,
224
+ lastRun: getLastBackfillRun(),
225
+ });
226
+ });
227
+ // v0.16 — Memory health score
228
+ app.get('/api/stats/health', (c) => {
229
+ return c.json(computeAllHealthScores());
230
+ });
231
+ app.get('/api/stats/health/:projectId', (c) => {
232
+ const id = Number(c.req.param('projectId'));
233
+ const result = computeHealthScore(id);
234
+ if (!result)
235
+ return c.json({ error: 'project not found' }, 404);
236
+ return c.json(result);
237
+ });
238
+ // v0.16 — Verification badges
239
+ app.get('/api/config/verification', (c) => {
240
+ return c.json({ enabled: isVerificationEnabled() });
241
+ });
242
+ app.put('/api/config/verification', async (c) => {
243
+ const body = (await c.req.json());
244
+ if (typeof body.enabled === 'boolean') {
245
+ setVerificationEnabled(body.enabled);
246
+ }
247
+ return c.json({ enabled: isVerificationEnabled() });
248
+ });
249
+ app.get('/api/sessions/:id/verification', (c) => {
250
+ const id = c.req.param('id');
251
+ const result = getOrComputeVerification(id);
252
+ return c.json(result);
253
+ });
254
+ // v0.10b — git correlation endpoints.
255
+ //
256
+ // Both are read-only. The write-path (running `git log`) is triggered by
257
+ // the daemon's own watcher + the explicit `recall correlate` CLI; we do
258
+ // not expose a POST here that lets arbitrary HTTP callers fire scoped
259
+ // subprocesses. If a session hasn't been correlated yet, the GET returns
260
+ // an empty list and the transcript header quietly shows nothing.
261
+ app.get('/api/sessions/:id/commits', async (c) => {
262
+ const id = c.req.param('id');
263
+ const existing = getCommitsForSession(id);
264
+ if (existing.length > 0 || c.req.query('refresh') !== '1') {
265
+ return c.json({ commits: existing });
266
+ }
267
+ // Lazy first-view correlation. Only runs when explicitly asked via
268
+ // ?refresh=1 to keep pageload cheap on repeat visits.
269
+ const res = await correlateSession(id);
270
+ return c.json({ commits: getCommitsForSession(id), status: res.status });
271
+ });
272
+ app.get('/api/commits/:sha/session', (c) => {
273
+ const sha = c.req.param('sha');
274
+ if (!/^[0-9a-fA-F]{4,40}$/.test(sha)) {
275
+ return c.json({ error: 'invalid sha format' }, 400);
276
+ }
277
+ return c.json({ sessions: findSessionsByCommit(sha) });
278
+ });
279
+ // v0.12b — Rediscovery "For you" picks. Three independent cards (rediscovered,
280
+ // expensive, authored) plus the availability bitmap so the UI can distinguish
281
+ // "feature off" from "feature on but no pick today". Each card is null when
282
+ // its backing data stream isn't producing yet (v0.11 semantic, v0.10a cost,
283
+ // v0.10b git respectively) — the response shape is stable so the client can
284
+ // render each slot independently.
285
+ app.get('/api/license/status', async (c) => {
286
+ const status = await getLicenseStatus();
287
+ return c.json(status);
288
+ });
289
+ app.get('/api/discover/today', requireProMiddleware, async (c) => {
290
+ try {
291
+ return c.json(await discoverToday());
292
+ }
293
+ catch (err) {
294
+ console.error('[discover.today]', err);
295
+ return c.json({
296
+ rediscovered: null,
297
+ expensive: null,
298
+ authored: null,
299
+ availability: { semantic: false, cost: false, git: false },
300
+ generatedAt: new Date().toISOString(),
301
+ error: err.message,
302
+ }, 500);
303
+ }
304
+ });
305
+ app.get('/api/projects', (c) => {
306
+ const db = getDb();
307
+ const rows = db
308
+ .prepare(`SELECT p.id, p.name, p.decoded_path,
309
+ COUNT(s.id) AS session_count,
310
+ COALESCE(SUM(s.message_count), 0) AS message_count,
311
+ MAX(COALESCE(s.ended_at, s.started_at)) AS latest
312
+ FROM projects p
313
+ LEFT JOIN sessions s ON s.project_id = p.id
314
+ GROUP BY p.id
315
+ ORDER BY MAX(COALESCE(s.ended_at, s.started_at, '')) DESC`)
316
+ .all();
317
+ return c.json(rows);
318
+ });
319
+ app.get('/api/sessions', (c) => {
320
+ const db = getDb();
321
+ const project = c.req.query('project');
322
+ const since = c.req.query('since');
323
+ const until = c.req.query('until');
324
+ const tagsFilter = c.req.queries('tag') ?? [];
325
+ const collection = c.req.query('collection');
326
+ const limit = Math.max(1, Math.min(500, Number(c.req.query('limit') ?? 100)));
327
+ const params = { limit };
328
+ let where = 's.message_count > 2';
329
+ if (project) {
330
+ where += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
331
+ params.proj = `%${project}%`;
332
+ }
333
+ if (since) {
334
+ where += ' AND s.started_at >= @since';
335
+ params.since = since;
336
+ }
337
+ if (until) {
338
+ where += ' AND s.started_at <= @until';
339
+ if (/^\d{4}-\d{2}-\d{2}$/.test(until))
340
+ params.until = `${until}T23:59:59.999Z`;
341
+ else
342
+ params.until = until;
343
+ }
344
+ if (tagsFilter.length > 0) {
345
+ const normalized = tagsFilter.map((t) => normalizeTag(t)).filter(Boolean);
346
+ normalized.forEach((tag, i) => {
347
+ where += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
348
+ params[`tag_${i}`] = tag;
349
+ });
350
+ }
351
+ if (collection) {
352
+ // Collections are hierarchical; filtering by a parent includes every
353
+ // child collection's sessions too (matches the UX spec: "recursively
354
+ // include child collections when the collection has children").
355
+ const ids = descendantIds(collection);
356
+ if (ids.length === 0) {
357
+ return c.json([]);
358
+ }
359
+ const placeholders = ids.map((_, i) => `@col_${i}`).join(',');
360
+ where += ` AND s.id IN (SELECT session_id FROM collection_sessions WHERE collection_id IN (${placeholders}))`;
361
+ ids.forEach((cid, i) => {
362
+ params[`col_${i}`] = cid;
363
+ });
364
+ }
365
+ const rows = db
366
+ .prepare(`SELECT s.id, p.name AS project, s.started_at, s.ended_at,
367
+ s.message_count, s.first_user_message, s.git_branch,
368
+ s.auto_title, s.auto_title_source, s.verification_status,
369
+ NULLIF(sa.alias, '') AS alias,
370
+ CASE
371
+ WHEN (sn.content IS NOT NULL AND sn.content != '')
372
+ OR (sn.auto_synopsis IS NOT NULL AND sn.auto_synopsis != '')
373
+ THEN 1 ELSE 0
374
+ END AS has_notes,
375
+ COALESCE(
376
+ (SELECT GROUP_CONCAT(tag, ',')
377
+ FROM (SELECT tag FROM session_tags WHERE session_id = s.id ORDER BY tag)),
378
+ ''
379
+ ) AS tags_csv
380
+ FROM sessions s
381
+ JOIN projects p ON p.id = s.project_id
382
+ LEFT JOIN session_aliases sa ON sa.session_id = s.id
383
+ LEFT JOIN session_notes sn ON sn.session_id = s.id
384
+ WHERE ${where}
385
+ ORDER BY COALESCE(s.ended_at, s.started_at, '') DESC
386
+ LIMIT @limit`)
387
+ .all(params);
388
+ // Expand tags_csv into a real array for the client, then decorate each
389
+ // row with the editor/terminal origin (in-memory, v0.15 T4). Origin is
390
+ // a separate lookup because we deliberately don't persist it to SQLite.
391
+ const expanded = rows.map(({ tags_csv, ...rest }) => {
392
+ const sessionId = rest.id;
393
+ const detected = terminalRegistry.getOrigin(sessionId);
394
+ // 'auto' = correlator-set from a terminal tab name or origin fallback.
395
+ // 'manual' = user typed it via PUT /api/sessions/:id/alias. Mirrors
396
+ // the same logic in the detail endpoint so the list view can apply
397
+ // the same display precedence (auto_title beats auto-alias).
398
+ const aliasValue = rest.alias;
399
+ const aliasSource = aliasValue == null
400
+ ? null
401
+ : terminalRegistry.isSessionAutoLinked(sessionId)
402
+ ? 'auto'
403
+ : 'manual';
404
+ return {
405
+ ...rest,
406
+ tags: tags_csv ? tags_csv.split(',') : [],
407
+ origin: detected ? { editor: detected.editor, label: detected.label } : null,
408
+ alias_source: aliasSource,
409
+ };
410
+ });
411
+ return c.json(expanded);
412
+ });
413
+ app.get('/api/sessions/:id', (c) => {
414
+ const db = getDb();
415
+ const id = c.req.param('id');
416
+ const session = db
417
+ .prepare(`SELECT s.*, p.name AS project_name, p.decoded_path,
418
+ NULLIF(sa.alias, '') AS alias
419
+ FROM sessions s
420
+ JOIN projects p ON p.id = s.project_id
421
+ LEFT JOIN session_aliases sa ON sa.session_id = s.id
422
+ WHERE s.id = ?`)
423
+ .get(id);
424
+ if (!session)
425
+ return c.json({ error: 'not found' }, 404);
426
+ const tags = tagsForSession(id);
427
+ const detected = terminalRegistry.getOrigin(id);
428
+ const origin = detected
429
+ ? { editor: detected.editor, label: detected.label }
430
+ : null;
431
+ // 'auto' = alias set by the correlator from a terminal tab name or origin
432
+ // fallback. 'manual' = user typed it via PUT /api/sessions/:id/alias.
433
+ // The client uses this to decide whether an ✨ agent title should beat
434
+ // the alias on display (auto → yes, manual → no).
435
+ const aliasSource = session.alias == null
436
+ ? null
437
+ : terminalRegistry.isSessionAutoLinked(id)
438
+ ? 'auto'
439
+ : 'manual';
440
+ const messages = db
441
+ .prepare(`SELECT uuid, type, role, timestamp, is_sidechain, content_text, tool_names
442
+ FROM messages
443
+ WHERE session_id = ?
444
+ ORDER BY COALESCE(timestamp, ''), rowid`)
445
+ .all(id);
446
+ return c.json({
447
+ session: { ...session, tags, origin, alias_source: aliasSource },
448
+ messages,
449
+ });
450
+ });
451
+ // Tag endpoints
452
+ app.get('/api/tags', (c) => c.json(allTagsWithCounts()));
453
+ app.get('/api/sessions/:id/tags', (c) => {
454
+ return c.json({ tags: tagsForSession(c.req.param('id')) });
455
+ });
456
+ app.post('/api/sessions/:id/tags', async (c) => {
457
+ const id = c.req.param('id');
458
+ const body = (await c.req.json().catch(() => null));
459
+ if (!body || typeof body.tag !== 'string') {
460
+ return c.json({ error: 'tag required' }, 400);
461
+ }
462
+ try {
463
+ const result = addTag(id, body.tag);
464
+ return c.json(result);
465
+ }
466
+ catch (err) {
467
+ return c.json({ error: err.message }, 400);
468
+ }
469
+ });
470
+ app.delete('/api/sessions/:id/tags/:tag', (c) => {
471
+ const id = c.req.param('id');
472
+ const tag = c.req.param('tag');
473
+ return c.json(removeTag(id, tag));
474
+ });
475
+ // Auto-tag config. GET redacts the apiKey over the wire (returns a marker
476
+ // + `hasApiKey` bool). PUT merges the submitted patch with existing values,
477
+ // preserving a stored apiKey when the PUT body omits one.
478
+ app.get('/api/config/auto-tag', (c) => {
479
+ return c.json(redactForApi(readAutoTagConfig()));
480
+ });
481
+ app.put('/api/config/auto-tag', async (c) => {
482
+ const body = await c.req.json().catch(() => ({}));
483
+ const parsed = AutoTagConfigSchema.partial().safeParse(body);
484
+ if (!parsed.success) {
485
+ return c.json({ error: 'invalid config', issues: parsed.error.issues }, 400);
486
+ }
487
+ const patch = parsed.data;
488
+ // If caller did not send an apiKey field, preserve the existing one.
489
+ if (patch.apiKey === undefined) {
490
+ delete patch.apiKey;
491
+ }
492
+ const merged = writeAutoTagConfig(patch);
493
+ // Autopilot depends on config — kick it so it picks up flag changes.
494
+ if (merged.autopilot && merged.enabled && merged.backend === 'api' && merged.apiKey) {
495
+ void kickAutopilot();
496
+ }
497
+ return c.json(redactForApi(merged));
498
+ });
499
+ // v0.14a — first-run onboarding state. The web UI hits this on boot; if
500
+ // `completed` and `skipped` are both false the 3-step tour fires. Writes
501
+ // are additive (history-preserving); reset wipes the terminal flags so
502
+ // the "Replay onboarding" button in the Command Center can fire again.
503
+ app.get('/api/onboarding', (c) => {
504
+ const db = getDb();
505
+ const latest = db
506
+ .prepare(`SELECT s.id,
507
+ p.name AS project,
508
+ s.started_at,
509
+ s.ended_at,
510
+ s.message_count,
511
+ s.first_user_message,
512
+ NULLIF(sa.alias, '') AS alias
513
+ FROM sessions s
514
+ JOIN projects p ON p.id = s.project_id
515
+ LEFT JOIN session_aliases sa ON sa.session_id = s.id
516
+ WHERE s.message_count > 2
517
+ ORDER BY COALESCE(s.ended_at, s.started_at, '') DESC
518
+ LIMIT 1`)
519
+ .get();
520
+ return c.json({ state: readOnboardingState(), mostRecentSession: latest ?? null });
521
+ });
522
+ app.put('/api/onboarding', async (c) => {
523
+ const body = await c.req.json().catch(() => ({}));
524
+ const parsed = OnboardingStateSchema.partial().safeParse(body);
525
+ if (!parsed.success) {
526
+ return c.json({ error: 'invalid onboarding state', issues: parsed.error.issues }, 400);
527
+ }
528
+ return c.json(writeOnboardingState(parsed.data));
529
+ });
530
+ app.post('/api/onboarding/reset', (c) => c.json(resetOnboardingState()));
531
+ // One-click MCP install into ~/.claude.json. Merges surgically; never
532
+ // clobbers other MCP entries the user may already have.
533
+ app.get('/api/config/mcp-install', (c) => c.json({ ...getMcpInstallStatus(), claudeCliAvailable: isClaudeCliAvailable() }));
534
+ app.post('/api/config/mcp-install', (c) => c.json({ ...installMcp(), claudeCliAvailable: isClaudeCliAvailable() }));
535
+ app.delete('/api/config/mcp-install', (c) => c.json({ ...uninstallMcp(), claudeCliAvailable: isClaudeCliAvailable() }));
536
+ // Button-driven MCP scan: spawn `claude -p` in headless mode so the
537
+ // user's own Claude Code subscription does the work — no API key, no
538
+ // copy-paste. Claude picks up the Recall MCP server from ~/.claude.json
539
+ // and applies tags via mcp__recall__apply_tags. We block until the
540
+ // subprocess finishes (capped at 30 min by the driver) and report the
541
+ // tag-count delta so the UI can show a concrete result.
542
+ const ClaudeCliScanSchema = z.object({
543
+ scope: z
544
+ .object({
545
+ untaggedOnly: z.boolean().optional(),
546
+ project: z.string().optional(),
547
+ collectionId: z.string().optional(),
548
+ sessionIds: z.array(z.string()).optional(),
549
+ limit: z.number().int().min(1).max(500).optional(),
550
+ })
551
+ .default({}),
552
+ /** Optional per-run model override; falls back to config.model, then claude's default. */
553
+ model: z.string().optional(),
554
+ /**
555
+ * Optional client-generated scan id. When present the runner emits per-
556
+ * session progress events on scanProgressRegistry so an EventSource
557
+ * subscriber can render live "Tagging session X of N" feedback. Clients
558
+ * that don't care (e.g. the legacy settings dialog) can omit it.
559
+ */
560
+ scanId: z.string().min(1).max(100).optional(),
561
+ });
562
+ app.post('/api/tags/scan/claude-cli', async (c) => {
563
+ if (!isClaudeCliAvailable()) {
564
+ return c.json({
565
+ error: 'claude CLI not found on PATH. Install Claude Code locally, then reload.',
566
+ }, 400);
567
+ }
568
+ const mcp = getMcpInstallStatus();
569
+ if (!mcp.installed) {
570
+ return c.json({ error: 'Recall MCP is not installed in Claude Code yet — run the one-click install first.' }, 400);
571
+ }
572
+ const body = await c.req.json().catch(() => ({}));
573
+ const parsed = ClaudeCliScanSchema.safeParse(body);
574
+ if (!parsed.success) {
575
+ return c.json({ error: 'invalid scope', issues: parsed.error.issues }, 400);
576
+ }
577
+ const scope = parsed.data.scope;
578
+ // Model precedence: per-run override → config.model → claude's default.
579
+ const cfg = readAutoTagConfig();
580
+ const model = parsed.data.model ?? cfg.model;
581
+ const db = getDb();
582
+ const tagCount = () => db
583
+ .prepare('SELECT COUNT(*) AS n FROM session_tags')
584
+ .get().n;
585
+ const before = tagCount();
586
+ const scanId = parsed.data.scanId;
587
+ const result = await runClaudeCliScan(scope, { model, scanId });
588
+ const after = tagCount();
589
+ const tagsAdded = Math.max(0, after - before);
590
+ // When the client passed a scanId, broadcast the terminal event so any
591
+ // open SSE subscriber can render the final success bubble and close.
592
+ if (scanId) {
593
+ publishScanProgress(scanId, {
594
+ type: 'done',
595
+ result: { success: result.success, exitCode: result.exitCode, tagsAdded },
596
+ });
597
+ }
598
+ return c.json({
599
+ success: result.success,
600
+ exitCode: result.exitCode,
601
+ tagsAdded,
602
+ model,
603
+ stdout: result.stdout.slice(0, 2000),
604
+ stderrTail: result.stderr.slice(-2000),
605
+ });
606
+ });
607
+ // Per-scan progress stream for the claude-cli auto-tag runner. Clients
608
+ // generate a scanId (UUID), open this EventSource first, then POST
609
+ // /api/tags/scan/claude-cli with the same id — the POST runs the scan
610
+ // and publishes progress on the registry while this route relays it.
611
+ // Heartbeats every 15s to keep intermediaries from closing the socket.
612
+ app.get('/api/claude-cli/scan/:scanId/progress', (c) => {
613
+ const scanId = c.req.param('scanId');
614
+ return streamSSE(c, async (stream) => {
615
+ const pending = [];
616
+ const wake = { resolve: () => { } };
617
+ let waiter = new Promise((r) => {
618
+ wake.resolve = r;
619
+ });
620
+ const unsubscribe = subscribeScanProgress(scanId, (ev) => {
621
+ pending.push(ev);
622
+ const prev = wake.resolve;
623
+ waiter = new Promise((r) => {
624
+ wake.resolve = r;
625
+ });
626
+ prev();
627
+ });
628
+ // Heartbeat loop: every 15s, push a `: heartbeat` comment. Keeps
629
+ // proxies (if any) and browsers from considering the stream idle.
630
+ let closed = false;
631
+ const heartbeat = setInterval(() => {
632
+ if (closed)
633
+ return;
634
+ stream.writeSSE({ event: 'heartbeat', data: '' }).catch(() => {
635
+ closed = true;
636
+ });
637
+ }, 15_000);
638
+ try {
639
+ while (!closed) {
640
+ if (pending.length === 0)
641
+ await waiter;
642
+ const ev = pending.shift();
643
+ if (!ev)
644
+ continue;
645
+ await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) });
646
+ if (ev.type === 'done')
647
+ break;
648
+ }
649
+ }
650
+ finally {
651
+ closed = true;
652
+ clearInterval(heartbeat);
653
+ unsubscribe();
654
+ }
655
+ });
656
+ });
657
+ // Recall prompt library: list + run. Any Recall prompt registered in
658
+ // src/mcp/prompts.ts is automatically exposed here so the web UI (and
659
+ // any other HTTP consumer) can spawn the user's local claude CLI to
660
+ // execute it. Same prompts, same MCP tools, same one-source-of-truth.
661
+ app.get('/api/prompts', (c) => c.json({
662
+ prompts: RECALL_PROMPTS.map((p) => ({
663
+ name: p.name,
664
+ title: p.title,
665
+ description: p.description,
666
+ })),
667
+ claudeCliAvailable: isClaudeCliAvailable(),
668
+ }));
669
+ app.post('/api/prompts/run', async (c) => {
670
+ if (!isClaudeCliAvailable()) {
671
+ return c.json({ error: 'claude CLI not found on PATH. Install Claude Code locally, then reload.' }, 400);
672
+ }
673
+ const mcp = getMcpInstallStatus();
674
+ if (!mcp.installed) {
675
+ return c.json({ error: 'Recall MCP is not installed in Claude Code yet — run the one-click install first.' }, 400);
676
+ }
677
+ const body = await c.req.json().catch(() => ({}));
678
+ const PromptRunSchema = z.object({
679
+ name: z.string(),
680
+ args: z.record(z.string(), z.unknown()).optional(),
681
+ model: z.string().optional(),
682
+ });
683
+ const parsed = PromptRunSchema.safeParse(body);
684
+ if (!parsed.success) {
685
+ return c.json({ error: 'invalid request', issues: parsed.error.issues }, 400);
686
+ }
687
+ const def = findPrompt(parsed.data.name);
688
+ if (!def)
689
+ return c.json({ error: `unknown prompt: ${parsed.data.name}` }, 404);
690
+ const promptText = def.build((parsed.data.args ?? {}));
691
+ const cfg = readAutoTagConfig();
692
+ const model = parsed.data.model ?? cfg.model;
693
+ const result = await spawnClaudePrompt(promptText, def.allowedTools, { model });
694
+ return c.json({
695
+ success: result.success,
696
+ exitCode: result.exitCode,
697
+ promptName: def.name,
698
+ model,
699
+ stdout: result.stdout,
700
+ stderrTail: result.stderr.slice(-4000),
701
+ });
702
+ });
703
+ // Autopilot status: GET snapshot, SSE stream for live progress updates.
704
+ app.get('/api/autopilot/status', (c) => c.json(getAutopilotSnapshot()));
705
+ app.get('/api/autopilot/events', (c) => streamSSE(c, async (stream) => {
706
+ await stream.writeSSE({ event: 'state', data: JSON.stringify(getAutopilotSnapshot()) });
707
+ const pending = [];
708
+ let resolve = () => { };
709
+ let wait = new Promise((r) => (resolve = r));
710
+ const unsubscribe = subscribeAutopilot((snap) => {
711
+ pending.push(snap);
712
+ const prev = resolve;
713
+ wait = new Promise((r) => (resolve = r));
714
+ prev();
715
+ });
716
+ try {
717
+ // Heartbeat loop with 30s max idle to keep the stream warm.
718
+ while (true) {
719
+ if (pending.length === 0) {
720
+ const heartbeat = new Promise((r) => setTimeout(() => r('tick'), 30000));
721
+ const next = await Promise.race([wait.then(() => 'event'), heartbeat]);
722
+ if (next === 'tick') {
723
+ await stream.writeSSE({ event: 'heartbeat', data: '1' });
724
+ continue;
725
+ }
726
+ }
727
+ const snap = pending.shift();
728
+ if (!snap)
729
+ continue;
730
+ await stream.writeSSE({ event: 'state', data: JSON.stringify(snap) });
731
+ }
732
+ }
733
+ finally {
734
+ unsubscribe();
735
+ }
736
+ }));
737
+ app.post('/api/autopilot/kick', (c) => {
738
+ void kickAutopilot();
739
+ return c.json({ ok: true, snapshot: getAutopilotSnapshot() });
740
+ });
741
+ // Auto-tag scan lifecycle.
742
+ // POST /api/tags/scan — start a background scan, returns { scanId, total }
743
+ // GET /api/tags/scan/:id — snapshot of current state
744
+ // GET /api/tags/scan/:id/events — SSE stream of progress/result/status/done events
745
+ // POST /api/tags/scan/:id/apply — persist selected suggestions as tags
746
+ // DELETE /api/tags/scan/:id — cancel + forget
747
+ const StartScanSchema = z.object({
748
+ scope: z
749
+ .object({
750
+ untaggedOnly: z.boolean().optional(),
751
+ project: z.string().optional(),
752
+ collectionId: z.string().optional(),
753
+ sessionIds: z.array(z.string()).optional(),
754
+ limit: z.number().int().min(1).max(500).optional(),
755
+ })
756
+ .default({}),
757
+ });
758
+ app.post('/api/tags/scan', async (c) => {
759
+ const cfg = readAutoTagConfig();
760
+ if (!cfg.enabled)
761
+ return c.json({ error: 'auto-tagging is disabled' }, 403);
762
+ if (cfg.backend !== 'api') {
763
+ return c.json({ error: 'api-backend scan requires backend=api in config' }, 400);
764
+ }
765
+ if (!cfg.apiKey)
766
+ return c.json({ error: 'no api key configured' }, 400);
767
+ const body = await c.req.json().catch(() => ({}));
768
+ const parsed = StartScanSchema.safeParse(body);
769
+ if (!parsed.success)
770
+ return c.json({ error: 'invalid scope', issues: parsed.error.issues }, 400);
771
+ const sessions = listSessionsForScan(parsed.data.scope);
772
+ if (sessions.length === 0)
773
+ return c.json({ error: 'no sessions match scope' }, 400);
774
+ const rec = createScan(sessions.length);
775
+ // fire-and-forget; client subscribes via SSE
776
+ void runScan(rec, {
777
+ apiKey: cfg.apiKey,
778
+ model: cfg.model,
779
+ minTags: cfg.minTagsPerSession,
780
+ maxTags: cfg.maxTagsPerSession,
781
+ sessions,
782
+ });
783
+ return c.json({ scanId: rec.id, total: rec.total });
784
+ });
785
+ app.get('/api/tags/scan/:id', (c) => {
786
+ const rec = getScan(c.req.param('id'));
787
+ if (!rec)
788
+ return c.json({ error: 'scan not found' }, 404);
789
+ // Strip non-serializable fields (AbortController, listener set) before
790
+ // emitting a snapshot over the wire.
791
+ const { controller: _controller, listeners: _listeners, ...safe } = rec;
792
+ void _controller;
793
+ void _listeners;
794
+ return c.json(safe);
795
+ });
796
+ app.get('/api/tags/scan/:id/events', (c) => {
797
+ const rec = getScan(c.req.param('id'));
798
+ if (!rec)
799
+ return c.json({ error: 'scan not found' }, 404);
800
+ return streamSSE(c, async (stream) => {
801
+ // Replay current state so late subscribers see what they missed.
802
+ await stream.writeSSE({
803
+ event: 'state',
804
+ data: JSON.stringify({
805
+ completed: rec.completed,
806
+ total: rec.total,
807
+ status: rec.status,
808
+ }),
809
+ });
810
+ for (const r of rec.results) {
811
+ await stream.writeSSE({ event: 'result', data: JSON.stringify(r) });
812
+ }
813
+ // Subscribe for new events via a simple pending-queue + wake-promise.
814
+ const pending = [];
815
+ const wake = { resolve: () => { } };
816
+ let waiter = new Promise((r) => {
817
+ wake.resolve = r;
818
+ });
819
+ const unsubscribe = subscribe(rec, (ev) => {
820
+ pending.push(ev);
821
+ const prev = wake.resolve;
822
+ waiter = new Promise((r) => {
823
+ wake.resolve = r;
824
+ });
825
+ prev();
826
+ });
827
+ try {
828
+ while (rec.status === 'running' || rec.status === 'pending') {
829
+ if (pending.length === 0)
830
+ await waiter;
831
+ const ev = pending.shift();
832
+ if (!ev)
833
+ continue;
834
+ await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) });
835
+ if (ev.type === 'done')
836
+ break;
837
+ if (ev.type === 'status' && (ev.status === 'cancelled' || ev.status === 'failed'))
838
+ break;
839
+ }
840
+ }
841
+ finally {
842
+ unsubscribe();
843
+ }
844
+ });
845
+ });
846
+ const ApplySchema = z.object({
847
+ selection: z.array(z.object({
848
+ sessionId: z.string(),
849
+ tags: z.array(z.string()).min(1),
850
+ })),
851
+ });
852
+ app.post('/api/tags/scan/:id/apply', async (c) => {
853
+ const rec = getScan(c.req.param('id'));
854
+ if (!rec)
855
+ return c.json({ error: 'scan not found' }, 404);
856
+ const body = await c.req.json().catch(() => ({}));
857
+ const parsed = ApplySchema.safeParse(body);
858
+ if (!parsed.success)
859
+ return c.json({ error: 'invalid selection' }, 400);
860
+ const result = applyScanSelection(rec, parsed.data.selection);
861
+ return c.json(result);
862
+ });
863
+ app.delete('/api/tags/scan/:id', (c) => {
864
+ const id = c.req.param('id');
865
+ cancelScan(id);
866
+ deleteScan(id);
867
+ return c.json({ ok: true });
868
+ });
869
+ // Alias CRUD. Never deletes history — DELETE clears to empty with history preserved.
870
+ app.put('/api/sessions/:id/alias', async (c) => {
871
+ const id = c.req.param('id');
872
+ const body = (await c.req.json().catch(() => null));
873
+ if (!body || typeof body.alias !== 'string') {
874
+ return c.json({ error: 'alias required' }, 400);
875
+ }
876
+ try {
877
+ const row = setAlias(id, body.alias);
878
+ // User explicitly typed an alias — from now on treat it as manual
879
+ // intent. The correlator MUST NOT clobber it on rename, and the client
880
+ // MUST render it over any agent title.
881
+ terminalRegistry.unlinkSession(id);
882
+ return c.json(row);
883
+ }
884
+ catch (err) {
885
+ return c.json({ error: err.message }, 400);
886
+ }
887
+ });
888
+ app.delete('/api/sessions/:id/alias', (c) => {
889
+ const id = c.req.param('id');
890
+ clearAlias(id);
891
+ terminalRegistry.unlinkSession(id);
892
+ return c.json({ ok: true });
893
+ });
894
+ app.get('/api/sessions/:id/alias', (c) => {
895
+ const id = c.req.param('id');
896
+ return c.json({ alias: getAlias(id) });
897
+ });
898
+ // v0.14b (T5) — auto-title config.
899
+ app.get('/api/config/auto-title', (c) => c.json(readAutoTitleConfig()));
900
+ app.put('/api/config/auto-title', async (c) => {
901
+ const body = await c.req.json().catch(() => ({}));
902
+ const parsed = AutoTitleConfigSchema.partial().safeParse(body);
903
+ if (!parsed.success) {
904
+ return c.json({ error: 'invalid config', issues: parsed.error.issues }, 400);
905
+ }
906
+ return c.json(writeAutoTitleConfig(parsed.data));
907
+ });
908
+ app.get('/api/sessions/:id/auto-title', (c) => {
909
+ const id = c.req.param('id');
910
+ const row = getAutoTitle(id);
911
+ if (!row)
912
+ return c.json({ error: 'session not found' }, 404);
913
+ return c.json(row);
914
+ });
915
+ /**
916
+ * Agent-generated title. 403 until the user has flipped
917
+ * `autoTitle.agentEnabled` in config — the endpoint shells out to `claude`
918
+ * and uses their subscription, so we only fire it on explicit opt-in.
919
+ */
920
+ app.post('/api/sessions/:id/auto-title', async (c) => {
921
+ const id = c.req.param('id');
922
+ const cfg = readAutoTitleConfig();
923
+ if (!cfg.agentEnabled) {
924
+ return c.json({ error: 'autoTitle.agentEnabled is false' }, 403);
925
+ }
926
+ const db = getDb();
927
+ const exists = db.prepare('SELECT 1 FROM sessions WHERE id = ?').get(id);
928
+ if (!exists)
929
+ return c.json({ error: 'session not found' }, 404);
930
+ try {
931
+ const title = await deriveAgentTitle(id);
932
+ setAutoTitle(id, title, 'agent');
933
+ return c.json(getAutoTitle(id));
934
+ }
935
+ catch (err) {
936
+ return c.json({ error: err.message, code: 'agent-title-failed' }, 500);
937
+ }
938
+ });
939
+ // Notes CRUD — never truly delete, PUT with empty string is the "clear" action.
940
+ app.get('/api/sessions/:id/notes', (c) => {
941
+ const id = c.req.param('id');
942
+ const note = getNote(id);
943
+ if (!note) {
944
+ // 204 No Content = no row yet; client treats as "empty, not-yet-created"
945
+ return c.body(null, 204);
946
+ }
947
+ return c.json(note);
948
+ });
949
+ app.put('/api/sessions/:id/notes', async (c) => {
950
+ const id = c.req.param('id');
951
+ const body = (await c.req.json().catch(() => null));
952
+ if (!body || typeof body.content !== 'string') {
953
+ return c.json({ error: 'content required (string)' }, 400);
954
+ }
955
+ try {
956
+ const row = setNote(id, body.content);
957
+ return c.json(row);
958
+ }
959
+ catch (err) {
960
+ return c.json({ error: err.message }, 500);
961
+ }
962
+ });
963
+ /**
964
+ * ✨ Generate Note — runs claude -p over a sampled transcript and stores
965
+ * the resulting markdown synopsis in `session_notes.auto_synopsis`. Never
966
+ * overwrites the user-typed `content`. Returns the full notes row so the
967
+ * client can re-render manual + synopsis side by side.
968
+ *
969
+ * Gated behind the same agentEnabled config flag as ✨ Generate Title —
970
+ * shells out to the user's Claude subscription.
971
+ */
972
+ app.post('/api/sessions/:id/generate-note', async (c) => {
973
+ const id = c.req.param('id');
974
+ const cfg = readAutoTitleConfig();
975
+ if (!cfg.agentEnabled) {
976
+ return c.json({ error: 'autoTitle.agentEnabled is false' }, 403);
977
+ }
978
+ try {
979
+ const synopsis = await deriveAgentNote(id);
980
+ const row = setAutoSynopsis(id, synopsis);
981
+ return c.json(row);
982
+ }
983
+ catch (err) {
984
+ const msg = err.message;
985
+ // 404 for missing session/messages, 500 for CLI failure.
986
+ const status = /no messages available/i.test(msg) ? 404 : 500;
987
+ return c.json({ error: msg }, status);
988
+ }
989
+ });
990
+ // v0.11 — semantic search config + status. Off by default. Enabling sends
991
+ // condensed session summaries to Claude via the local `claude` CLI; the UI
992
+ // surfaces this disclosure before flipping the switch.
993
+ app.get('/api/semantic/status', (c) => c.json(getSemanticStatus()));
994
+ app.put('/api/semantic/config', async (c) => {
995
+ const body = await c.req.json().catch(() => ({}));
996
+ const parsed = SemanticConfigSchema.partial().safeParse(body);
997
+ if (!parsed.success) {
998
+ return c.json({ error: 'invalid semantic config', issues: parsed.error.issues }, 400);
999
+ }
1000
+ writeSemanticConfig(parsed.data);
1001
+ return c.json(getSemanticStatus());
1002
+ });
1003
+ app.get('/api/semantic/config', (c) => c.json(readSemanticConfig()));
1004
+ // Trigger a non-blocking backfill pass. Returns immediately; progress
1005
+ // can be polled via /api/semantic/status.
1006
+ app.post('/api/semantic/backfill', async (c) => {
1007
+ const cfg = readSemanticConfig();
1008
+ if (!cfg.enabled)
1009
+ return c.json({ error: 'semantic search is disabled' }, 400);
1010
+ const body = (await c.req.json().catch(() => ({})));
1011
+ const limit = Math.max(1, Math.min(5000, Number(body.limit ?? 200)));
1012
+ void semanticBackfill({ limit, force: !!body.force }).catch((err) => console.error('[semantic.backfill] error:', err));
1013
+ return c.json({ ok: true, limit });
1014
+ });
1015
+ // One-shot summarize for a single session (e.g. from the transcript header).
1016
+ app.post('/api/semantic/sessions/:id', async (c) => {
1017
+ const cfg = readSemanticConfig();
1018
+ if (!cfg.enabled)
1019
+ return c.json({ error: 'semantic search is disabled' }, 400);
1020
+ const id = c.req.param('id');
1021
+ const result = await processSemanticSession(id);
1022
+ return c.json(result);
1023
+ });
1024
+ // v0.7 — Vector tier endpoints (Pro-only)
1025
+ app.get('/api/semantic/vector-status', (c) => {
1026
+ const embedder = getEmbedderStatus();
1027
+ const worker = getWorkerStatus();
1028
+ const modelInstalled = isModelInstalled();
1029
+ return c.json({ embedder, worker, modelInstalled });
1030
+ });
1031
+ app.post('/api/semantic/install', requireProMiddleware, async (c) => {
1032
+ if (isModelInstalled())
1033
+ return c.json({ ok: true, status: 'already_installed' });
1034
+ try {
1035
+ await downloadModel();
1036
+ await loadEmbedder();
1037
+ startWorker();
1038
+ return c.json({ ok: true, status: 'installed' });
1039
+ }
1040
+ catch (err) {
1041
+ const msg = err instanceof Error ? err.message : 'unknown error';
1042
+ return c.json({ ok: false, error: msg }, 500);
1043
+ }
1044
+ });
1045
+ app.get('/api/sessions/:id/similar', requireProMiddleware, async (c) => {
1046
+ if (!getEmbedderStatus().loaded) {
1047
+ return c.json({ error: 'vector model not loaded' }, 503);
1048
+ }
1049
+ const id = c.req.param('id');
1050
+ const limit = Math.max(1, Math.min(50, Number(c.req.query('limit') ?? 10)));
1051
+ try {
1052
+ const results = await findSimilarSessions(id, limit);
1053
+ return c.json({ sessionId: id, similar: results });
1054
+ }
1055
+ catch (err) {
1056
+ const msg = err instanceof Error ? err.message : 'unknown error';
1057
+ return c.json({ error: msg }, 500);
1058
+ }
1059
+ });
1060
+ app.get('/api/search', requireProMiddleware, async (c) => {
1061
+ const db = getDb();
1062
+ const q = c.req.query('q')?.trim();
1063
+ if (!q)
1064
+ return c.json({ query: '', hits: [], tags: [] });
1065
+ // Bound the query length — pathological inputs (thousands of tokens)
1066
+ // would build huge SQL and an expensive FTS5 plan. 500 is 5× any sane
1067
+ // search.
1068
+ if (q.length > 500)
1069
+ return c.json({ error: 'query too long (max 500 chars)' }, 400);
1070
+ const project = c.req.query('project');
1071
+ // Split query tokens: anything starting with '#' becomes a tag filter,
1072
+ // everything else goes to FTS. `#auth-fix migration` = sessions tagged
1073
+ // 'auth-fix' AND message text contains 'migration'.
1074
+ const tokens = q.split(/\s+/).filter((t) => t.length > 0);
1075
+ const tagTokens = tokens.filter((t) => t.startsWith('#')).map((t) => normalizeTag(t)).filter(Boolean);
1076
+ const textTokens = tokens.filter((t) => !t.startsWith('#'));
1077
+ const terms = textTokens.map((t) => `"${t.replace(/"/g, '')}"`);
1078
+ const fts = terms.join(' ');
1079
+ const limit = Math.max(1, Math.min(200, Number(c.req.query('limit') ?? 30)));
1080
+ // Tag-only queries: no FTS at all, just list matching sessions' first
1081
+ // user message as the snippet.
1082
+ if (terms.length === 0 && tagTokens.length > 0) {
1083
+ let sql = `
1084
+ SELECT s.id AS session_id,
1085
+ s.id AS message_uuid,
1086
+ p.name AS project,
1087
+ s.started_at,
1088
+ COALESCE(s.first_user_message, '') AS snippet,
1089
+ CAST(NULL AS TEXT) AS role,
1090
+ CAST(NULL AS TEXT) AS timestamp,
1091
+ NULLIF(sa.alias, '') AS alias
1092
+ FROM sessions s
1093
+ JOIN projects p ON p.id = s.project_id
1094
+ LEFT JOIN session_aliases sa ON sa.session_id = s.id
1095
+ WHERE 1=1
1096
+ `;
1097
+ const params = { limit };
1098
+ if (project) {
1099
+ sql += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
1100
+ params.proj = `%${project}%`;
1101
+ }
1102
+ tagTokens.forEach((tag, i) => {
1103
+ sql += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
1104
+ params[`tag_${i}`] = tag;
1105
+ });
1106
+ sql += ' ORDER BY COALESCE(s.started_at, \'\') DESC LIMIT @limit';
1107
+ const rows = db.prepare(sql).all(params);
1108
+ return c.json({ query: q, hits: rows, tags: tagTokens });
1109
+ }
1110
+ let sql = `
1111
+ SELECT m.session_id AS session_id,
1112
+ m.uuid AS message_uuid,
1113
+ p.name AS project,
1114
+ s.started_at,
1115
+ snippet(messages_fts, 0, '<<', '>>', '…', 20) AS snippet,
1116
+ m.role,
1117
+ m.timestamp,
1118
+ NULLIF(sa.alias, '') AS alias
1119
+ FROM messages_fts
1120
+ JOIN messages m ON m.rowid = messages_fts.rowid
1121
+ JOIN sessions s ON s.id = m.session_id
1122
+ JOIN projects p ON p.id = s.project_id
1123
+ LEFT JOIN session_aliases sa ON sa.session_id = s.id
1124
+ WHERE messages_fts MATCH @fts
1125
+ `;
1126
+ const params = { fts, limit };
1127
+ if (project) {
1128
+ sql += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
1129
+ params.proj = `%${project}%`;
1130
+ }
1131
+ tagTokens.forEach((tag, i) => {
1132
+ sql += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
1133
+ params[`tag_${i}`] = tag;
1134
+ });
1135
+ sql += ' ORDER BY bm25(messages_fts) LIMIT @limit';
1136
+ const rows = db.prepare(sql).all(params);
1137
+ const ftsHits = rows.map((r) => ({
1138
+ ...r,
1139
+ matched_via: 'fts',
1140
+ }));
1141
+ const mode = c.req.query('mode');
1142
+ if (mode !== 'semantic') {
1143
+ return c.json({ query: q, hits: ftsHits, tags: tagTokens });
1144
+ }
1145
+ // v0.11 — semantic mode: run sessions_fts over (summary, keywords).
1146
+ let semanticRows = [];
1147
+ try {
1148
+ let semSql = `
1149
+ SELECT s.id AS session_id,
1150
+ s.id AS message_uuid,
1151
+ p.name AS project,
1152
+ s.started_at,
1153
+ COALESCE(NULLIF(ss.summary, ''), s.first_user_message, '') AS snippet,
1154
+ CAST(NULL AS TEXT) AS role,
1155
+ CAST(NULL AS TEXT) AS timestamp,
1156
+ NULLIF(sa.alias, '') AS alias,
1157
+ bm25(sessions_fts) AS rank
1158
+ FROM sessions_fts
1159
+ JOIN session_semantic ss ON ss.rowid = sessions_fts.rowid
1160
+ JOIN sessions s ON s.id = ss.session_id
1161
+ JOIN projects p ON p.id = s.project_id
1162
+ LEFT JOIN session_aliases sa ON sa.session_id = s.id
1163
+ WHERE sessions_fts MATCH @fts
1164
+ `;
1165
+ const semParams = { fts, limit };
1166
+ if (project) {
1167
+ semSql += ' AND (p.name LIKE @proj OR p.decoded_path LIKE @proj)';
1168
+ semParams.proj = `%${project}%`;
1169
+ }
1170
+ tagTokens.forEach((tag, i) => {
1171
+ semSql += ` AND s.id IN (SELECT session_id FROM session_tags WHERE tag = @tag_${i})`;
1172
+ semParams[`tag_${i}`] = tag;
1173
+ });
1174
+ semSql += ' ORDER BY rank LIMIT @limit';
1175
+ semanticRows = db.prepare(semSql).all(semParams);
1176
+ }
1177
+ catch (err) {
1178
+ console.error('[search.semantic] failed:', err);
1179
+ }
1180
+ // v0.7 — vector lane via RRF fusion when embedder is loaded.
1181
+ if (getEmbedderStatus().loaded) {
1182
+ try {
1183
+ const vectorHits = await vectorSearch(q, limit);
1184
+ const bm25Lane = ftsHits.map((h) => ({
1185
+ id: String(h.session_id),
1186
+ data: h,
1187
+ lane: 'bm25',
1188
+ }));
1189
+ const summaryLane = semanticRows.map((r) => ({
1190
+ id: String(r.session_id),
1191
+ data: r,
1192
+ lane: 'summary',
1193
+ }));
1194
+ const vectorLane = vectorHits.map((vh) => ({
1195
+ id: vh.sessionId,
1196
+ data: { session_id: vh.sessionId, snippet: vh.text, matched_via: 'vector' },
1197
+ lane: 'vector',
1198
+ }));
1199
+ const fused = fuseResults([bm25Lane, summaryLane, vectorLane]).slice(0, limit);
1200
+ const hits = fused.map((f) => ({
1201
+ ...f.data,
1202
+ session_id: f.id,
1203
+ rrf_score: f.score,
1204
+ lanes: f.lanes,
1205
+ matched_via: f.lanes.length > 1 ? 'fused' : f.lanes[0],
1206
+ }));
1207
+ return c.json({ query: q, hits, tags: tagTokens, mode: 'semantic', fusion: 'rrf' });
1208
+ }
1209
+ catch (err) {
1210
+ console.error('[search.vector] failed, falling back:', err);
1211
+ }
1212
+ }
1213
+ // Fallback: simple merge without vector lane.
1214
+ const seenSessionIds = new Set(ftsHits.map((h) => String(h.session_id)));
1215
+ const semHits = semanticRows
1216
+ .filter((r) => !seenSessionIds.has(String(r.session_id)))
1217
+ .map(({ rank: _rank, ...rest }) => ({
1218
+ ...rest,
1219
+ matched_via: 'semantic',
1220
+ }));
1221
+ const merged = [...ftsHits, ...semHits].slice(0, limit);
1222
+ return c.json({ query: q, hits: merged, tags: tagTokens, mode: 'semantic' });
1223
+ });
1224
+ // Context export — returns markdown ready to paste into a new Claude conversation.
1225
+ app.get('/api/sessions/:id/context', requireProMiddleware, (c) => {
1226
+ const db = getDb();
1227
+ const id = c.req.param('id');
1228
+ const mode = c.req.query('mode') === 'full' ? 'full' : 'condensed';
1229
+ const includeSidechain = c.req.query('subagents') === '1';
1230
+ const prelude = c.req.query('prelude') ?? null;
1231
+ const session = db
1232
+ .prepare(`SELECT s.id, p.name AS project_name, p.decoded_path,
1233
+ s.started_at, s.ended_at, s.message_count, s.git_branch
1234
+ FROM sessions s JOIN projects p ON p.id = s.project_id
1235
+ WHERE s.id = ?`)
1236
+ .get(id);
1237
+ if (!session)
1238
+ return c.json({ error: 'not found' }, 404);
1239
+ const messages = db
1240
+ .prepare(`SELECT uuid, type, role, timestamp, is_sidechain, content_text, tool_names
1241
+ FROM messages
1242
+ WHERE session_id = ?
1243
+ ORDER BY COALESCE(timestamp, ''), rowid`)
1244
+ .all(id);
1245
+ const markdown = formatSessionAsContext(session, messages, {
1246
+ mode,
1247
+ includeSidechain,
1248
+ prelude,
1249
+ });
1250
+ return c.text(markdown);
1251
+ });
1252
+ /**
1253
+ * v0.8 — Collections. User-curated, hierarchical groupings of sessions.
1254
+ * Every mutation runs in a transaction inside `src/utils/collections.ts`,
1255
+ * which also appends to the event log and rewrites the plain-text mirror
1256
+ * at ~/.recall/collections.json. The server here is a thin wrapper.
1257
+ */
1258
+ app.get('/api/collections', (c) => {
1259
+ const includeArchived = c.req.query('archived') === '1';
1260
+ return c.json({ collections: listCollections(includeArchived) });
1261
+ });
1262
+ app.get('/api/collections/:id', (c) => {
1263
+ const id = c.req.param('id');
1264
+ const col = getCollection(id);
1265
+ if (!col)
1266
+ return c.json({ error: 'not found' }, 404);
1267
+ const members = sessionsInCollection(id, /* includeDescendants */ true);
1268
+ return c.json({ collection: col, members });
1269
+ });
1270
+ app.post('/api/collections', async (c) => {
1271
+ const body = (await c.req.json().catch(() => null));
1272
+ if (!body || typeof body.name !== 'string') {
1273
+ return c.json({ error: 'name required' }, 400);
1274
+ }
1275
+ try {
1276
+ const row = createCollection({
1277
+ name: body.name,
1278
+ description: body.description ?? null,
1279
+ icon: body.icon ?? null,
1280
+ color: body.color ?? null,
1281
+ parent_id: body.parent_id ?? null,
1282
+ sort_key: body.sort_key,
1283
+ });
1284
+ return c.json(row, 201);
1285
+ }
1286
+ catch (err) {
1287
+ return c.json({ error: err.message }, 400);
1288
+ }
1289
+ });
1290
+ app.patch('/api/collections/:id', async (c) => {
1291
+ const id = c.req.param('id');
1292
+ const body = (await c.req.json().catch(() => null));
1293
+ if (!body)
1294
+ return c.json({ error: 'body required' }, 400);
1295
+ try {
1296
+ const row = patchCollection(id, body);
1297
+ return c.json(row);
1298
+ }
1299
+ catch (err) {
1300
+ return c.json({ error: err.message }, 400);
1301
+ }
1302
+ });
1303
+ app.post('/api/collections/:id/archive', (c) => {
1304
+ const id = c.req.param('id');
1305
+ try {
1306
+ const row = archiveCollection(id);
1307
+ return c.json(row);
1308
+ }
1309
+ catch (err) {
1310
+ return c.json({ error: err.message }, 404);
1311
+ }
1312
+ });
1313
+ app.post('/api/collections/:id/restore', (c) => {
1314
+ const id = c.req.param('id');
1315
+ try {
1316
+ const row = restoreCollection(id);
1317
+ return c.json(row);
1318
+ }
1319
+ catch (err) {
1320
+ return c.json({ error: err.message }, 404);
1321
+ }
1322
+ });
1323
+ app.post('/api/collections/:id/members', async (c) => {
1324
+ const id = c.req.param('id');
1325
+ const body = (await c.req.json().catch(() => null));
1326
+ if (!body || typeof body.session_id !== 'string') {
1327
+ return c.json({ error: 'session_id required' }, 400);
1328
+ }
1329
+ try {
1330
+ const result = addSessionToCollection(id, body.session_id, body.note ?? null);
1331
+ return c.json(result);
1332
+ }
1333
+ catch (err) {
1334
+ return c.json({ error: err.message }, 400);
1335
+ }
1336
+ });
1337
+ app.delete('/api/collections/:id/members/:sid', (c) => {
1338
+ const id = c.req.param('id');
1339
+ const sid = c.req.param('sid');
1340
+ try {
1341
+ const result = removeSessionFromCollection(id, sid);
1342
+ return c.json(result);
1343
+ }
1344
+ catch (err) {
1345
+ return c.json({ error: err.message }, 400);
1346
+ }
1347
+ });
1348
+ app.get('/api/sessions/:id/collections', (c) => {
1349
+ const id = c.req.param('id');
1350
+ return c.json({ collections: collectionsForSession(id) });
1351
+ });
1352
+ /**
1353
+ * v0.15 T6 — Auto-collections. Rules bind (type, pattern) pairs to
1354
+ * target collections; suggestions propose new rules for clusters the
1355
+ * daemon spots in the corpus. Every mutation is wrapped by the util
1356
+ * layer's transaction + JSON mirror, so the server is a thin shell.
1357
+ */
1358
+ const AUTO_RULE_TYPES = [
1359
+ 'cwd-prefix',
1360
+ 'project-id',
1361
+ 'tag',
1362
+ 'plan-file',
1363
+ 'git-branch-prefix',
1364
+ ];
1365
+ app.get('/api/auto-collections/rules', (c) => c.json({ rules: listAutoRules() }));
1366
+ app.post('/api/auto-collections/rules', async (c) => {
1367
+ const body = (await c.req.json().catch(() => null));
1368
+ if (!body ||
1369
+ typeof body.name !== 'string' ||
1370
+ typeof body.pattern !== 'string' ||
1371
+ !body.type ||
1372
+ !AUTO_RULE_TYPES.includes(body.type)) {
1373
+ return c.json({ error: 'name, type, pattern required (type must be a known matcher)' }, 400);
1374
+ }
1375
+ try {
1376
+ const rule = createAutoRule({
1377
+ name: body.name,
1378
+ type: body.type,
1379
+ pattern: body.pattern,
1380
+ collection_id: body.collection_id,
1381
+ parent_collection_id: body.parent_collection_id,
1382
+ priority: body.priority,
1383
+ enabled: body.enabled,
1384
+ });
1385
+ return c.json(rule, 201);
1386
+ }
1387
+ catch (err) {
1388
+ return c.json({ error: err.message }, 400);
1389
+ }
1390
+ });
1391
+ app.patch('/api/auto-collections/rules/:id', async (c) => {
1392
+ const id = c.req.param('id');
1393
+ const body = (await c.req.json().catch(() => null));
1394
+ if (!body)
1395
+ return c.json({ error: 'body required' }, 400);
1396
+ try {
1397
+ const rule = patchAutoRule(id, body);
1398
+ return c.json(rule);
1399
+ }
1400
+ catch (err) {
1401
+ return c.json({ error: err.message }, 400);
1402
+ }
1403
+ });
1404
+ app.delete('/api/auto-collections/rules/:id', (c) => {
1405
+ const id = c.req.param('id');
1406
+ try {
1407
+ const result = deleteAutoRule(id);
1408
+ return c.json(result);
1409
+ }
1410
+ catch (err) {
1411
+ return c.json({ error: err.message }, 400);
1412
+ }
1413
+ });
1414
+ app.get('/api/auto-collections/suggestions', (c) => {
1415
+ const includeDismissed = c.req.query('dismissed') === '1';
1416
+ return c.json({
1417
+ suggestions: listAutoSuggestions({ includeDismissed }),
1418
+ });
1419
+ });
1420
+ app.post('/api/auto-collections/suggestions/:id/accept', (c) => {
1421
+ const id = c.req.param('id');
1422
+ try {
1423
+ const rule = acceptAutoSuggestion(id);
1424
+ return c.json({ rule });
1425
+ }
1426
+ catch (err) {
1427
+ return c.json({ error: err.message }, 400);
1428
+ }
1429
+ });
1430
+ app.post('/api/auto-collections/suggestions/:id/dismiss', (c) => {
1431
+ const id = c.req.param('id');
1432
+ try {
1433
+ dismissAutoSuggestion(id);
1434
+ return c.json({ ok: true });
1435
+ }
1436
+ catch (err) {
1437
+ return c.json({ error: err.message }, 400);
1438
+ }
1439
+ });
1440
+ app.post('/api/auto-collections/detect', (c) => {
1441
+ // Manual re-scan — surfaced in the UI for users who don't want to wait
1442
+ // for the 6h background pass.
1443
+ const suggestions = detectAutoSuggestions();
1444
+ return c.json({ suggestions });
1445
+ });
1446
+ app.get('/api/auto-collections/suggestions/:id/preview', (c) => {
1447
+ const id = c.req.param('id');
1448
+ const limit = Math.max(1, Math.min(20, Number(c.req.query('limit')) || 3));
1449
+ const all = listAutoSuggestions({ includeDismissed: false });
1450
+ const match = all.find((s) => s.id === id);
1451
+ if (!match)
1452
+ return c.json({ error: 'suggestion not found' }, 404);
1453
+ const sessions = previewAutoSuggestion(match.type, match.pattern, limit);
1454
+ return c.json({ sessions });
1455
+ });
1456
+ app.get('/api/auto-collections/parents', (c) => {
1457
+ // Lightweight helper for the UI: which collection ids are auto-managed?
1458
+ const ids = Array.from(autoCollectionIdSet());
1459
+ return c.json({ auto_collection_ids: ids });
1460
+ });
1461
+ // v0.15 Threads — intent-grouping DAG. See src/utils/threads.ts.
1462
+ app.get('/api/threads', (c) => {
1463
+ const includeArchived = c.req.query('archived') === '1';
1464
+ return c.json({ threads: listThreads({ includeArchived }) });
1465
+ });
1466
+ app.get('/api/threads/:id', (c) => {
1467
+ const id = c.req.param('id');
1468
+ const detail = getThread(id);
1469
+ if (!detail)
1470
+ return c.json({ error: 'thread not found' }, 404);
1471
+ // Enrich each edge with alias_source from the terminal registry. The DB
1472
+ // doesn't persist this — it's derived at request time the same way as the
1473
+ // sessions list endpoint, so the graph applies the same display precedence
1474
+ // (agent ✨ title > manual alias > heuristic title > auto-alias).
1475
+ const edges = detail.edges.map((e) => ({
1476
+ ...e,
1477
+ alias_source: e.alias == null
1478
+ ? null
1479
+ : terminalRegistry.isSessionAutoLinked(e.session_id)
1480
+ ? 'auto'
1481
+ : 'manual',
1482
+ }));
1483
+ return c.json({ thread: { ...detail, edges } });
1484
+ });
1485
+ app.post('/api/threads', async (c) => {
1486
+ const body = (await c.req.json().catch(() => ({})));
1487
+ if (!body.name)
1488
+ return c.json({ error: 'name required' }, 400);
1489
+ try {
1490
+ const thread = createThread({
1491
+ name: body.name,
1492
+ summary: body.summary ?? null,
1493
+ originSessionId: body.originSessionId,
1494
+ });
1495
+ return c.json({ thread });
1496
+ }
1497
+ catch (err) {
1498
+ return c.json({ error: err.message }, 400);
1499
+ }
1500
+ });
1501
+ app.patch('/api/threads/:id', async (c) => {
1502
+ const id = c.req.param('id');
1503
+ const body = (await c.req.json().catch(() => ({})));
1504
+ try {
1505
+ if (body.name)
1506
+ renameThread(id, body.name);
1507
+ if (body.close)
1508
+ closeThread(id);
1509
+ if (body.reopen)
1510
+ reopenThread(id);
1511
+ if (body.archive)
1512
+ archiveThread(id);
1513
+ const detail = getThread(id);
1514
+ if (!detail)
1515
+ return c.json({ error: 'thread not found' }, 404);
1516
+ return c.json({ thread: detail });
1517
+ }
1518
+ catch (err) {
1519
+ return c.json({ error: err.message }, 400);
1520
+ }
1521
+ });
1522
+ app.post('/api/threads/:id/sessions', async (c) => {
1523
+ const threadId = c.req.param('id');
1524
+ const body = (await c.req.json().catch(() => ({})));
1525
+ if (!body.sessionId)
1526
+ return c.json({ error: 'sessionId required' }, 400);
1527
+ try {
1528
+ const edge = addSessionToThread({
1529
+ threadId,
1530
+ sessionId: body.sessionId,
1531
+ parentSessionId: body.parentSessionId ?? null,
1532
+ role: body.role,
1533
+ });
1534
+ return c.json({ edge });
1535
+ }
1536
+ catch (err) {
1537
+ return c.json({ error: err.message }, 400);
1538
+ }
1539
+ });
1540
+ app.delete('/api/threads/:id/sessions/:sessionId', (c) => {
1541
+ const threadId = c.req.param('id');
1542
+ const sessionId = c.req.param('sessionId');
1543
+ const result = removeSessionFromThread(threadId, sessionId);
1544
+ return c.json(result);
1545
+ });
1546
+ app.patch('/api/threads/:id/sessions/:sessionId', async (c) => {
1547
+ const threadId = c.req.param('id');
1548
+ const sessionId = c.req.param('sessionId');
1549
+ const body = (await c.req.json().catch(() => ({})));
1550
+ try {
1551
+ const edge = setParent(threadId, sessionId, body.parentSessionId ?? null);
1552
+ return c.json({ edge });
1553
+ }
1554
+ catch (err) {
1555
+ return c.json({ error: err.message }, 400);
1556
+ }
1557
+ });
1558
+ app.post('/api/threads/:id/merge', async (c) => {
1559
+ const destId = c.req.param('id');
1560
+ const body = (await c.req.json().catch(() => ({})));
1561
+ if (!body.sourceId)
1562
+ return c.json({ error: 'sourceId required' }, 400);
1563
+ try {
1564
+ const detail = mergeThreads(body.sourceId, destId);
1565
+ return c.json({ thread: detail });
1566
+ }
1567
+ catch (err) {
1568
+ return c.json({ error: err.message }, 400);
1569
+ }
1570
+ });
1571
+ app.post('/api/threads/:id/split', async (c) => {
1572
+ const threadId = c.req.param('id');
1573
+ const body = (await c.req.json().catch(() => ({})));
1574
+ if (!body.sessionIds?.length || !body.newThreadName) {
1575
+ return c.json({ error: 'sessionIds and newThreadName required' }, 400);
1576
+ }
1577
+ try {
1578
+ const detail = splitThread({
1579
+ threadId,
1580
+ sessionIds: body.sessionIds,
1581
+ newThreadName: body.newThreadName,
1582
+ });
1583
+ return c.json({ thread: detail });
1584
+ }
1585
+ catch (err) {
1586
+ return c.json({ error: err.message }, 400);
1587
+ }
1588
+ });
1589
+ app.get('/api/sessions/:id/threads', (c) => {
1590
+ const sessionId = c.req.param('id');
1591
+ return c.json({ threads: threadsForSession(sessionId) });
1592
+ });
1593
+ /**
1594
+ * v0.7 Bucket 5: bulk thread title generation.
1595
+ *
1596
+ * POST /api/threads/:id/titles/generate kicks off a detached walk and
1597
+ * returns {jobId} immediately. The client then opens an SSE stream on
1598
+ * /api/jobs/:jobId/stream to follow progress, or DELETEs the job to cancel.
1599
+ *
1600
+ * Pre-flight count: when the client asks for `?count=1` (or POST body
1601
+ * { count: true }) we return the number of sessions in the thread that
1602
+ * already have an agent-sourced title so the UI can render the
1603
+ * "skip already-titled vs regenerate all" choice. No job is started.
1604
+ */
1605
+ app.get('/api/threads/:id/titles/preflight', (c) => {
1606
+ const threadId = c.req.param('id');
1607
+ const detail = getThread(threadId);
1608
+ if (!detail)
1609
+ return c.json({ error: 'thread not found' }, 404);
1610
+ const db = getDb();
1611
+ let alreadyTitled = 0;
1612
+ for (const e of detail.edges) {
1613
+ const row = db
1614
+ .prepare(`SELECT auto_title_source FROM sessions WHERE id = ?`)
1615
+ .get(e.session_id);
1616
+ if (row?.auto_title_source === 'agent')
1617
+ alreadyTitled += 1;
1618
+ }
1619
+ return c.json({
1620
+ total: detail.edges.length,
1621
+ alreadyTitled,
1622
+ untitled: detail.edges.length - alreadyTitled,
1623
+ });
1624
+ });
1625
+ app.post('/api/threads/:id/titles/generate', async (c) => {
1626
+ const threadId = c.req.param('id');
1627
+ const body = (await c.req.json().catch(() => ({})));
1628
+ const detail = getThread(threadId);
1629
+ if (!detail)
1630
+ return c.json({ error: 'thread not found' }, 404);
1631
+ if (detail.edges.length === 0) {
1632
+ return c.json({ error: 'thread has no sessions' }, 400);
1633
+ }
1634
+ // BUCKET 6 INTEGRATION POINT: model resolution. Today we read the user's
1635
+ // auto-tag model preference if no explicit override is supplied. Bucket 6
1636
+ // will replace this with a richer engine-config lookup keyed off feature
1637
+ // (autoTitle vs autoTag vs others) so different features can route to
1638
+ // different models from a single source of truth.
1639
+ const cfg = readAutoTagConfig();
1640
+ const model = body.model ?? cfg.model;
1641
+ const jobId = startBulkTitleJob({
1642
+ threadId,
1643
+ force: body.force ?? false,
1644
+ model,
1645
+ });
1646
+ return c.json({ jobId });
1647
+ });
1648
+ app.get('/api/jobs/:jobId/stream', (c) => {
1649
+ const jobId = c.req.param('jobId');
1650
+ const snap = getJobSnapshot(jobId);
1651
+ if (!snap)
1652
+ return c.json({ error: 'job not found' }, 404);
1653
+ // Standard SSE resume: client sends Last-Event-ID to pick up where it
1654
+ // left off. Hono passes the header through; we coerce to a number so the
1655
+ // generator can skip already-emitted events.
1656
+ const lastEventId = Number(c.req.header('Last-Event-ID') ?? 0);
1657
+ return streamSSE(c, async (stream) => {
1658
+ let closed = false;
1659
+ const heartbeat = setInterval(() => {
1660
+ if (closed)
1661
+ return;
1662
+ stream.writeSSE({ event: 'heartbeat', data: '' }).catch(() => {
1663
+ closed = true;
1664
+ });
1665
+ }, 15_000);
1666
+ try {
1667
+ for await (const ev of subscribeJob(jobId, lastEventId)) {
1668
+ if (closed)
1669
+ break;
1670
+ await stream.writeSSE({
1671
+ id: String(ev.id),
1672
+ event: ev.kind,
1673
+ data: JSON.stringify(ev.data),
1674
+ });
1675
+ if (ev.kind === 'done')
1676
+ break;
1677
+ }
1678
+ }
1679
+ finally {
1680
+ closed = true;
1681
+ clearInterval(heartbeat);
1682
+ }
1683
+ });
1684
+ });
1685
+ app.get('/api/jobs/:jobId', (c) => {
1686
+ const snap = getJobSnapshot(c.req.param('jobId'));
1687
+ if (!snap)
1688
+ return c.json({ error: 'job not found' }, 404);
1689
+ return c.json(snap);
1690
+ });
1691
+ app.delete('/api/jobs/:jobId', (c) => {
1692
+ const ok = cancelJob(c.req.param('jobId'));
1693
+ if (!ok)
1694
+ return c.json({ error: 'job not found or already done' }, 404);
1695
+ return c.json({ ok: true });
1696
+ });
1697
+ /**
1698
+ * v0.7 - Terminal registry endpoints for the VS Code extension.
1699
+ *
1700
+ * The extension POSTs these when the user's VS Code terminals open, get
1701
+ * renamed, or close. We store the mapping shell_pid → tab_name in memory
1702
+ * so the correlator (see watcher.ts — landing in v0.7.1) can auto-alias
1703
+ * new Claude Code sessions with the tab name they were started in.
1704
+ *
1705
+ * All three endpoints accept small JSON bodies and return `{ ok: true,
1706
+ * count }`. They never read or write anything outside the in-memory map.
1707
+ */
1708
+ app.post('/api/terminal/opened', async (c) => {
1709
+ const body = (await c.req.json().catch(() => null));
1710
+ if (!body || typeof body.shell_pid !== 'number' || typeof body.tab_name !== 'string') {
1711
+ return c.json({ error: 'shell_pid and tab_name required' }, 400);
1712
+ }
1713
+ const entry = terminalRegistry.upsert({
1714
+ shell_pid: body.shell_pid,
1715
+ tab_name: body.tab_name,
1716
+ cwd: body.cwd ?? null,
1717
+ opened_at: body.opened_at ?? new Date().toISOString(),
1718
+ });
1719
+ return c.json({ ok: true, count: terminalRegistry.size(), entry });
1720
+ });
1721
+ app.post('/api/terminal/renamed', async (c) => {
1722
+ const body = (await c.req.json().catch(() => null));
1723
+ if (!body || typeof body.shell_pid !== 'number' || typeof body.tab_name !== 'string') {
1724
+ return c.json({ error: 'shell_pid and tab_name required' }, 400);
1725
+ }
1726
+ const entry = terminalRegistry.rename(body.shell_pid, body.tab_name);
1727
+ if (!entry)
1728
+ return c.json({ error: 'unknown shell_pid' }, 404);
1729
+ const propagated = propagateRenameToSessions(body.shell_pid, body.tab_name);
1730
+ return c.json({ ok: true, entry, propagated });
1731
+ });
1732
+ app.post('/api/terminal/closed', async (c) => {
1733
+ const body = (await c.req.json().catch(() => null));
1734
+ if (!body || typeof body.shell_pid !== 'number') {
1735
+ return c.json({ error: 'shell_pid required' }, 400);
1736
+ }
1737
+ const removed = terminalRegistry.remove(body.shell_pid);
1738
+ return c.json({ ok: true, removed, count: terminalRegistry.size() });
1739
+ });
1740
+ /**
1741
+ * Deterministic-link breadcrumb. The VS Code extension fires
1742
+ * `onDidStartTerminalShellExecution` when the user runs `claude` and POSTs
1743
+ * the (shell_pid, tab_name, cwd, started_at) tuple here. The correlator
1744
+ * drains the most recent matching cwd within ~30s of the JSONL appearing
1745
+ * on disk. This replaces the lsof + ppid-walk guesswork that previously
1746
+ * mis-linked sessions to sibling terminals' shell_pids.
1747
+ */
1748
+ app.post('/api/terminal/claude-started', async (c) => {
1749
+ const body = (await c.req.json().catch(() => null));
1750
+ if (!body || typeof body.shell_pid !== 'number') {
1751
+ return c.json({ error: 'shell_pid required' }, 400);
1752
+ }
1753
+ terminalRegistry.pushPending({
1754
+ shell_pid: body.shell_pid,
1755
+ tab_name: typeof body.tab_name === 'string' ? body.tab_name : '',
1756
+ cwd: typeof body.cwd === 'string' ? body.cwd : null,
1757
+ started_at: typeof body.started_at === 'string' ? body.started_at : new Date().toISOString(),
1758
+ });
1759
+ return c.json({ ok: true, pending: terminalRegistry.pendingSize() });
1760
+ });
1761
+ /**
1762
+ * Full-snapshot reconcile. The extension sends the complete list of open
1763
+ * terminals on a polling loop (every few seconds). The registry replaces
1764
+ * itself with this list and, for any shell_pid whose tab_name changed,
1765
+ * propagates the new name to every session that started in that shell —
1766
+ * so a rename in VS Code shows up as the session alias in Claude Recall
1767
+ * without any manual step.
1768
+ */
1769
+ app.post('/api/terminal/sync', async (c) => {
1770
+ const body = (await c.req.json().catch(() => null));
1771
+ if (!body || !Array.isArray(body.terminals)) {
1772
+ return c.json({ error: 'terminals array required' }, 400);
1773
+ }
1774
+ // Capture previous tab names so we can detect renames post-reconcile.
1775
+ const previous = new Map();
1776
+ for (const t of terminalRegistry.all())
1777
+ previous.set(t.shell_pid, t.tab_name);
1778
+ const snapshot = body.terminals
1779
+ .filter((t) => !!t && typeof t.shell_pid === 'number' && typeof t.tab_name === 'string')
1780
+ .map((t) => ({
1781
+ shell_pid: t.shell_pid,
1782
+ tab_name: t.tab_name,
1783
+ cwd: t.cwd ?? null,
1784
+ opened_at: t.opened_at ?? new Date().toISOString(),
1785
+ }));
1786
+ const diff = terminalRegistry.sync(snapshot);
1787
+ // Propagate any rename we see. The registry may reject a Claude Code
1788
+ // auto-title for storage purposes (to keep a user-set name pinned), but
1789
+ // for PROPAGATION we want to consider both: the registry's stored name
1790
+ // (good when the user typed a real name) AND the raw incoming name
1791
+ // (good when Claude generated a session title that strips to clean).
1792
+ // propagateRenameToSessions handles the strip + clean check.
1793
+ let propagatedTotal = 0;
1794
+ for (const t of snapshot) {
1795
+ const prev = previous.get(t.shell_pid);
1796
+ const stored = terminalRegistry.get(t.shell_pid)?.tab_name ?? t.tab_name;
1797
+ // Pick the candidate most likely to produce a useful alias: the stored
1798
+ // name when it's a real user-typed name, otherwise the raw incoming
1799
+ // (which propagation will strip if it's a Claude auto-title).
1800
+ const storedLooksClean = !!stored && !isGenericShellName(stored) && !looksLikeClaudeAutoTitle(stored);
1801
+ const candidate = storedLooksClean ? stored : t.tab_name;
1802
+ if (prev !== undefined && prev !== candidate) {
1803
+ propagatedTotal += propagateRenameToSessions(t.shell_pid, candidate);
1804
+ }
1805
+ }
1806
+ return c.json({
1807
+ ok: true,
1808
+ count: terminalRegistry.size(),
1809
+ diff,
1810
+ propagated: propagatedTotal,
1811
+ });
1812
+ });
1813
+ app.get('/api/terminal/registry', (c) => {
1814
+ return c.json({
1815
+ terminals: terminalRegistry.all(),
1816
+ count: terminalRegistry.size(),
1817
+ });
1818
+ });
1819
+ /**
1820
+ * Force-rerun the correlator for a single session. Unlinks any existing
1821
+ * shell-pid association, clears the auto alias, and calls tryAutoAlias
1822
+ * again so the session picks up the current registry state. Intended for
1823
+ * sessions that were aliased under an old correlator version, or when a
1824
+ * user has opened a fresh terminal AFTER the session was indexed and
1825
+ * wants the alias tied to that new terminal.
1826
+ */
1827
+ app.post('/api/sessions/:id/recorrelate', async (c) => {
1828
+ const id = c.req.param('id');
1829
+ const row = getDb()
1830
+ .prepare('SELECT file_path FROM sessions WHERE id = ?')
1831
+ .get(id);
1832
+ if (!row?.file_path)
1833
+ return c.json({ error: 'session not found' }, 404);
1834
+ terminalRegistry.unlinkSession(id);
1835
+ clearAlias(id);
1836
+ await tryAutoAlias(row.file_path);
1837
+ const newAlias = getAlias(id);
1838
+ return c.json({
1839
+ ok: true,
1840
+ alias: newAlias,
1841
+ linked_pid: terminalRegistry
1842
+ .all()
1843
+ .find((t) => terminalRegistry.sessionsFor(t.shell_pid).includes(id))
1844
+ ?.shell_pid ?? null,
1845
+ });
1846
+ });
1847
+ /**
1848
+ * v0.4.5 — Paste expand.
1849
+ * A message whose content is `[Pasted text #N +L lines] <path>` stores only
1850
+ * a placeholder. This endpoint attempts two recoveries:
1851
+ * 1. Scan the *next* ~10 messages in the same session for a Read tool
1852
+ * result that contains the path — if found, return that content.
1853
+ * 2. Fall back to reading the file from disk.
1854
+ *
1855
+ * Security: we refuse any path that doesn't appear in an actual paste
1856
+ * placeholder in an indexed message in this session. This is an
1857
+ * "allowlist via observation" — we only serve files the user themselves
1858
+ * already referenced in a prior session.
1859
+ */
1860
+ app.get('/api/paste-expand', async (c) => {
1861
+ const sessionId = c.req.query('session');
1862
+ const messageUuid = c.req.query('message');
1863
+ const path = c.req.query('path');
1864
+ if (!sessionId || !messageUuid || !path) {
1865
+ return c.json({ error: 'session, message and path are required' }, 400);
1866
+ }
1867
+ const db = getDb();
1868
+ const msg = db
1869
+ .prepare('SELECT rowid, content_text FROM messages WHERE uuid = ? AND session_id = ?')
1870
+ .get(messageUuid, sessionId);
1871
+ if (!msg)
1872
+ return c.json({ error: 'message not found in session' }, 404);
1873
+ // The placeholder pattern. Path must be literally in the content.
1874
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1875
+ const allowlistRe = new RegExp(`\\[Pasted text #\\d+ \\+\\d+ lines\\]\\s*${escaped}`);
1876
+ if (!allowlistRe.test(msg.content_text ?? '')) {
1877
+ return c.json({ error: 'path not referenced by this message' }, 403);
1878
+ }
1879
+ // 1) search next messages for a Read tool result containing this file's content
1880
+ const nearby = db
1881
+ .prepare(`SELECT content_text FROM messages
1882
+ WHERE session_id = ? AND rowid > ?
1883
+ ORDER BY rowid ASC LIMIT 10`)
1884
+ .all(sessionId, msg.rowid);
1885
+ for (const m of nearby) {
1886
+ const body = m.content_text ?? '';
1887
+ // Tool results are rendered with a "**Tool result**\n```\n…\n```" wrapper.
1888
+ if (body.includes('**Tool result**') && body.includes(path)) {
1889
+ return c.json({ source: 'tool-result', content: body });
1890
+ }
1891
+ // Some Read results are numbered-line format; also accept heuristic match.
1892
+ if (/^\s*1\t/.test(body) && body.length > 200) {
1893
+ return c.json({ source: 'tool-result', content: body });
1894
+ }
1895
+ }
1896
+ // 2) fall back to disk read, bounded at 2 MB.
1897
+ //
1898
+ // Path-traversal defense: even though the allowlist regex above ensures
1899
+ // the path appears in an indexed paste placeholder, a crafted session
1900
+ // message could contain `../../etc/passwd`. Resolve with realpath() (so
1901
+ // symlinks are followed to their real target) then require the resolved
1902
+ // path to live under $HOME. That contains the blast radius to the user's
1903
+ // own files — never /etc, /var, another user's home, etc.
1904
+ try {
1905
+ const real = await realpath(path);
1906
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
1907
+ if (!home || (!real.startsWith(home + '/') && !real.startsWith(home + '\\'))) {
1908
+ return c.json({ error: 'path outside allowed root' }, 403);
1909
+ }
1910
+ const st = await stat(real);
1911
+ const MAX = 2 * 1024 * 1024;
1912
+ if (st.size > MAX) {
1913
+ return c.json({ error: 'file too large', size: st.size, max: MAX }, 413);
1914
+ }
1915
+ const content = await readFile(real, 'utf8');
1916
+ return c.json({ source: 'disk', content });
1917
+ }
1918
+ catch (err) {
1919
+ return c.json({ source: 'missing', error: err.message });
1920
+ }
1921
+ });
1922
+ // Per-project stats — for the "project summary" card above the sessions list.
1923
+ app.get('/api/projects/:name/stats', (c) => {
1924
+ const db = getDb();
1925
+ const name = c.req.param('name');
1926
+ const summary = db
1927
+ .prepare(`SELECT
1928
+ (SELECT COUNT(*) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE p.name=? AND s.message_count > 2) AS sessions,
1929
+ (SELECT COALESCE(SUM(s.message_count), 0) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE p.name=?) AS messages,
1930
+ (SELECT MIN(s.started_at) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE p.name=? AND s.started_at IS NOT NULL) AS earliest,
1931
+ (SELECT MAX(COALESCE(s.ended_at, s.started_at)) FROM sessions s JOIN projects p ON p.id=s.project_id WHERE (s.ended_at IS NOT NULL OR s.started_at IS NOT NULL) AND p.name=?) AS latest`)
1932
+ .get(name, name, name, name);
1933
+ const branches = db
1934
+ .prepare(`SELECT DISTINCT s.git_branch FROM sessions s
1935
+ JOIN projects p ON p.id = s.project_id
1936
+ WHERE p.name = ? AND s.git_branch IS NOT NULL
1937
+ ORDER BY s.git_branch
1938
+ LIMIT 20`)
1939
+ .all(name)
1940
+ .map((r) => r.git_branch);
1941
+ return c.json({ ...summary, branches });
1942
+ });
1943
+ // --- Static UI ---
1944
+ if (HAS_BUNDLED_UI) {
1945
+ app.use('/assets/*', serveStatic({ root: DIST_WEB }));
1946
+ app.get('/favicon.svg', serveStatic({ root: DIST_WEB }));
1947
+ // The HTML itself must NEVER cache — it's what references the
1948
+ // hashed JS/CSS bundles. If the HTML caches, the browser keeps
1949
+ // asking for a stale bundle hash even after rebuilds.
1950
+ app.get('/', (c) => {
1951
+ c.header('cache-control', 'no-cache, no-store, must-revalidate');
1952
+ c.header('pragma', 'no-cache');
1953
+ c.header('expires', '0');
1954
+ return c.html(readFileSync(INDEX_HTML, 'utf8'));
1955
+ });
1956
+ app.get('*', (c) => {
1957
+ if (c.req.path.startsWith('/api/'))
1958
+ return c.notFound();
1959
+ c.header('cache-control', 'no-cache, no-store, must-revalidate');
1960
+ c.header('pragma', 'no-cache');
1961
+ c.header('expires', '0');
1962
+ return c.html(readFileSync(INDEX_HTML, 'utf8'));
1963
+ });
1964
+ }
1965
+ else {
1966
+ app.get('/', (c) => {
1967
+ const stats = readStats();
1968
+ return c.html(placeholderHtml({
1969
+ projects: stats.projects,
1970
+ sessions: stats.sessions,
1971
+ messages: stats.messages,
1972
+ port: Number(c.req.raw.headers.get('host')?.split(':')[1] ?? 0),
1973
+ version: VERSION,
1974
+ }));
1975
+ });
1976
+ }
1977
+ return app;
1978
+ }
1979
+ export async function startServer(port) {
1980
+ const app = buildApp();
1981
+ return new Promise((resolve, reject) => {
1982
+ try {
1983
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
1984
+ // Kick autopilot once on boot. No-op unless the user has already
1985
+ // enabled it + set an API key. Idempotent — kickAutopilot guards
1986
+ // against double-starts internally.
1987
+ void kickAutopilot();
1988
+ // v0.14b (T5) — one-time heuristic backfill. Cheap (SQL NULL check
1989
+ // makes repeat boots a no-op after the first fill), so we just run
1990
+ // it every startup instead of tracking a "has-run" flag.
1991
+ if (readAutoTitleConfig().heuristicEnabled) {
1992
+ try {
1993
+ const { updated } = backfillHeuristicTitles();
1994
+ if (updated > 0) {
1995
+ console.log(`[auto-title] backfilled heuristic title on ${updated} sessions`);
1996
+ }
1997
+ }
1998
+ catch (err) {
1999
+ console.error('[auto-title] backfill failed:', err);
2000
+ }
2001
+ }
2002
+ resolve(server);
2003
+ });
2004
+ }
2005
+ catch (err) {
2006
+ reject(err);
2007
+ }
2008
+ });
2009
+ }
2010
+ //# sourceMappingURL=server.js.map