spiracha 1.5.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (401) hide show
  1. package/AGENTS.md +114 -205
  2. package/README.md +94 -269
  3. package/apps/ui/AGENTS.md +17 -7
  4. package/apps/ui/README.md +52 -8
  5. package/apps/ui/components.json +21 -0
  6. package/apps/ui/package.json +65 -0
  7. package/apps/ui/src/components/antigravity-conversations-table.tsx +245 -0
  8. package/apps/ui/src/components/antigravity-keychain-panel.tsx +76 -0
  9. package/apps/ui/src/components/antigravity-workspaces-table.tsx +63 -0
  10. package/apps/ui/src/components/app-shell.tsx +125 -0
  11. package/apps/ui/src/components/breadcrumbs.tsx +71 -0
  12. package/apps/ui/src/components/claude-code-sessions-table.tsx +150 -0
  13. package/apps/ui/src/components/claude-code-workspaces-table.tsx +57 -0
  14. package/apps/ui/src/components/cursor-threads-table.tsx +152 -0
  15. package/apps/ui/src/components/cursor-workspaces-table.tsx +140 -0
  16. package/apps/ui/src/components/data-table.tsx +243 -0
  17. package/apps/ui/src/components/delete-confirm-dialog.tsx +88 -0
  18. package/apps/ui/src/components/export-dialog.tsx +174 -0
  19. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  20. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  21. package/apps/ui/src/components/json-panel.tsx +17 -0
  22. package/apps/ui/src/components/kiro-sessions-table.tsx +148 -0
  23. package/apps/ui/src/components/kiro-workspaces-table.tsx +53 -0
  24. package/apps/ui/src/components/list-search-input.tsx +18 -0
  25. package/apps/ui/src/components/loading-panel.tsx +23 -0
  26. package/apps/ui/src/components/metadata-section.tsx +31 -0
  27. package/apps/ui/src/components/metric-card.tsx +19 -0
  28. package/apps/ui/src/components/opencode-sessions-table.tsx +164 -0
  29. package/apps/ui/src/components/opencode-workspaces-table.tsx +57 -0
  30. package/apps/ui/src/components/page-header.tsx +39 -0
  31. package/apps/ui/src/components/projects-loading-state.tsx +74 -0
  32. package/apps/ui/src/components/projects-table.tsx +108 -0
  33. package/apps/ui/src/components/qoder-sessions-table.tsx +133 -0
  34. package/apps/ui/src/components/qoder-workspaces-table.tsx +57 -0
  35. package/apps/ui/src/components/recent-threads-list.tsx +52 -0
  36. package/apps/ui/src/components/reload-error-panel.tsx +20 -0
  37. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  38. package/apps/ui/src/components/text-document-panel.tsx +19 -0
  39. package/apps/ui/src/components/theme-toggle.tsx +48 -0
  40. package/apps/ui/src/components/threads-table.tsx +165 -0
  41. package/apps/ui/src/components/transcript-view.tsx +648 -0
  42. package/apps/ui/src/components/ui/alert-dialog.tsx +160 -0
  43. package/apps/ui/src/components/ui/badge.tsx +45 -0
  44. package/apps/ui/src/components/ui/button.tsx +62 -0
  45. package/apps/ui/src/components/ui/checkbox.tsx +29 -0
  46. package/apps/ui/src/components/ui/dialog.tsx +137 -0
  47. package/apps/ui/src/components/ui/dropdown-menu.tsx +217 -0
  48. package/apps/ui/src/components/ui/input.tsx +21 -0
  49. package/apps/ui/src/components/ui/scroll-area.tsx +46 -0
  50. package/apps/ui/src/components/ui/select.tsx +163 -0
  51. package/apps/ui/src/components/ui/separator.tsx +28 -0
  52. package/apps/ui/src/components/ui/sheet.tsx +107 -0
  53. package/apps/ui/src/components/ui/skeleton.tsx +7 -0
  54. package/apps/ui/src/components/ui/table.tsx +76 -0
  55. package/apps/ui/src/components/ui/tabs.tsx +71 -0
  56. package/apps/ui/src/components/ui/tooltip.tsx +44 -0
  57. package/apps/ui/src/integrations/tanstack-query/devtools.tsx +6 -0
  58. package/apps/ui/src/integrations/tanstack-query/root-provider.tsx +10 -0
  59. package/apps/ui/src/lib/antigravity-conversation-state.ts +41 -0
  60. package/apps/ui/src/lib/antigravity-queries.ts +33 -0
  61. package/apps/ui/src/lib/antigravity-server.ts +273 -0
  62. package/apps/ui/src/lib/antigravity-transcript-events.ts +343 -0
  63. package/apps/ui/src/lib/claude-code-queries.ts +34 -0
  64. package/apps/ui/src/lib/claude-code-server.ts +243 -0
  65. package/apps/ui/src/lib/claude-code-transcript-events.ts +161 -0
  66. package/apps/ui/src/lib/codex-queries.ts +82 -0
  67. package/apps/ui/src/lib/codex-server.ts +291 -0
  68. package/apps/ui/src/lib/cursor-queries.ts +37 -0
  69. package/apps/ui/src/lib/cursor-server.ts +315 -0
  70. package/apps/ui/src/lib/cursor-transcript-events.ts +229 -0
  71. package/apps/ui/src/lib/download.ts +166 -0
  72. package/apps/ui/src/lib/formatters.ts +147 -0
  73. package/apps/ui/src/lib/grok-queries.ts +22 -0
  74. package/apps/ui/src/lib/grok-server.ts +169 -0
  75. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  76. package/apps/ui/src/lib/kiro-queries.ts +22 -0
  77. package/apps/ui/src/lib/kiro-server.ts +151 -0
  78. package/apps/ui/src/lib/kiro-transcript-events.ts +113 -0
  79. package/apps/ui/src/lib/opencode-queries.ts +37 -0
  80. package/apps/ui/src/lib/opencode-server.ts +151 -0
  81. package/apps/ui/src/lib/opencode-transcript-events.ts +221 -0
  82. package/apps/ui/src/lib/package-metadata.ts +16 -0
  83. package/apps/ui/src/lib/path-utils.ts +13 -0
  84. package/apps/ui/src/lib/qoder-queries.ts +22 -0
  85. package/apps/ui/src/lib/qoder-server.ts +135 -0
  86. package/apps/ui/src/lib/qoder-transcript-events.ts +139 -0
  87. package/apps/ui/src/lib/route-search.ts +182 -0
  88. package/apps/ui/src/lib/settings-store.tsx +59 -0
  89. package/apps/ui/src/lib/source-session-export-server.ts +139 -0
  90. package/apps/ui/src/lib/text-filter.ts +22 -0
  91. package/apps/ui/src/lib/thread-id.ts +3 -0
  92. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  93. package/apps/ui/src/lib/thread-transcript-stats.ts +57 -0
  94. package/apps/ui/src/lib/utils.ts +7 -0
  95. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  96. package/apps/ui/src/routeTree.gen.ts +837 -0
  97. package/apps/ui/src/router.tsx +20 -0
  98. package/apps/ui/src/routes/$threadId.tsx +22 -0
  99. package/apps/ui/src/routes/__root.tsx +127 -0
  100. package/apps/ui/src/routes/analytics.tsx +143 -0
  101. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +504 -0
  102. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +365 -0
  103. package/apps/ui/src/routes/antigravity.index.tsx +51 -0
  104. package/apps/ui/src/routes/api.v1.conversation-query.ts +12 -0
  105. package/apps/ui/src/routes/api.v1.conversations.$source.$id.export.ts +12 -0
  106. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +16 -0
  107. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  108. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  109. package/apps/ui/src/routes/api.v1.conversations.ts +12 -0
  110. package/apps/ui/src/routes/api.v1.resolve.ts +12 -0
  111. package/apps/ui/src/routes/api.v1.sources.ts +12 -0
  112. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +468 -0
  113. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +274 -0
  114. package/apps/ui/src/routes/claude-code.index.tsx +48 -0
  115. package/apps/ui/src/routes/codex.$project.tsx +390 -0
  116. package/apps/ui/src/routes/codex.index.tsx +116 -0
  117. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +435 -0
  118. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +426 -0
  119. package/apps/ui/src/routes/cursor.index.tsx +120 -0
  120. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  121. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  122. package/apps/ui/src/routes/grok.index.tsx +48 -0
  123. package/apps/ui/src/routes/index.tsx +124 -0
  124. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +396 -0
  125. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +303 -0
  126. package/apps/ui/src/routes/kiro.index.tsx +48 -0
  127. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +420 -0
  128. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +327 -0
  129. package/apps/ui/src/routes/opencode.index.tsx +61 -0
  130. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +344 -0
  131. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +186 -0
  132. package/apps/ui/src/routes/qoder.index.tsx +48 -0
  133. package/apps/ui/src/routes/settings.tsx +91 -0
  134. package/apps/ui/src/routes/threads.$threadId.tsx +1103 -0
  135. package/apps/ui/src/styles.css +122 -0
  136. package/apps/ui/tsconfig.json +29 -0
  137. package/apps/ui/vite.config.ts +84 -0
  138. package/bin/spiracha.ts +32 -0
  139. package/package.json +54 -80
  140. package/src/client.ts +423 -0
  141. package/src/lib/antigravity-db.ts +414 -44
  142. package/src/lib/antigravity-exporter-types.ts +4 -0
  143. package/src/lib/antigravity-projects.ts +201 -0
  144. package/src/lib/claude-code-db.ts +498 -32
  145. package/src/lib/claude-code-exporter-types.ts +5 -0
  146. package/src/lib/claude-code-transcript-phase.ts +60 -1
  147. package/src/lib/claude-code-transcript.ts +8 -5
  148. package/src/lib/codex-analytics.ts +1 -1
  149. package/src/lib/codex-browser-db.ts +410 -74
  150. package/src/lib/codex-browser-export.ts +29 -41
  151. package/src/lib/codex-browser-types.ts +1 -1
  152. package/src/lib/codex-thread-cache.ts +41 -13
  153. package/src/lib/codex-thread-parser.ts +86 -35
  154. package/src/lib/{codex-exporter-types.ts → codex-thread-types.ts} +2 -30
  155. package/src/lib/codex-transcript-filter.ts +31 -0
  156. package/src/lib/{codex-exporter-transcript.ts → codex-transcript-renderer.ts} +104 -48
  157. package/src/lib/concurrency.ts +41 -0
  158. package/src/lib/conversation-api.ts +1172 -0
  159. package/src/lib/conversation-data/adapter-helpers.ts +110 -0
  160. package/src/lib/conversation-data/antigravity-adapter.ts +167 -0
  161. package/src/lib/conversation-data/claude-code-adapter.ts +206 -0
  162. package/src/lib/conversation-data/codex-adapter.ts +269 -0
  163. package/src/lib/conversation-data/cursor-adapter.ts +205 -0
  164. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  165. package/src/lib/conversation-data/index.ts +386 -0
  166. package/src/lib/conversation-data/kiro-adapter.ts +174 -0
  167. package/src/lib/conversation-data/message-selector.ts +37 -0
  168. package/src/lib/conversation-data/opencode-adapter.ts +202 -0
  169. package/src/lib/conversation-data/path-match.ts +79 -0
  170. package/src/lib/conversation-data/qoder-adapter.ts +255 -0
  171. package/src/lib/conversation-data/types.ts +159 -0
  172. package/src/lib/conversation-zip-export.ts +57 -0
  173. package/src/lib/cursor-db.ts +100 -25
  174. package/src/lib/cursor-exporter-types.ts +0 -19
  175. package/src/lib/grok-db.ts +1026 -0
  176. package/src/lib/grok-exporter-types.ts +110 -0
  177. package/src/lib/grok-transcript-phase.ts +52 -0
  178. package/src/lib/grok-transcript.ts +154 -0
  179. package/src/lib/kiro-db.ts +52 -1
  180. package/src/lib/model-label.ts +11 -3
  181. package/src/lib/opencode-db.ts +598 -55
  182. package/src/lib/opencode-transcript.ts +8 -5
  183. package/src/lib/qoder-acp-client.ts +268 -0
  184. package/src/lib/qoder-db.ts +1601 -0
  185. package/src/lib/qoder-exporter-types.ts +114 -0
  186. package/src/lib/qoder-transcript-phase.ts +41 -0
  187. package/src/lib/qoder-transcript.ts +112 -0
  188. package/src/lib/shared.ts +12 -0
  189. package/src/lib/transcript-load-limiter.ts +82 -0
  190. package/src/lib/ui-cache.ts +43 -17
  191. package/src/lib/ui-export-archive.ts +60 -0
  192. package/apps/ui/dist/client/assets/_threadId-CAIeH5mq.js +0 -1
  193. package/apps/ui/dist/client/assets/analytics-DOy1b6Sh.js +0 -1
  194. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-DqySVO2K.js +0 -7
  195. package/apps/ui/dist/client/assets/antigravity-conversations._conversationId-KAcXtNxI.js +0 -1
  196. package/apps/ui/dist/client/assets/antigravity-keychain-panel-B77qJz6f.js +0 -1
  197. package/apps/ui/dist/client/assets/antigravity._workspaceKey-B3NM6gRH.js +0 -1
  198. package/apps/ui/dist/client/assets/antigravity._workspaceKey-ByitGWki.js +0 -1
  199. package/apps/ui/dist/client/assets/antigravity.index-DP9JC2P9.js +0 -1
  200. package/apps/ui/dist/client/assets/antigravity.index-DgW25FEV.js +0 -1
  201. package/apps/ui/dist/client/assets/badge-C3i4OKs8.js +0 -1
  202. package/apps/ui/dist/client/assets/checkbox-CdHitXWQ.js +0 -1
  203. package/apps/ui/dist/client/assets/claude-code-sessions._sessionId-8pQ55ci2.js +0 -1
  204. package/apps/ui/dist/client/assets/claude-code-sessions._sessionId-DVz5UmtU.js +0 -1
  205. package/apps/ui/dist/client/assets/claude-code._workspaceKey-BBKskSum.js +0 -1
  206. package/apps/ui/dist/client/assets/claude-code._workspaceKey-OkikMUFs.js +0 -1
  207. package/apps/ui/dist/client/assets/claude-code.index-Cwu76dbb.js +0 -1
  208. package/apps/ui/dist/client/assets/claude-code.index-D6eMd08t.js +0 -1
  209. package/apps/ui/dist/client/assets/createServerFn-B-KkwmHU.js +0 -3
  210. package/apps/ui/dist/client/assets/cursor-threads._composerId-CmBFclK8.js +0 -1
  211. package/apps/ui/dist/client/assets/cursor-threads._composerId-hBWlP1D-.js +0 -1
  212. package/apps/ui/dist/client/assets/cursor._workspaceKey-BFPkIRi-.js +0 -1
  213. package/apps/ui/dist/client/assets/cursor._workspaceKey-CaAZjGmm.js +0 -1
  214. package/apps/ui/dist/client/assets/cursor.index-8y4o20kb.js +0 -2
  215. package/apps/ui/dist/client/assets/cursor.index-CrnMwDCi.js +0 -1
  216. package/apps/ui/dist/client/assets/data-table-Br78_dk3.js +0 -4
  217. package/apps/ui/dist/client/assets/delete-confirm-dialog-Bxs07u-j.js +0 -7
  218. package/apps/ui/dist/client/assets/dist-B9pyYCVU.js +0 -1
  219. package/apps/ui/dist/client/assets/dist-BrJhN2A0.js +0 -5
  220. package/apps/ui/dist/client/assets/dist-Cmd3AIfD.js +0 -1
  221. package/apps/ui/dist/client/assets/dist-DvMS2965.js +0 -1
  222. package/apps/ui/dist/client/assets/download-Bf0QJKD3.js +0 -1
  223. package/apps/ui/dist/client/assets/dropdown-menu-D5cvPaZt.js +0 -1
  224. package/apps/ui/dist/client/assets/es2015-ad6040dw.js +0 -41
  225. package/apps/ui/dist/client/assets/export-dialog-Dr8d2MqD.js +0 -1
  226. package/apps/ui/dist/client/assets/formatters-BtqyrX_c.js +0 -1
  227. package/apps/ui/dist/client/assets/index-C-STgIH4.js +0 -166
  228. package/apps/ui/dist/client/assets/jsx-runtime-DGeXAQPT.js +0 -1
  229. package/apps/ui/dist/client/assets/kiro-sessions._sessionId-CKgfJ7GR.js +0 -1
  230. package/apps/ui/dist/client/assets/kiro-sessions._sessionId-zvIJHtQF.js +0 -1
  231. package/apps/ui/dist/client/assets/kiro._workspaceKey-DhVuJuZB.js +0 -1
  232. package/apps/ui/dist/client/assets/kiro._workspaceKey-QArsjkTY.js +0 -1
  233. package/apps/ui/dist/client/assets/kiro.index-C-fr4m7i.js +0 -1
  234. package/apps/ui/dist/client/assets/kiro.index-OYgAdtZd.js +0 -1
  235. package/apps/ui/dist/client/assets/metric-card-Dplm0ZiM.js +0 -1
  236. package/apps/ui/dist/client/assets/opencode-sessions._sessionId-DLjiTst-.js +0 -1
  237. package/apps/ui/dist/client/assets/opencode-sessions._sessionId-biBwrRbt.js +0 -8
  238. package/apps/ui/dist/client/assets/opencode._workspaceKey-C7OIzMOJ.js +0 -1
  239. package/apps/ui/dist/client/assets/opencode._workspaceKey-sBakX0oI.js +0 -1
  240. package/apps/ui/dist/client/assets/opencode.index-Bl_kNs9N.js +0 -1
  241. package/apps/ui/dist/client/assets/opencode.index-CN53bVmb.js +0 -1
  242. package/apps/ui/dist/client/assets/page-header-Cwa3p6Tl.js +0 -1
  243. package/apps/ui/dist/client/assets/preload-helper-B8fFrdxC.js +0 -1
  244. package/apps/ui/dist/client/assets/projects._project-D8WloxQN.js +0 -1
  245. package/apps/ui/dist/client/assets/projects._project-DSDwH4NF.js +0 -1
  246. package/apps/ui/dist/client/assets/projects.index-5xRtuJdX.js +0 -3
  247. package/apps/ui/dist/client/assets/projects.index-DEfhWqa8.js +0 -1
  248. package/apps/ui/dist/client/assets/react-dom-DL96Jor4.js +0 -1
  249. package/apps/ui/dist/client/assets/refresh-ccw-BgKjOiA6.js +0 -1
  250. package/apps/ui/dist/client/assets/reload-error-panel-AHqu0akK.js +0 -1
  251. package/apps/ui/dist/client/assets/routes-DnzF_9CX.js +0 -1
  252. package/apps/ui/dist/client/assets/scroll-text-fM0sMOB3.js +0 -1
  253. package/apps/ui/dist/client/assets/select-CYF6pF37.js +0 -1
  254. package/apps/ui/dist/client/assets/settings-Dx70jgP_.js +0 -1
  255. package/apps/ui/dist/client/assets/sqlite-error-B6uZjUe8.js +0 -1
  256. package/apps/ui/dist/client/assets/styles-B7Zi9EFA.css +0 -1
  257. package/apps/ui/dist/client/assets/tabs-CmuhkT-c.js +0 -7
  258. package/apps/ui/dist/client/assets/text-filter-DVAZySUS.js +0 -2
  259. package/apps/ui/dist/client/assets/thread-transcript-stats-BUNz6u7F.js +0 -1
  260. package/apps/ui/dist/client/assets/threads._threadId-BB9tTsV4.js +0 -1
  261. package/apps/ui/dist/client/assets/threads._threadId-BMRSFow3.js +0 -1
  262. package/apps/ui/dist/client/assets/useMutation-BSFult2i.js +0 -1
  263. package/apps/ui/dist/client/assets/useQuery-CjINKZf1.js +0 -1
  264. package/apps/ui/dist/server/assets/_tanstack-start-manifest_v-CQ8Jjs3B.js +0 -387
  265. package/apps/ui/dist/server/assets/_threadId-B6SrBR9E.js +0 -6
  266. package/apps/ui/dist/server/assets/analytics-CYP_LT5Y.js +0 -16
  267. package/apps/ui/dist/server/assets/analytics-CtpOMVMO.js +0 -146
  268. package/apps/ui/dist/server/assets/antigravity-conversation-state-HgzS302O.js +0 -16
  269. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-BKOyL2T6.js +0 -613
  270. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-D426O-64.js +0 -11
  271. package/apps/ui/dist/server/assets/antigravity-conversations._conversationId-eS87Dx7g.js +0 -20
  272. package/apps/ui/dist/server/assets/antigravity-db-Bh8_U9uw.js +0 -580
  273. package/apps/ui/dist/server/assets/antigravity-keychain-DOiuHDwK.js +0 -126
  274. package/apps/ui/dist/server/assets/antigravity-keychain-panel-CC7FLmAK.js +0 -55
  275. package/apps/ui/dist/server/assets/antigravity-queries-CGrJO9Vr.js +0 -37
  276. package/apps/ui/dist/server/assets/antigravity-server-CHRjVFqN.js +0 -114
  277. package/apps/ui/dist/server/assets/antigravity._workspaceKey-3m_MzNFA.js +0 -11
  278. package/apps/ui/dist/server/assets/antigravity._workspaceKey-B0QeYUOh.js +0 -28
  279. package/apps/ui/dist/server/assets/antigravity._workspaceKey-C3kP-qyN.js +0 -210
  280. package/apps/ui/dist/server/assets/antigravity.index-CPCYkqCi.js +0 -104
  281. package/apps/ui/dist/server/assets/antigravity.index-DudTB3Tq.js +0 -11
  282. package/apps/ui/dist/server/assets/badge-EvdhKK_Z.js +0 -26
  283. package/apps/ui/dist/server/assets/button-CmTDnzOn.js +0 -46
  284. package/apps/ui/dist/server/assets/checkbox-C0hovF41.js +0 -19
  285. package/apps/ui/dist/server/assets/claude-code-db-Ik3VStKZ.js +0 -361
  286. package/apps/ui/dist/server/assets/claude-code-queries-Cgzi4K-e.js +0 -37
  287. package/apps/ui/dist/server/assets/claude-code-server-PfraClXS.js +0 -72
  288. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-C55oe6zj.js +0 -18
  289. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-D1RYYi3j.js +0 -473
  290. package/apps/ui/dist/server/assets/claude-code-sessions._sessionId-DSvPLEUL.js +0 -11
  291. package/apps/ui/dist/server/assets/claude-code-transcript-C-bxh3cS.js +0 -139
  292. package/apps/ui/dist/server/assets/claude-code-transcript-phase-DmUMc8VU.js +0 -10
  293. package/apps/ui/dist/server/assets/claude-code._workspaceKey-B5Xg_-tE.js +0 -28
  294. package/apps/ui/dist/server/assets/claude-code._workspaceKey-BPlzyXPa.js +0 -11
  295. package/apps/ui/dist/server/assets/claude-code._workspaceKey-DqDve2wl.js +0 -191
  296. package/apps/ui/dist/server/assets/claude-code.index-B7yYR7dD.js +0 -92
  297. package/apps/ui/dist/server/assets/claude-code.index-BUHThG1W.js +0 -11
  298. package/apps/ui/dist/server/assets/codex-queries-CtgeZ7VL.js +0 -99
  299. package/apps/ui/dist/server/assets/codex-server-CGXU4H8-.js +0 -2294
  300. package/apps/ui/dist/server/assets/concurrency-VXtYvlGh.js +0 -18
  301. package/apps/ui/dist/server/assets/createServerRpc-CF_DEwnm.js +0 -12
  302. package/apps/ui/dist/server/assets/createSsrRpc-C754NPki.js +0 -16
  303. package/apps/ui/dist/server/assets/cursor-db-CZnYy7aT.js +0 -830
  304. package/apps/ui/dist/server/assets/cursor-exporter-types-CI3goo-c.js +0 -34
  305. package/apps/ui/dist/server/assets/cursor-queries-NCIM0Nat.js +0 -67
  306. package/apps/ui/dist/server/assets/cursor-recovery-CXpO8KcD.js +0 -361
  307. package/apps/ui/dist/server/assets/cursor-server-DNu52A3K.js +0 -213
  308. package/apps/ui/dist/server/assets/cursor-threads._composerId-BB0Y_Mao.js +0 -11
  309. package/apps/ui/dist/server/assets/cursor-threads._composerId-Ckn6ItHI.js +0 -582
  310. package/apps/ui/dist/server/assets/cursor-threads._composerId-p9-ARAzc.js +0 -18
  311. package/apps/ui/dist/server/assets/cursor-transcript-B7tGnqtn.js +0 -125
  312. package/apps/ui/dist/server/assets/cursor._workspaceKey-CZuohIUL.js +0 -28
  313. package/apps/ui/dist/server/assets/cursor._workspaceKey-DPp5JSjy.js +0 -401
  314. package/apps/ui/dist/server/assets/cursor._workspaceKey-nmg3YIOQ.js +0 -11
  315. package/apps/ui/dist/server/assets/cursor.index-CcsX7DG0.js +0 -11
  316. package/apps/ui/dist/server/assets/cursor.index-DkWT-mxw.js +0 -189
  317. package/apps/ui/dist/server/assets/data-table-Cdct823O.js +0 -189
  318. package/apps/ui/dist/server/assets/delete-confirm-dialog-BcBNCxWB.js +0 -144
  319. package/apps/ui/dist/server/assets/download-DMmiy1xf.js +0 -92
  320. package/apps/ui/dist/server/assets/dropdown-menu-Dy_9t6TN.js +0 -36
  321. package/apps/ui/dist/server/assets/empty-plugin-adapters-B3lHh1La.js +0 -5
  322. package/apps/ui/dist/server/assets/export-dialog-D88ze9Gy.js +0 -240
  323. package/apps/ui/dist/server/assets/formatters-FJaGZgJk.js +0 -91
  324. package/apps/ui/dist/server/assets/kiro-db--fXrED_-.js +0 -552
  325. package/apps/ui/dist/server/assets/kiro-queries-CzNlM2ok.js +0 -37
  326. package/apps/ui/dist/server/assets/kiro-server-9WRxg2Au.js +0 -72
  327. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-CRY2c7YO.js +0 -11
  328. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-F1UlwxmR.js +0 -449
  329. package/apps/ui/dist/server/assets/kiro-sessions._sessionId-bvTulT7C.js +0 -18
  330. package/apps/ui/dist/server/assets/kiro-transcript-DoekQBaH.js +0 -112
  331. package/apps/ui/dist/server/assets/kiro-transcript-phase-CJG40nQg.js +0 -27
  332. package/apps/ui/dist/server/assets/kiro._workspaceKey-BDe9sQwo.js +0 -11
  333. package/apps/ui/dist/server/assets/kiro._workspaceKey-CJsjgNwO.js +0 -28
  334. package/apps/ui/dist/server/assets/kiro._workspaceKey-D_-4Qb7y.js +0 -193
  335. package/apps/ui/dist/server/assets/kiro.index-C__-UROk.js +0 -11
  336. package/apps/ui/dist/server/assets/kiro.index-CmTbqeYj.js +0 -93
  337. package/apps/ui/dist/server/assets/loading-panel-BGFnWseS.js +0 -27
  338. package/apps/ui/dist/server/assets/metric-card-ByEeLu0r.js +0 -23
  339. package/apps/ui/dist/server/assets/model-label-B1NWGc65.js +0 -13
  340. package/apps/ui/dist/server/assets/opencode-db-BM7KjOzc.js +0 -397
  341. package/apps/ui/dist/server/assets/opencode-queries-BeNYXnQv.js +0 -50
  342. package/apps/ui/dist/server/assets/opencode-server-BpQ_kRze.js +0 -72
  343. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-CGQ-t0vD.js +0 -518
  344. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-D8Q7in_Z.js +0 -18
  345. package/apps/ui/dist/server/assets/opencode-sessions._sessionId-DKOvi0lV.js +0 -11
  346. package/apps/ui/dist/server/assets/opencode-think-tags-CAoD-EcZ.js +0 -87
  347. package/apps/ui/dist/server/assets/opencode-transcript-BNTcGNym.js +0 -141
  348. package/apps/ui/dist/server/assets/opencode-transcript-phase-OqLkiQD0.js +0 -32
  349. package/apps/ui/dist/server/assets/opencode._workspaceKey-BdqcWHw1.js +0 -11
  350. package/apps/ui/dist/server/assets/opencode._workspaceKey-CfH96HTT.js +0 -28
  351. package/apps/ui/dist/server/assets/opencode._workspaceKey-DdbHPDrO.js +0 -207
  352. package/apps/ui/dist/server/assets/opencode.index-BbVzkJXP.js +0 -11
  353. package/apps/ui/dist/server/assets/opencode.index-Vh_Gq_1L.js +0 -93
  354. package/apps/ui/dist/server/assets/page-header-VNSaM3xd.js +0 -29
  355. package/apps/ui/dist/server/assets/path-transforms-DL2IwtYd.js +0 -31
  356. package/apps/ui/dist/server/assets/projects._project-4io5LO0E.js +0 -20
  357. package/apps/ui/dist/server/assets/projects._project-Bshqk7JA.js +0 -12
  358. package/apps/ui/dist/server/assets/projects._project-D6ZoGhJ8.js +0 -395
  359. package/apps/ui/dist/server/assets/projects.index-BLXOx5eL.js +0 -12
  360. package/apps/ui/dist/server/assets/projects.index-BkLiF2FF.js +0 -182
  361. package/apps/ui/dist/server/assets/projects.index-DesYXwfi.js +0 -14
  362. package/apps/ui/dist/server/assets/reload-error-panel-BJMxY3U1.js +0 -25
  363. package/apps/ui/dist/server/assets/route-search-ts4W9MJ9.js +0 -38
  364. package/apps/ui/dist/server/assets/router-Sac2DGk6.js +0 -518
  365. package/apps/ui/dist/server/assets/routes-Bsact1uB.js +0 -184
  366. package/apps/ui/dist/server/assets/routes-BzkJgq4W.js +0 -34
  367. package/apps/ui/dist/server/assets/select-GW76p-ld.js +0 -76
  368. package/apps/ui/dist/server/assets/settings-OayxIYQQ.js +0 -100
  369. package/apps/ui/dist/server/assets/settings-store-DpEJEQ7M.js +0 -52
  370. package/apps/ui/dist/server/assets/shared-DyhChtHf.js +0 -137
  371. package/apps/ui/dist/server/assets/source-session-export-server-DNutCawb.js +0 -38
  372. package/apps/ui/dist/server/assets/sqlite-error-LZDrnxdd.js +0 -13
  373. package/apps/ui/dist/server/assets/start-B_HFwkkk.js +0 -4
  374. package/apps/ui/dist/server/assets/tabs-DkaChNVg.js +0 -501
  375. package/apps/ui/dist/server/assets/text-filter-CGKxMCKt.js +0 -36
  376. package/apps/ui/dist/server/assets/thread-transcript-stats-Dh34mt2u.js +0 -45
  377. package/apps/ui/dist/server/assets/threads._threadId-BSSK4nkI.js +0 -26
  378. package/apps/ui/dist/server/assets/threads._threadId-BeXEc4Lo.js +0 -648
  379. package/apps/ui/dist/server/assets/threads._threadId-Ci0S4mKU.js +0 -18
  380. package/apps/ui/dist/server/assets/ui-export-files-BHLX9bhN.js +0 -83
  381. package/apps/ui/dist/server/assets/utils-C_uf36nf.js +0 -8
  382. package/apps/ui/dist/server/server.js +0 -5895
  383. package/bin/codex-chats-claude.js +0 -5
  384. package/bin/codex-chats.js +0 -5
  385. package/bin/spiracha.js +0 -5
  386. package/src/export-chats.ts +0 -120
  387. package/src/export-claude.ts +0 -36
  388. package/src/export-cursor.ts +0 -244
  389. package/src/lib/claude-exporter.ts +0 -864
  390. package/src/lib/codex-exporter-cli.ts +0 -277
  391. package/src/lib/codex-exporter-db.ts +0 -319
  392. package/src/lib/codex-exporter.ts +0 -115
  393. package/src/lib/cursor-exporter.ts +0 -266
  394. package/src/lib/interactive-cli.ts +0 -433
  395. package/src/lib/native-open.ts +0 -54
  396. package/src/mcp-server.ts +0 -137
  397. package/src/spiracha.ts +0 -116
  398. package/src/ui-cli.ts +0 -310
  399. /package/apps/ui/{dist/client → public}/icon.svg +0 -0
  400. /package/apps/ui/{dist/client → public}/manifest.json +0 -0
  401. /package/apps/ui/{dist/client → public}/robots.txt +0 -0
@@ -0,0 +1,1172 @@
1
+ import { mapWithConcurrency } from './concurrency';
2
+ import {
3
+ type ConversationDetail,
4
+ type ConversationMessageSelector,
5
+ type ConversationSource,
6
+ type DeleteConversationOptions,
7
+ type DeleteConversationsOptions,
8
+ deleteConversation,
9
+ deleteConversations,
10
+ type ExportConversationsZipOptions,
11
+ type GetConversationOptions,
12
+ getConversation,
13
+ isConversationSource,
14
+ type ListConversationsForPathOptions,
15
+ listConversationSources,
16
+ listConversationsForPath,
17
+ renderConversationMarkdown,
18
+ resolveConversationRef,
19
+ } from './conversation-data';
20
+ import { createConversationMarkdownZip } from './conversation-zip-export';
21
+
22
+ type ConversationApiDependencies = {
23
+ deleteConversation?: typeof deleteConversation;
24
+ deleteConversations?: typeof deleteConversations;
25
+ getConversation?: typeof getConversation;
26
+ listConversationSources?: typeof listConversationSources;
27
+ listConversationsForPath?: typeof listConversationsForPath;
28
+ renderConversationMarkdown?: typeof renderConversationMarkdown;
29
+ resolveConversationRef?: typeof resolveConversationRef;
30
+ };
31
+
32
+ type ApiErrorCode =
33
+ | 'conversation_not_found'
34
+ | 'internal_error'
35
+ | 'method_not_allowed'
36
+ | 'not_found'
37
+ | 'unsupported_operation'
38
+ | 'validation_error';
39
+ type ParseResult<T> = { error: Response } | { value: T };
40
+
41
+ const MAX_CURSOR_OFFSET = 1_000_000;
42
+ const BATCH_LOAD_CONCURRENCY = 4;
43
+ const MAX_ID_BATCH_SIZE = 200;
44
+ const MAX_ID_LENGTH = 2048;
45
+ const MAX_LIMIT = 200;
46
+ const MAX_PATH_LENGTH = 4096;
47
+ const MAX_TIMESTAMP_MS = 9_999_999_999_999;
48
+
49
+ const jsonResponse = (body: unknown, status = 200, headers: HeadersInit = {}) =>
50
+ Response.json(body, {
51
+ headers: {
52
+ 'Cache-Control': 'no-store',
53
+ 'X-Content-Type-Options': 'nosniff',
54
+ ...headers,
55
+ },
56
+ status,
57
+ });
58
+
59
+ const errorResponse = (
60
+ code: ApiErrorCode,
61
+ message: string,
62
+ status: number,
63
+ details?: unknown,
64
+ headers: HeadersInit = {},
65
+ ) =>
66
+ jsonResponse(
67
+ {
68
+ error: {
69
+ code,
70
+ details,
71
+ message,
72
+ },
73
+ },
74
+ status,
75
+ headers,
76
+ );
77
+
78
+ const parseBoolean = (value: string | null) => value === 'true' || value === '1';
79
+
80
+ const isMessageSelector = (value: unknown): value is ConversationMessageSelector => {
81
+ return value === 'all' || value === 'last_assistant' || value === 'last_final_answer';
82
+ };
83
+
84
+ const invalidSourceResponse = (source: unknown) =>
85
+ errorResponse('validation_error', `Unsupported conversation source: ${String(source)}`, 400, {
86
+ field: 'source',
87
+ source,
88
+ });
89
+
90
+ const invalidMessageSelectorResponse = (messageSelector: unknown) =>
91
+ errorResponse('validation_error', `Unsupported message selector: ${String(messageSelector)}`, 400, {
92
+ field: 'message_selector',
93
+ message_selector: messageSelector,
94
+ });
95
+
96
+ const parseSources = (value: string | null): ParseResult<ConversationSource[] | 'all'> => {
97
+ if (!value) {
98
+ return { value: 'all' };
99
+ }
100
+
101
+ const sources = value
102
+ .split(',')
103
+ .map((source) => source.trim())
104
+ .filter(Boolean);
105
+ const invalidSource = sources.find((source) => !isConversationSource(source));
106
+
107
+ return invalidSource ? { error: invalidSourceResponse(invalidSource) } : { value: sources as ConversationSource[] };
108
+ };
109
+
110
+ const parseMessageSelector = (
111
+ value: string | null,
112
+ fallback: ConversationMessageSelector,
113
+ ): ParseResult<ConversationMessageSelector> => {
114
+ if (!value) {
115
+ return { value: fallback };
116
+ }
117
+
118
+ return isMessageSelector(value) ? { value } : { error: invalidMessageSelectorResponse(value) };
119
+ };
120
+
121
+ const decodeCursorOffset = (cursor: string) => {
122
+ try {
123
+ const decoded = Buffer.from(cursor, 'base64url').toString('utf8');
124
+ if (!/^\d+$/u.test(decoded)) {
125
+ return null;
126
+ }
127
+
128
+ const offset = Number(decoded);
129
+ return Number.isSafeInteger(offset) && offset >= 0 && offset <= MAX_CURSOR_OFFSET ? offset : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ };
134
+
135
+ const invalidFieldResponse = (field: string, value: unknown, message: string) =>
136
+ errorResponse('validation_error', message, 400, { field, value });
137
+
138
+ const normalizeLimit = (value: number | undefined): number | undefined => {
139
+ if (value === undefined) {
140
+ return undefined;
141
+ }
142
+
143
+ return Math.min(value, MAX_LIMIT);
144
+ };
145
+
146
+ const validateCursor = (cursor: string | null | undefined): Response | null => {
147
+ if (!cursor) {
148
+ return null;
149
+ }
150
+
151
+ return decodeCursorOffset(cursor) === null
152
+ ? invalidFieldResponse('cursor', cursor, '`cursor` must be a valid pagination cursor.')
153
+ : null;
154
+ };
155
+
156
+ const validateLimit = (limit: number | undefined): Response | null => {
157
+ if (limit === undefined) {
158
+ return null;
159
+ }
160
+
161
+ return Number.isSafeInteger(limit) && limit > 0
162
+ ? null
163
+ : invalidFieldResponse('limit', limit, '`limit` must be a positive integer.');
164
+ };
165
+
166
+ const validatePathLength = (field: string, value: string): Response | null => {
167
+ return value.length <= MAX_PATH_LENGTH
168
+ ? null
169
+ : invalidFieldResponse(field, value.length, `\`${field}\` is too long.`);
170
+ };
171
+
172
+ const validateTimestamp = (field: string, value: number | undefined): Response | null => {
173
+ if (value === undefined) {
174
+ return null;
175
+ }
176
+
177
+ return Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIMESTAMP_MS
178
+ ? null
179
+ : invalidFieldResponse(field, value, `\`${field}\` must be a non-negative epoch millisecond timestamp.`);
180
+ };
181
+
182
+ const validateDeleteId = (id: string): Response | null => {
183
+ return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u.test(id) && !id.includes('..')
184
+ ? null
185
+ : invalidFieldResponse(
186
+ 'id',
187
+ id,
188
+ 'Conversation id contains characters that are not allowed for destructive requests.',
189
+ );
190
+ };
191
+
192
+ const parseListLimitParam = (value: string | null): ParseResult<number | undefined> => {
193
+ if (!value) {
194
+ return { value: undefined };
195
+ }
196
+
197
+ const parsed = Number(value);
198
+ if (!Number.isFinite(parsed)) {
199
+ return { error: invalidFieldResponse('limit', value, '`limit` must be a positive integer.') };
200
+ }
201
+
202
+ const validationError = validateLimit(parsed);
203
+ return validationError ? { error: validationError } : { value: normalizeLimit(parsed) };
204
+ };
205
+
206
+ const parseTimestampParam = (field: string, value: string | null): ParseResult<number | undefined> => {
207
+ if (!value) {
208
+ return { value: undefined };
209
+ }
210
+
211
+ const parsed = Number(value);
212
+ if (!Number.isFinite(parsed)) {
213
+ return {
214
+ error: invalidFieldResponse(
215
+ field,
216
+ value,
217
+ `\`${field}\` must be a non-negative epoch millisecond timestamp.`,
218
+ ),
219
+ };
220
+ }
221
+
222
+ const validationError = validateTimestamp(field, parsed);
223
+ return validationError ? { error: validationError } : { value: parsed };
224
+ };
225
+
226
+ const buildListOptions = (url: URL): ParseResult<ListConversationsForPathOptions> => {
227
+ const cwd = url.searchParams.get('cwd')?.trim();
228
+ if (!cwd) {
229
+ return { error: errorResponse('validation_error', '`cwd` is required.', 400, { field: 'cwd' }) };
230
+ }
231
+ const cwdLengthError = validatePathLength('cwd', cwd);
232
+ if (cwdLengthError) {
233
+ return { error: cwdLengthError };
234
+ }
235
+
236
+ const cursor = url.searchParams.get('cursor');
237
+ const cursorError = validateCursor(cursor);
238
+ if (cursorError) {
239
+ return { error: cursorError };
240
+ }
241
+
242
+ const sources = parseSources(url.searchParams.get('source'));
243
+ if ('error' in sources) {
244
+ return sources;
245
+ }
246
+
247
+ const messageSelector = parseMessageSelector(url.searchParams.get('message_selector'), 'last_final_answer');
248
+ if ('error' in messageSelector) {
249
+ return messageSelector;
250
+ }
251
+
252
+ const limit = parseListLimitParam(url.searchParams.get('limit'));
253
+ if ('error' in limit) {
254
+ return limit;
255
+ }
256
+
257
+ const updatedAfterMs = parseTimestampParam('updated_after_ms', url.searchParams.get('updated_after_ms'));
258
+ if ('error' in updatedAfterMs) {
259
+ return updatedAfterMs;
260
+ }
261
+
262
+ const updatedBeforeMs = parseTimestampParam('updated_before_ms', url.searchParams.get('updated_before_ms'));
263
+ if ('error' in updatedBeforeMs) {
264
+ return updatedBeforeMs;
265
+ }
266
+
267
+ return {
268
+ value: {
269
+ cursor,
270
+ cwd,
271
+ includeMessages: parseBoolean(url.searchParams.get('include_messages')),
272
+ limit: limit.value,
273
+ messageSelector: messageSelector.value,
274
+ sources: sources.value,
275
+ updatedAfterMs: updatedAfterMs.value,
276
+ updatedBeforeMs: updatedBeforeMs.value,
277
+ },
278
+ };
279
+ };
280
+
281
+ const normalizeMeta = (meta: { hasNext: boolean; nextCursor: string | null }) => ({
282
+ hasNext: meta.hasNext,
283
+ next_cursor: meta.nextCursor,
284
+ });
285
+
286
+ const getDeps = (dependencies: ConversationApiDependencies) => ({
287
+ deleteConversation: dependencies.deleteConversation ?? deleteConversation,
288
+ deleteConversations: dependencies.deleteConversations ?? deleteConversations,
289
+ getConversation: dependencies.getConversation ?? getConversation,
290
+ listConversationSources: dependencies.listConversationSources ?? listConversationSources,
291
+ listConversationsForPath: dependencies.listConversationsForPath ?? listConversationsForPath,
292
+ renderConversationMarkdown: dependencies.renderConversationMarkdown ?? renderConversationMarkdown,
293
+ resolveConversationRef: dependencies.resolveConversationRef ?? resolveConversationRef,
294
+ });
295
+
296
+ const handleSources = async (dependencies: ReturnType<typeof getDeps>) => {
297
+ return jsonResponse({
298
+ data: await dependencies.listConversationSources(),
299
+ });
300
+ };
301
+
302
+ const handleListConversations = async (url: URL, dependencies: ReturnType<typeof getDeps>) => {
303
+ const result = buildListOptions(url);
304
+ if ('error' in result) {
305
+ return result.error;
306
+ }
307
+
308
+ const page = await dependencies.listConversationsForPath(result.value);
309
+ return jsonResponse({
310
+ data: page.data,
311
+ meta: normalizeMeta(page.meta),
312
+ });
313
+ };
314
+
315
+ const buildGetConversationOptions = (
316
+ source: string | undefined,
317
+ id: string | undefined,
318
+ url: URL,
319
+ ): ParseResult<GetConversationOptions> => {
320
+ if (!source || !id) {
321
+ return { error: errorResponse('validation_error', 'Conversation source and id are required.', 400) };
322
+ }
323
+
324
+ if (!isConversationSource(source)) {
325
+ return { error: invalidSourceResponse(source) };
326
+ }
327
+
328
+ let decodedId: string;
329
+ try {
330
+ decodedId = decodeURIComponent(id);
331
+ } catch {
332
+ return { error: invalidFieldResponse('id', id, 'Conversation id must be URL encoded.') };
333
+ }
334
+ if (!decodedId.trim() || decodedId.length > MAX_ID_LENGTH) {
335
+ return { error: invalidFieldResponse('id', decodedId.length, 'Conversation id is invalid.') };
336
+ }
337
+
338
+ const messageSelector = parseMessageSelector(url.searchParams.get('message_selector'), 'all');
339
+ if ('error' in messageSelector) {
340
+ return messageSelector;
341
+ }
342
+
343
+ return {
344
+ value: {
345
+ id: decodedId,
346
+ messageSelector: messageSelector.value,
347
+ source,
348
+ },
349
+ };
350
+ };
351
+
352
+ const buildDeleteConversationOptions = (
353
+ source: string | undefined,
354
+ id: string | undefined,
355
+ ): ParseResult<DeleteConversationOptions> => {
356
+ if (!source || !id) {
357
+ return { error: errorResponse('validation_error', 'Conversation source and id are required.', 400) };
358
+ }
359
+
360
+ if (!isConversationSource(source)) {
361
+ return { error: invalidSourceResponse(source) };
362
+ }
363
+
364
+ let decodedId: string;
365
+ try {
366
+ decodedId = decodeURIComponent(id);
367
+ } catch {
368
+ return { error: invalidFieldResponse('id', id, 'Conversation id must be URL encoded.') };
369
+ }
370
+ if (!decodedId.trim() || decodedId.length > MAX_ID_LENGTH) {
371
+ return { error: invalidFieldResponse('id', decodedId.length, 'Conversation id is invalid.') };
372
+ }
373
+ const deleteIdError = validateDeleteId(decodedId);
374
+ if (deleteIdError) {
375
+ return { error: deleteIdError };
376
+ }
377
+
378
+ return {
379
+ value: {
380
+ id: decodedId,
381
+ source,
382
+ },
383
+ };
384
+ };
385
+
386
+ const handleGetConversation = async (
387
+ source: string | undefined,
388
+ id: string | undefined,
389
+ url: URL,
390
+ dependencies: ReturnType<typeof getDeps>,
391
+ ) => {
392
+ const result = buildGetConversationOptions(source, id, url);
393
+ if ('error' in result) {
394
+ return result.error;
395
+ }
396
+
397
+ const conversation = await dependencies.getConversation(result.value);
398
+ if (!conversation) {
399
+ return errorResponse('conversation_not_found', 'No conversation exists for that source and id.', 404, {
400
+ id: result.value.id,
401
+ source: result.value.source,
402
+ });
403
+ }
404
+
405
+ return jsonResponse({ data: conversation });
406
+ };
407
+
408
+ const handleExportConversation = async (
409
+ source: string | undefined,
410
+ id: string | undefined,
411
+ url: URL,
412
+ dependencies: ReturnType<typeof getDeps>,
413
+ ) => {
414
+ const result = buildGetConversationOptions(source, id, url);
415
+ if ('error' in result) {
416
+ return result.error;
417
+ }
418
+
419
+ const conversation = await dependencies.getConversation(result.value);
420
+ if (!conversation) {
421
+ return errorResponse('conversation_not_found', 'No conversation exists for that source and id.', 404, {
422
+ id: result.value.id,
423
+ source: result.value.source,
424
+ });
425
+ }
426
+
427
+ return new Response(
428
+ dependencies.renderConversationMarkdown(conversation, {
429
+ messageSelector: result.value.messageSelector,
430
+ }),
431
+ {
432
+ headers: {
433
+ 'Cache-Control': 'no-store',
434
+ 'Content-Type': 'text/markdown; charset=utf-8',
435
+ 'X-Content-Type-Options': 'nosniff',
436
+ },
437
+ },
438
+ );
439
+ };
440
+
441
+ const handleDeleteConversation = async (
442
+ source: string | undefined,
443
+ id: string | undefined,
444
+ dependencies: ReturnType<typeof getDeps>,
445
+ ) => {
446
+ const result = buildDeleteConversationOptions(source, id);
447
+ if ('error' in result) {
448
+ return result.error;
449
+ }
450
+
451
+ const deleteResult = await dependencies.deleteConversation(result.value);
452
+ if (!deleteResult) {
453
+ return errorResponse(
454
+ 'unsupported_operation',
455
+ `Deleting ${result.value.source} conversations is not supported by the stable API.`,
456
+ 405,
457
+ {
458
+ source: result.value.source,
459
+ },
460
+ );
461
+ }
462
+
463
+ if (deleteResult.deletedIds.length === 0) {
464
+ return errorResponse('conversation_not_found', 'No conversation exists for that source and id.', 404, {
465
+ id: result.value.id,
466
+ source: result.value.source,
467
+ });
468
+ }
469
+
470
+ return jsonResponse({ data: deleteResult });
471
+ };
472
+
473
+ const parseJsonBody = async (request: Request): Promise<ParseResult<Record<string, unknown>>> => {
474
+ let body: unknown;
475
+ try {
476
+ body = await request.json();
477
+ } catch {
478
+ return { error: errorResponse('validation_error', 'Request body must be JSON.', 400) };
479
+ }
480
+
481
+ return isRecord(body)
482
+ ? { value: body }
483
+ : { error: errorResponse('validation_error', 'Request body must be a JSON object.', 400) };
484
+ };
485
+
486
+ const parseJsonSourceOption = (body: Record<string, unknown>): ParseResult<ConversationSource> => {
487
+ const sourceOption = getStringOption(body, 'source', 'source');
488
+ if ('error' in sourceOption) {
489
+ return sourceOption;
490
+ }
491
+
492
+ const source = sourceOption.value?.trim();
493
+ if (!source) {
494
+ return { error: errorResponse('validation_error', '`source` is required.', 400, { field: 'source' }) };
495
+ }
496
+
497
+ return isConversationSource(source) ? { value: source } : { error: invalidSourceResponse(source) };
498
+ };
499
+
500
+ const parseJsonIdsOption = (
501
+ body: Record<string, unknown>,
502
+ options: { destructive: boolean },
503
+ ): ParseResult<string[]> => {
504
+ const idsValue = getOption(body, 'ids', 'ids');
505
+ if (!Array.isArray(idsValue) || idsValue.length === 0) {
506
+ return {
507
+ error: invalidFieldResponse('ids', idsValue, '`ids` must be a non-empty array of explicit ids.'),
508
+ };
509
+ }
510
+
511
+ if (idsValue.length > MAX_ID_BATCH_SIZE) {
512
+ return {
513
+ error: invalidFieldResponse(
514
+ 'ids',
515
+ idsValue.length,
516
+ `\`ids\` cannot include more than ${MAX_ID_BATCH_SIZE} ids.`,
517
+ ),
518
+ };
519
+ }
520
+
521
+ const ids: string[] = [];
522
+ const seenIds = new Set<string>();
523
+ for (const [index, value] of idsValue.entries()) {
524
+ if (typeof value !== 'string') {
525
+ return {
526
+ error: invalidFieldResponse('ids', value, `\`ids[${index}]\` must be a string.`),
527
+ };
528
+ }
529
+
530
+ const id = value.trim();
531
+ if (!id || id.length > MAX_ID_LENGTH) {
532
+ return {
533
+ error: invalidFieldResponse('ids', value, `\`ids[${index}]\` is invalid.`),
534
+ };
535
+ }
536
+
537
+ const deleteIdError = options.destructive ? validateDeleteId(id) : null;
538
+ if (deleteIdError) {
539
+ return { error: deleteIdError };
540
+ }
541
+
542
+ if (!seenIds.has(id)) {
543
+ seenIds.add(id);
544
+ ids.push(id);
545
+ }
546
+ }
547
+
548
+ return { value: ids };
549
+ };
550
+
551
+ const parseJsonExportFormat = (body: Record<string, unknown>): ParseResult<'md'> => {
552
+ const outputFormat = getStringOption(body, 'outputFormat', 'output_format');
553
+ if ('error' in outputFormat) {
554
+ return outputFormat;
555
+ }
556
+
557
+ if (outputFormat.value === undefined || outputFormat.value === 'md') {
558
+ return { value: 'md' };
559
+ }
560
+
561
+ return { error: invalidFieldResponse('output_format', outputFormat.value, '`output_format` must be "md".') };
562
+ };
563
+
564
+ const parseJsonExportMessageSelector = (body: Record<string, unknown>): ParseResult<ConversationMessageSelector> => {
565
+ const messageSelectorValue = getStringOption(body, 'messageSelector', 'message_selector');
566
+ if ('error' in messageSelectorValue) {
567
+ return messageSelectorValue;
568
+ }
569
+
570
+ return parseMessageSelector(messageSelectorValue.value ?? null, 'all');
571
+ };
572
+
573
+ const parseConversationIdSetRecord = (
574
+ body: Record<string, unknown>,
575
+ options: { destructive: boolean },
576
+ ): ParseResult<DeleteConversationsOptions> => {
577
+ const source = parseJsonSourceOption(body);
578
+ if ('error' in source) {
579
+ return source;
580
+ }
581
+
582
+ const ids = parseJsonIdsOption(body, options);
583
+ if ('error' in ids) {
584
+ return ids;
585
+ }
586
+
587
+ return {
588
+ value: {
589
+ ids: ids.value,
590
+ source: source.value,
591
+ },
592
+ };
593
+ };
594
+
595
+ const parseExportConversationsBody = async (request: Request): Promise<ParseResult<ExportConversationsZipOptions>> => {
596
+ const body = await parseJsonBody(request);
597
+ if ('error' in body) {
598
+ return body;
599
+ }
600
+
601
+ const idSet = parseConversationIdSetRecord(body.value, { destructive: false });
602
+ if ('error' in idSet) {
603
+ return idSet;
604
+ }
605
+
606
+ const outputFormat = parseJsonExportFormat(body.value);
607
+ if ('error' in outputFormat) {
608
+ return outputFormat;
609
+ }
610
+
611
+ const messageSelector = parseJsonExportMessageSelector(body.value);
612
+ if ('error' in messageSelector) {
613
+ return messageSelector;
614
+ }
615
+
616
+ return {
617
+ value: {
618
+ ids: idSet.value.ids,
619
+ messageSelector: messageSelector.value,
620
+ outputFormat: outputFormat.value,
621
+ source: idSet.value.source,
622
+ },
623
+ };
624
+ };
625
+
626
+ const handleDeleteConversations = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
627
+ const body = await parseJsonBody(request);
628
+ if ('error' in body) {
629
+ return body.error;
630
+ }
631
+
632
+ const result = parseConversationIdSetRecord(body.value, { destructive: true });
633
+ if ('error' in result) {
634
+ return result.error;
635
+ }
636
+
637
+ const deleteResult = await dependencies.deleteConversations(result.value);
638
+ if (!deleteResult) {
639
+ return errorResponse(
640
+ 'unsupported_operation',
641
+ `Deleting ${result.value.source} conversations is not supported by the stable API.`,
642
+ 405,
643
+ {
644
+ source: result.value.source,
645
+ },
646
+ );
647
+ }
648
+
649
+ if (deleteResult.deletedIds.length === 0) {
650
+ return errorResponse('conversation_not_found', 'No conversations exist for that source and id set.', 404, {
651
+ ids: result.value.ids,
652
+ source: result.value.source,
653
+ });
654
+ }
655
+
656
+ return jsonResponse({ data: deleteResult });
657
+ };
658
+
659
+ const getConversationZipEntry = (conversation: ConversationDetail, markdown: string) => ({
660
+ fallbackBaseName: `${conversation.source}-${conversation.id}`,
661
+ markdown,
662
+ title: conversation.title,
663
+ });
664
+
665
+ const handleExportConversations = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
666
+ const result = await parseExportConversationsBody(request);
667
+ if ('error' in result) {
668
+ return result.error;
669
+ }
670
+
671
+ const loaded = await mapWithConcurrency(result.value.ids, BATCH_LOAD_CONCURRENCY, async (id) => {
672
+ const conversation = await dependencies.getConversation({
673
+ id,
674
+ messageSelector: result.value.messageSelector,
675
+ source: result.value.source,
676
+ });
677
+ if (!conversation) {
678
+ return { entry: null, id };
679
+ }
680
+
681
+ return {
682
+ entry: getConversationZipEntry(
683
+ conversation,
684
+ dependencies.renderConversationMarkdown(conversation, {
685
+ messageSelector: result.value.messageSelector,
686
+ }),
687
+ ),
688
+ id,
689
+ };
690
+ });
691
+ const missingIds = loaded.filter(({ entry }) => !entry).map(({ id }) => id);
692
+
693
+ if (missingIds.length > 0) {
694
+ return errorResponse(
695
+ 'conversation_not_found',
696
+ 'Some conversations do not exist for that source and id set.',
697
+ 404,
698
+ {
699
+ ids: missingIds,
700
+ source: result.value.source,
701
+ },
702
+ );
703
+ }
704
+
705
+ const zip = await createConversationMarkdownZip({
706
+ entries: loaded.flatMap(({ entry }) => (entry ? [entry] : [])),
707
+ fileBaseName: `${result.value.source}-conversations-${result.value.ids.length}`,
708
+ });
709
+
710
+ return new Response(zip.blob, {
711
+ headers: {
712
+ 'Cache-Control': 'no-store',
713
+ 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(zip.fileName)}`,
714
+ 'Content-Type': zip.mimeType,
715
+ 'X-Content-Type-Options': 'nosniff',
716
+ },
717
+ });
718
+ };
719
+
720
+ const handleResolve = async (url: URL, dependencies: ReturnType<typeof getDeps>) => {
721
+ const ref = url.searchParams.get('ref')?.trim();
722
+ if (!ref) {
723
+ return errorResponse('validation_error', '`ref` is required.', 400, { field: 'ref' });
724
+ }
725
+
726
+ const resolved = await dependencies.resolveConversationRef(ref);
727
+ if (!resolved) {
728
+ return errorResponse('conversation_not_found', 'Unable to resolve conversation reference.', 404, { ref });
729
+ }
730
+
731
+ return jsonResponse({ data: resolved });
732
+ };
733
+
734
+ const validateSourceOption = (sources: unknown): Response | null => {
735
+ if (sources === undefined || sources === 'all') {
736
+ return null;
737
+ }
738
+
739
+ if (!Array.isArray(sources)) {
740
+ return invalidSourceResponse(sources);
741
+ }
742
+
743
+ if (sources.length === 0) {
744
+ return invalidFieldResponse('source', sources, '`source` must include at least one source.');
745
+ }
746
+
747
+ const invalidSource = sources.find((source) => !isConversationSource(source));
748
+ return invalidSource ? invalidSourceResponse(invalidSource) : null;
749
+ };
750
+
751
+ const validateMessageSelectorOption = (messageSelector: unknown): Response | null => {
752
+ if (messageSelector === undefined || isMessageSelector(messageSelector)) {
753
+ return null;
754
+ }
755
+
756
+ return invalidMessageSelectorResponse(messageSelector);
757
+ };
758
+
759
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
760
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
761
+ };
762
+
763
+ const getOption = (options: Record<string, unknown>, camelKey: string, snakeKey: string) => {
764
+ if (camelKey in options) {
765
+ return options[camelKey];
766
+ }
767
+
768
+ return snakeKey in options ? options[snakeKey] : undefined;
769
+ };
770
+
771
+ const getStringOption = (
772
+ options: Record<string, unknown>,
773
+ camelKey: string,
774
+ snakeKey: string,
775
+ ): ParseResult<string | undefined> => {
776
+ const value = getOption(options, camelKey, snakeKey);
777
+ if (value === undefined) {
778
+ return { value: undefined };
779
+ }
780
+
781
+ return typeof value === 'string'
782
+ ? { value }
783
+ : { error: invalidFieldResponse(snakeKey, value, `\`${snakeKey}\` must be a string.`) };
784
+ };
785
+
786
+ const getNumberOption = (
787
+ options: Record<string, unknown>,
788
+ camelKey: string,
789
+ snakeKey: string,
790
+ ): ParseResult<number | undefined> => {
791
+ const value = getOption(options, camelKey, snakeKey);
792
+ if (value === undefined) {
793
+ return { value: undefined };
794
+ }
795
+
796
+ return typeof value === 'number' && Number.isFinite(value)
797
+ ? { value }
798
+ : { error: invalidFieldResponse(snakeKey, value, `\`${snakeKey}\` must be a finite number.`) };
799
+ };
800
+
801
+ const getBooleanOption = (
802
+ options: Record<string, unknown>,
803
+ camelKey: string,
804
+ snakeKey: string,
805
+ ): ParseResult<boolean | undefined> => {
806
+ const value = getOption(options, camelKey, snakeKey);
807
+ if (value === undefined) {
808
+ return { value: undefined };
809
+ }
810
+
811
+ return typeof value === 'boolean'
812
+ ? { value }
813
+ : { error: invalidFieldResponse(snakeKey, value, `\`${snakeKey}\` must be a boolean.`) };
814
+ };
815
+
816
+ const parseJsonSources = (value: unknown): ParseResult<ConversationSource[] | 'all'> => {
817
+ if (value === undefined || value === 'all') {
818
+ return { value: 'all' };
819
+ }
820
+
821
+ if (typeof value === 'string') {
822
+ return parseSources(value);
823
+ }
824
+
825
+ if (!Array.isArray(value)) {
826
+ return { error: invalidSourceResponse(value) };
827
+ }
828
+
829
+ if (value.length === 0) {
830
+ return { error: invalidFieldResponse('source', value, '`source` must include at least one source.') };
831
+ }
832
+
833
+ const invalidSource = value.find((source) => !isConversationSource(source));
834
+ return invalidSource ? { error: invalidSourceResponse(invalidSource) } : { value: value as ConversationSource[] };
835
+ };
836
+
837
+ const parseJsonCwd = (body: Record<string, unknown>): ParseResult<string> => {
838
+ const cwdOption = getStringOption(body, 'cwd', 'cwd');
839
+ if ('error' in cwdOption) {
840
+ return cwdOption;
841
+ }
842
+
843
+ const cwd = cwdOption.value?.trim();
844
+ if (!cwd) {
845
+ return { error: errorResponse('validation_error', '`cwd` is required.', 400, { field: 'cwd' }) };
846
+ }
847
+
848
+ const cwdLengthError = validatePathLength('cwd', cwd);
849
+ return cwdLengthError ? { error: cwdLengthError } : { value: cwd };
850
+ };
851
+
852
+ const parseJsonCursor = (body: Record<string, unknown>): ParseResult<string | null> => {
853
+ const cursorValue = body.cursor;
854
+ const cursor = typeof cursorValue === 'string' ? cursorValue : null;
855
+ if (cursorValue !== undefined && cursorValue !== null && typeof cursorValue !== 'string') {
856
+ return { error: invalidFieldResponse('cursor', cursorValue, '`cursor` must be a string.') };
857
+ }
858
+
859
+ const cursorError = validateCursor(cursor);
860
+ return cursorError ? { error: cursorError } : { value: cursor };
861
+ };
862
+
863
+ const parseJsonMessageSelector = (body: Record<string, unknown>): ParseResult<ConversationMessageSelector> => {
864
+ const messageSelectorValue = getStringOption(body, 'messageSelector', 'message_selector');
865
+ if ('error' in messageSelectorValue) {
866
+ return messageSelectorValue;
867
+ }
868
+
869
+ return parseMessageSelector(messageSelectorValue.value ?? null, 'last_final_answer');
870
+ };
871
+
872
+ const parseJsonNumberOption = (
873
+ body: Record<string, unknown>,
874
+ camelKey: string,
875
+ snakeKey: string,
876
+ validate: (value: number | undefined) => Response | null,
877
+ ): ParseResult<number | undefined> => {
878
+ const value = getNumberOption(body, camelKey, snakeKey);
879
+ if ('error' in value) {
880
+ return value;
881
+ }
882
+
883
+ const validationError = validate(value.value);
884
+ return validationError ? { error: validationError } : value;
885
+ };
886
+
887
+ const normalizeJsonListOptions = (body: unknown): ParseResult<ListConversationsForPathOptions> => {
888
+ if (!isRecord(body)) {
889
+ return { error: errorResponse('validation_error', 'Request body must be a JSON object.', 400) };
890
+ }
891
+
892
+ const cwd = parseJsonCwd(body);
893
+ if ('error' in cwd) {
894
+ return cwd;
895
+ }
896
+
897
+ const cursor = parseJsonCursor(body);
898
+ if ('error' in cursor) {
899
+ return cursor;
900
+ }
901
+
902
+ const sources = parseJsonSources(getOption(body, 'sources', 'source'));
903
+ if ('error' in sources) {
904
+ return sources;
905
+ }
906
+
907
+ const messageSelector = parseJsonMessageSelector(body);
908
+ if ('error' in messageSelector) {
909
+ return messageSelector;
910
+ }
911
+
912
+ const limit = parseJsonNumberOption(body, 'limit', 'limit', validateLimit);
913
+ if ('error' in limit) {
914
+ return limit;
915
+ }
916
+
917
+ const updatedAfterMs = parseJsonNumberOption(body, 'updatedAfterMs', 'updated_after_ms', (value) =>
918
+ validateTimestamp('updated_after_ms', value),
919
+ );
920
+ if ('error' in updatedAfterMs) {
921
+ return updatedAfterMs;
922
+ }
923
+
924
+ const updatedBeforeMs = parseJsonNumberOption(body, 'updatedBeforeMs', 'updated_before_ms', (value) =>
925
+ validateTimestamp('updated_before_ms', value),
926
+ );
927
+ if ('error' in updatedBeforeMs) {
928
+ return updatedBeforeMs;
929
+ }
930
+
931
+ const includeMessages = getBooleanOption(body, 'includeMessages', 'include_messages');
932
+ if ('error' in includeMessages) {
933
+ return includeMessages;
934
+ }
935
+
936
+ return {
937
+ value: {
938
+ cursor: cursor.value,
939
+ cwd: cwd.value,
940
+ includeMessages: includeMessages.value,
941
+ limit: normalizeLimit(limit.value),
942
+ messageSelector: messageSelector.value,
943
+ sources: sources.value,
944
+ updatedAfterMs: updatedAfterMs.value,
945
+ updatedBeforeMs: updatedBeforeMs.value,
946
+ },
947
+ };
948
+ };
949
+
950
+ const validateListQueryOptions = (options: ListConversationsForPathOptions): Response | null => {
951
+ if (typeof options.cwd !== 'string' || !options.cwd.trim()) {
952
+ return errorResponse('validation_error', '`cwd` is required.', 400, { field: 'cwd' });
953
+ }
954
+
955
+ return (
956
+ validatePathLength('cwd', options.cwd) ??
957
+ validateCursor(options.cursor) ??
958
+ validateLimit(options.limit) ??
959
+ validateTimestamp('updated_after_ms', options.updatedAfterMs) ??
960
+ validateTimestamp('updated_before_ms', options.updatedBeforeMs) ??
961
+ validateSourceOption(options.sources) ??
962
+ validateMessageSelectorOption(options.messageSelector)
963
+ );
964
+ };
965
+
966
+ const handleConversationQuery = async (request: Request, dependencies: ReturnType<typeof getDeps>) => {
967
+ let body: unknown;
968
+ try {
969
+ body = await request.json();
970
+ } catch {
971
+ return errorResponse('validation_error', 'Request body must be JSON.', 400);
972
+ }
973
+
974
+ const normalized = normalizeJsonListOptions(body);
975
+ if ('error' in normalized) {
976
+ return normalized.error;
977
+ }
978
+
979
+ const options = normalized.value;
980
+ const validationError = validateListQueryOptions(options);
981
+ if (validationError) {
982
+ return validationError;
983
+ }
984
+
985
+ const page = await dependencies.listConversationsForPath(options);
986
+ return jsonResponse({
987
+ data: page.data,
988
+ meta: normalizeMeta(page.meta),
989
+ });
990
+ };
991
+
992
+ type ApiRouteContext = {
993
+ action: string | undefined;
994
+ dependencies: ReturnType<typeof getDeps>;
995
+ id: string | undefined;
996
+ method: string;
997
+ request: Request;
998
+ resource: string | undefined;
999
+ segments: string[];
1000
+ source: string | undefined;
1001
+ url: URL;
1002
+ };
1003
+
1004
+ type ApiRoute = {
1005
+ handle: (context: ApiRouteContext) => Promise<Response> | Response;
1006
+ matches: (context: ApiRouteContext) => boolean;
1007
+ method: string;
1008
+ resource: string;
1009
+ };
1010
+
1011
+ const API_ROUTES: ApiRoute[] = [
1012
+ {
1013
+ handle: ({ dependencies }) => handleSources(dependencies),
1014
+ matches: ({ source }) => !source,
1015
+ method: 'GET',
1016
+ resource: 'sources',
1017
+ },
1018
+ {
1019
+ handle: ({ dependencies, url }) => handleListConversations(url, dependencies),
1020
+ matches: ({ source }) => !source,
1021
+ method: 'GET',
1022
+ resource: 'conversations',
1023
+ },
1024
+ {
1025
+ handle: ({ dependencies, id, source, url }) => handleGetConversation(source, id, url, dependencies),
1026
+ matches: ({ action, id, source }) => Boolean(source && id && !action),
1027
+ method: 'GET',
1028
+ resource: 'conversations',
1029
+ },
1030
+ {
1031
+ handle: ({ dependencies, id, source, url }) => handleExportConversation(source, id, url, dependencies),
1032
+ matches: ({ action, id, source }) => Boolean(source && id && action === 'export'),
1033
+ method: 'GET',
1034
+ resource: 'conversations',
1035
+ },
1036
+ {
1037
+ handle: ({ dependencies, id, source }) => handleDeleteConversation(source, id, dependencies),
1038
+ matches: ({ action, id, source }) => Boolean(source && id && !action),
1039
+ method: 'DELETE',
1040
+ resource: 'conversations',
1041
+ },
1042
+ {
1043
+ handle: ({ dependencies, request }) => handleDeleteConversations(request, dependencies),
1044
+ matches: ({ action, source }) => !source && action === 'delete',
1045
+ method: 'POST',
1046
+ resource: 'conversations',
1047
+ },
1048
+ {
1049
+ handle: ({ dependencies, request }) => handleExportConversations(request, dependencies),
1050
+ matches: ({ action, source }) => !source && action === 'export',
1051
+ method: 'POST',
1052
+ resource: 'conversations',
1053
+ },
1054
+ {
1055
+ handle: ({ dependencies, url }) => handleResolve(url, dependencies),
1056
+ matches: ({ source }) => !source,
1057
+ method: 'GET',
1058
+ resource: 'resolve',
1059
+ },
1060
+ {
1061
+ handle: ({ dependencies, request }) => handleConversationQuery(request, dependencies),
1062
+ matches: ({ source }) => !source,
1063
+ method: 'POST',
1064
+ resource: 'conversation-query',
1065
+ },
1066
+ ];
1067
+
1068
+ const API_RESOURCES = new Set(API_ROUTES.map((route) => route.resource));
1069
+
1070
+ const findRoute = (context: ApiRouteContext) =>
1071
+ API_ROUTES.find(
1072
+ (route) => route.resource === context.resource && route.method === context.method && route.matches(context),
1073
+ ) ?? null;
1074
+
1075
+ const allowedMethodsForContext = (context: ApiRouteContext) => {
1076
+ return [
1077
+ ...new Set(
1078
+ API_ROUTES.filter((route) => route.resource === context.resource && route.matches(context)).map(
1079
+ (route) => route.method,
1080
+ ),
1081
+ ),
1082
+ ].sort();
1083
+ };
1084
+
1085
+ const parseConversationApiSegments = (segments: string[], resource: string) => {
1086
+ if (segments.length === 3) {
1087
+ return { action: undefined, id: undefined, resource, source: undefined };
1088
+ }
1089
+ if (segments.length === 4 && (segments[3] === 'delete' || segments[3] === 'export')) {
1090
+ return { action: segments[3], id: undefined, resource, source: undefined };
1091
+ }
1092
+ if (segments.length === 5) {
1093
+ return { action: undefined, id: segments[4], resource, source: segments[3] };
1094
+ }
1095
+ if (segments.length === 6 && segments[5] === 'export') {
1096
+ return { action: 'export', id: segments[4], resource, source: segments[3] };
1097
+ }
1098
+ return { action: '__invalid__', id: undefined, resource, source: undefined };
1099
+ };
1100
+
1101
+ const parseApiSegments = (segments: string[]) => {
1102
+ if (segments.length < 3 || segments[0] !== 'api' || segments[1] !== 'v1') {
1103
+ return null;
1104
+ }
1105
+
1106
+ const resource = segments[2];
1107
+ if (!resource) {
1108
+ return null;
1109
+ }
1110
+
1111
+ if (resource === 'conversations') {
1112
+ return parseConversationApiSegments(segments, resource);
1113
+ }
1114
+
1115
+ return segments.length === 3
1116
+ ? { action: undefined, id: undefined, resource, source: undefined }
1117
+ : { action: '__invalid__', id: undefined, resource, source: undefined };
1118
+ };
1119
+
1120
+ export const handleConversationApiRequest = async (
1121
+ request: Request,
1122
+ dependencies: ConversationApiDependencies = {},
1123
+ ): Promise<Response> => {
1124
+ const url = new URL(request.url);
1125
+ const segments = url.pathname.split('/').filter(Boolean);
1126
+ const parsed = parseApiSegments(segments);
1127
+
1128
+ if (!parsed) {
1129
+ return errorResponse('not_found', 'API route not found.', 404);
1130
+ }
1131
+
1132
+ if (parsed.action === '__invalid__') {
1133
+ return errorResponse('not_found', 'API route not found.', 404);
1134
+ }
1135
+
1136
+ const context = {
1137
+ action: parsed.action,
1138
+ dependencies: getDeps(dependencies),
1139
+ id: parsed.id,
1140
+ method: request.method,
1141
+ request,
1142
+ resource: parsed.resource,
1143
+ segments,
1144
+ source: parsed.source,
1145
+ url,
1146
+ };
1147
+ const route = findRoute(context);
1148
+
1149
+ if (route) {
1150
+ try {
1151
+ return await route.handle(context);
1152
+ } catch (error) {
1153
+ console.error('[spiracha:conversation-api] request_failed', {
1154
+ error: error instanceof Error ? error.message : String(error),
1155
+ method: request.method,
1156
+ pathname: url.pathname,
1157
+ });
1158
+ return errorResponse('internal_error', 'Conversation API request failed.', 500);
1159
+ }
1160
+ }
1161
+
1162
+ if (parsed.resource && API_RESOURCES.has(parsed.resource)) {
1163
+ const allowedMethods = allowedMethodsForContext(context);
1164
+ if (allowedMethods.length > 0) {
1165
+ return errorResponse('method_not_allowed', 'Method not allowed.', 405, undefined, {
1166
+ Allow: allowedMethods.join(', '),
1167
+ });
1168
+ }
1169
+ }
1170
+
1171
+ return errorResponse('not_found', 'API route not found.', 404);
1172
+ };