create-walle 0.9.21 → 0.9.23

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 (500) hide show
  1. package/README.md +27 -5
  2. package/package.json +2 -2
  3. package/template/CLAUDE.md +2 -2
  4. package/template/LICENSE +1 -1
  5. package/template/bin/ctm-dev-cleanup.js +24 -3
  6. package/template/bin/ctm-launch.sh +13 -0
  7. package/template/bin/dev.sh +156 -18
  8. package/template/bin/node-bin.sh +84 -0
  9. package/template/bin/pin-node.sh +51 -0
  10. package/template/claude-task-manager/api-prompts.js +1203 -182
  11. package/template/claude-task-manager/api-reviews.js +109 -15
  12. package/template/claude-task-manager/approval-agent.js +1360 -280
  13. package/template/claude-task-manager/bin/restart-ctm.sh +64 -23
  14. package/template/claude-task-manager/bin/storage-migration-supervisor.js +338 -0
  15. package/template/claude-task-manager/db.js +4417 -295
  16. package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
  17. package/template/claude-task-manager/docs/approval-ai-refinement.md +138 -0
  18. package/template/claude-task-manager/docs/approval-rescue-loop.md +74 -0
  19. package/template/claude-task-manager/docs/codex-operational-warning-health.md +107 -0
  20. package/template/claude-task-manager/docs/codex-resume-state-guard-design.md +17 -12
  21. package/template/claude-task-manager/docs/codex-terminal-render-controller-handoff.md +311 -0
  22. package/template/claude-task-manager/docs/coding-agent-hooks-architecture.md +418 -0
  23. package/template/claude-task-manager/docs/conversation-import-freshness.md +20 -0
  24. package/template/claude-task-manager/docs/google-workspace-auth-health.md +77 -0
  25. package/template/claude-task-manager/docs/image-paste-ux.md +13 -0
  26. package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
  27. package/template/claude-task-manager/docs/main-loop-offload-architecture.md +66 -0
  28. package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +274 -519
  29. package/template/claude-task-manager/docs/mobile-live-streaming.md +27 -5
  30. package/template/claude-task-manager/docs/mobile-remote-submission-lifecycle.md +69 -0
  31. package/template/claude-task-manager/docs/phone-access-design.md +53 -15
  32. package/template/claude-task-manager/docs/phone-passkey-identity.md +122 -0
  33. package/template/claude-task-manager/docs/phone-setup.md +3 -0
  34. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +25 -1
  35. package/template/claude-task-manager/docs/remote-desktop-access-design.md +268 -0
  36. package/template/claude-task-manager/docs/restart-lifecycle-architecture.md +95 -0
  37. package/template/claude-task-manager/docs/runtime-work-control-plane.md +53 -0
  38. package/template/claude-task-manager/docs/session-interactive-wait-surfaces.md +38 -0
  39. package/template/claude-task-manager/docs/session-needs-you-dismissal.md +84 -0
  40. package/template/claude-task-manager/docs/session-render-state-management-design.md +91 -3
  41. package/template/claude-task-manager/docs/session-standup-command-center-design.md +25 -1
  42. package/template/claude-task-manager/docs/session-title-authority.md +32 -0
  43. package/template/claude-task-manager/docs/session-workspace-binding.md +33 -0
  44. package/template/claude-task-manager/docs/skill-intent-resolution-design.md +72 -0
  45. package/template/claude-task-manager/docs/walle-mcp-supervisor-health.md +86 -0
  46. package/template/claude-task-manager/docs/walle-relay-phone-access-design.md +24 -15
  47. package/template/claude-task-manager/docs/walle-session-history-hydration.md +114 -0
  48. package/template/claude-task-manager/docs/walle-session-input-queue.md +104 -0
  49. package/template/claude-task-manager/docs/walle-session-model-catalog.md +90 -0
  50. package/template/claude-task-manager/docs/walle-session-model-preferences.md +15 -6
  51. package/template/claude-task-manager/git-utils.js +897 -27
  52. package/template/claude-task-manager/lib/agent-capabilities.js +33 -0
  53. package/template/claude-task-manager/lib/agent-cli-cache.js +37 -7
  54. package/template/claude-task-manager/lib/agent-hooks-installer.js +26 -2
  55. package/template/claude-task-manager/lib/agent-presets.js +17 -1
  56. package/template/claude-task-manager/lib/all-sessions-query.js +108 -0
  57. package/template/claude-task-manager/lib/approval-ai-refinement.js +488 -0
  58. package/template/claude-task-manager/lib/approval-self-adapt.js +168 -0
  59. package/template/claude-task-manager/lib/async-semaphore.js +44 -0
  60. package/template/claude-task-manager/lib/auth-context.js +5 -0
  61. package/template/claude-task-manager/lib/auth-rate-limit.js +47 -4
  62. package/template/claude-task-manager/lib/auth-rules.js +29 -2
  63. package/template/claude-task-manager/lib/auto-approval-verifier.js +129 -16
  64. package/template/claude-task-manager/lib/background-llm.js +144 -17
  65. package/template/claude-task-manager/lib/branch-inventory.js +212 -0
  66. package/template/claude-task-manager/lib/claude-desktop-sessions.js +15 -3
  67. package/template/claude-task-manager/lib/coalesce-sync-frames.js +151 -0
  68. package/template/claude-task-manager/lib/codex-launch-health.js +762 -0
  69. package/template/claude-task-manager/lib/codex-transcript-pager.js +51 -0
  70. package/template/claude-task-manager/lib/codex-zst.js +124 -0
  71. package/template/claude-task-manager/lib/coding-agent-models.js +233 -30
  72. package/template/claude-task-manager/lib/connection-health.js +232 -0
  73. package/template/claude-task-manager/lib/conversation-blob-parser.js +42 -0
  74. package/template/claude-task-manager/lib/conversation-tail-merge.js +89 -26
  75. package/template/claude-task-manager/lib/ctm-session-context-api.js +39 -10
  76. package/template/claude-task-manager/lib/cursor-conversation-store.js +354 -0
  77. package/template/claude-task-manager/lib/db-owner-worker-client.js +315 -0
  78. package/template/claude-task-manager/lib/document-review.js +141 -6
  79. package/template/claude-task-manager/lib/escalation-review.js +152 -0
  80. package/template/claude-task-manager/lib/graceful-shutdown.js +159 -0
  81. package/template/claude-task-manager/lib/headless-term-service.js +678 -0
  82. package/template/claude-task-manager/lib/heavy-worker-fallback.js +38 -0
  83. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +542 -0
  84. package/template/claude-task-manager/lib/jsonl-range-reader.js +112 -0
  85. package/template/claude-task-manager/lib/main-db-census.js +216 -0
  86. package/template/claude-task-manager/lib/message-pagination.js +106 -4
  87. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +750 -26
  88. package/template/claude-task-manager/lib/mobile-auth-api.js +274 -7
  89. package/template/claude-task-manager/lib/mobile-auth-store.js +592 -10
  90. package/template/claude-task-manager/lib/mobile-notification-dispatcher.js +15 -0
  91. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +311 -0
  92. package/template/claude-task-manager/lib/model-overview-cache.js +141 -0
  93. package/template/claude-task-manager/lib/models-health-routing-notice.js +126 -0
  94. package/template/claude-task-manager/lib/node-pin-guard.js +93 -0
  95. package/template/claude-task-manager/lib/perf-tracker.js +242 -6
  96. package/template/claude-task-manager/lib/permission-match.js +76 -0
  97. package/template/claude-task-manager/lib/permission-sync.js +133 -20
  98. package/template/claude-task-manager/lib/process-title.js +35 -0
  99. package/template/claude-task-manager/lib/prompt-executions-query.js +25 -0
  100. package/template/claude-task-manager/lib/prompt-index-disk-cache.js +44 -0
  101. package/template/claude-task-manager/lib/prompt-intent.js +132 -0
  102. package/template/claude-task-manager/lib/provider-user-context.js +34 -0
  103. package/template/claude-task-manager/lib/read-pool-client.js +313 -0
  104. package/template/claude-task-manager/lib/readpool-breaker.js +31 -0
  105. package/template/claude-task-manager/lib/recent-sessions-breaker.js +12 -0
  106. package/template/claude-task-manager/lib/remote-feedback-client.js +72 -0
  107. package/template/claude-task-manager/lib/remote-relay-protocol.js +37 -4
  108. package/template/claude-task-manager/lib/remote-relay-store.js +159 -0
  109. package/template/claude-task-manager/lib/remote-submission-observer.js +278 -0
  110. package/template/claude-task-manager/lib/restart-guard.js +109 -0
  111. package/template/claude-task-manager/lib/restore-interruption-detector.js +439 -0
  112. package/template/claude-task-manager/lib/restore-policy.js +13 -0
  113. package/template/claude-task-manager/lib/restore-resume-batch.js +74 -0
  114. package/template/claude-task-manager/lib/restore-runtime.js +68 -0
  115. package/template/claude-task-manager/lib/restore-storm.js +34 -0
  116. package/template/claude-task-manager/lib/resume-cwd.js +36 -0
  117. package/template/claude-task-manager/lib/resume-preflight.js +313 -0
  118. package/template/claude-task-manager/lib/runtime-work-registry.js +444 -0
  119. package/template/claude-task-manager/lib/sanitize-openai-auth.js +31 -0
  120. package/template/claude-task-manager/lib/scheduler.js +21 -1
  121. package/template/claude-task-manager/lib/scrollback-snapshot-store.js +159 -0
  122. package/template/claude-task-manager/lib/serial-task-queue.js +64 -0
  123. package/template/claude-task-manager/lib/server-listeners.js +239 -0
  124. package/template/claude-task-manager/lib/session-capture.js +42 -7
  125. package/template/claude-task-manager/lib/session-content-backfill.js +131 -0
  126. package/template/claude-task-manager/lib/session-history.js +388 -43
  127. package/template/claude-task-manager/lib/session-host-manager.js +287 -0
  128. package/template/claude-task-manager/lib/session-image-refs.js +209 -0
  129. package/template/claude-task-manager/lib/session-jobs.js +399 -59
  130. package/template/claude-task-manager/lib/session-prompt-index.js +137 -0
  131. package/template/claude-task-manager/lib/session-restore.js +53 -0
  132. package/template/claude-task-manager/lib/session-standup.js +123 -23
  133. package/template/claude-task-manager/lib/session-state-bus.js +14 -0
  134. package/template/claude-task-manager/lib/session-stream.js +64 -16
  135. package/template/claude-task-manager/lib/session-timeline-summary.js +260 -0
  136. package/template/claude-task-manager/lib/session-token-usage.js +494 -0
  137. package/template/claude-task-manager/lib/session-workspace-binding.js +356 -0
  138. package/template/claude-task-manager/lib/setup-network-config.js +9 -0
  139. package/template/claude-task-manager/lib/size-cap.js +45 -0
  140. package/template/claude-task-manager/lib/size-cap.test.js +62 -0
  141. package/template/claude-task-manager/lib/skill-autocomplete.js +180 -1
  142. package/template/claude-task-manager/lib/skill-intent-resolver.js +304 -0
  143. package/template/claude-task-manager/lib/sqlite-driver.js +19 -3
  144. package/template/claude-task-manager/lib/standup-attention.js +7 -3
  145. package/template/claude-task-manager/lib/status-authority.js +39 -0
  146. package/template/claude-task-manager/lib/status-hooks.js +4 -0
  147. package/template/claude-task-manager/lib/storage-migration.js +235 -0
  148. package/template/claude-task-manager/lib/structured-capture.js +298 -0
  149. package/template/claude-task-manager/lib/sync-io-census.js +163 -0
  150. package/template/claude-task-manager/lib/tailscale-setup.js +6 -0
  151. package/template/claude-task-manager/lib/terminal-activity-evidence.js +33 -0
  152. package/template/claude-task-manager/lib/terminal-choice.js +364 -0
  153. package/template/claude-task-manager/lib/terminal-control-sanitize.js +17 -0
  154. package/template/claude-task-manager/lib/terminal-fingerprint.js +48 -0
  155. package/template/claude-task-manager/lib/terminal-output-flush.js +84 -0
  156. package/template/claude-task-manager/lib/timeline-order.js +122 -0
  157. package/template/claude-task-manager/lib/transcript-store.js +348 -43
  158. package/template/claude-task-manager/lib/transport-security.js +84 -1
  159. package/template/claude-task-manager/lib/wait-state.js +184 -0
  160. package/template/claude-task-manager/lib/walle-client.js +47 -5
  161. package/template/claude-task-manager/lib/walle-ctm-history.js +564 -4
  162. package/template/claude-task-manager/lib/walle-external-actions.js +135 -16
  163. package/template/claude-task-manager/lib/walle-history-hydration.js +46 -0
  164. package/template/claude-task-manager/lib/walle-native-health.js +403 -0
  165. package/template/claude-task-manager/lib/walle-repair.js +701 -0
  166. package/template/claude-task-manager/lib/walle-session-cache.js +109 -0
  167. package/template/claude-task-manager/lib/walle-session-context.js +57 -21
  168. package/template/claude-task-manager/lib/walle-session-model-catalog.js +34 -0
  169. package/template/claude-task-manager/lib/walle-supervisor.js +539 -63
  170. package/template/claude-task-manager/lib/walle-transcript.js +52 -0
  171. package/template/claude-task-manager/lib/worktree-active-sync.js +11 -7
  172. package/template/claude-task-manager/lib/worktree-cwd.js +32 -1
  173. package/template/claude-task-manager/package.json +1 -1
  174. package/template/claude-task-manager/prompt-harvest.js +89 -66
  175. package/template/claude-task-manager/providers/claude-code.js +51 -3
  176. package/template/claude-task-manager/providers/cursor.js +140 -45
  177. package/template/claude-task-manager/public/css/reviews.css +551 -61
  178. package/template/claude-task-manager/public/css/setup.css +191 -0
  179. package/template/claude-task-manager/public/css/walle-session.css +865 -10
  180. package/template/claude-task-manager/public/css/walle.css +154 -0
  181. package/template/claude-task-manager/public/designs/ai-providers-consolidation-v2.html +830 -0
  182. package/template/claude-task-manager/public/index.html +18516 -2058
  183. package/template/claude-task-manager/public/ipad.html +363 -0
  184. package/template/claude-task-manager/public/js/document-review-links.js +301 -0
  185. package/template/claude-task-manager/public/js/image-normalize.js +69 -36
  186. package/template/claude-task-manager/public/js/message-renderer.js +1265 -77
  187. package/template/claude-task-manager/public/js/prompts.js +66 -29
  188. package/template/claude-task-manager/public/js/reviews.js +901 -133
  189. package/template/claude-task-manager/public/js/session-activity-utils.js +11 -1
  190. package/template/claude-task-manager/public/js/session-search-utils.js +94 -10
  191. package/template/claude-task-manager/public/js/session-status-precedence.js +23 -5
  192. package/template/claude-task-manager/public/js/setup.js +1273 -176
  193. package/template/claude-task-manager/public/js/stream-view.js +691 -73
  194. package/template/claude-task-manager/public/js/terminal-reconciler.js +210 -0
  195. package/template/claude-task-manager/public/js/walle-session.js +2455 -158
  196. package/template/claude-task-manager/public/js/walle.js +455 -28
  197. package/template/claude-task-manager/public/m/app.css +2909 -262
  198. package/template/claude-task-manager/public/m/app.js +6601 -398
  199. package/template/claude-task-manager/public/m/claim.html +224 -17
  200. package/template/claude-task-manager/public/m/index.html +117 -21
  201. package/template/claude-task-manager/public/m/sw.js +3 -1
  202. package/template/claude-task-manager/public/manifest.json +2 -2
  203. package/template/claude-task-manager/public/prompts.html +30 -14
  204. package/template/claude-task-manager/queue-engine.js +507 -28
  205. package/template/claude-task-manager/scripts/repair-claude-session-images.js +27 -8
  206. package/template/claude-task-manager/server.js +14341 -2197
  207. package/template/claude-task-manager/session-integrity.js +160 -18
  208. package/template/claude-task-manager/session-search-ranking.js +1 -0
  209. package/template/claude-task-manager/session-utils.js +25 -5
  210. package/template/claude-task-manager/workers/approval-blocklist.js +96 -6
  211. package/template/claude-task-manager/workers/approval-widget-validator.js +14 -8
  212. package/template/claude-task-manager/workers/conversation-import-worker.js +11 -50
  213. package/template/claude-task-manager/workers/db-owner-worker.js +386 -0
  214. package/template/claude-task-manager/workers/harvest-worker.js +9 -55
  215. package/template/claude-task-manager/workers/headless-term-worker.js +9 -530
  216. package/template/claude-task-manager/workers/read-pool-worker.js +387 -0
  217. package/template/claude-task-manager/workers/scrollback-worker.js +11 -72
  218. package/template/claude-task-manager/workers/session-host-process.js +146 -0
  219. package/template/claude-task-manager/workers/session-integrity-worker.js +10 -54
  220. package/template/claude-task-manager/workers/state-detectors/base.js +18 -1
  221. package/template/claude-task-manager/workers/state-detectors/claude-code.js +182 -9
  222. package/template/claude-task-manager/workers/state-detectors/codex.js +150 -2
  223. package/template/claude-task-manager/workers/state-detectors/cursor.js +127 -0
  224. package/template/claude-task-manager/workers/state-detectors/gemini.js +21 -0
  225. package/template/claude-task-manager/workers/state-detectors/index.js +29 -0
  226. package/template/claude-task-manager/workers/state-detectors/opencode.js +103 -0
  227. package/template/docs/design/markdown-review-pane.md +206 -0
  228. package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +129 -38
  229. package/template/docs/designs/2026-05-20-mobile-worktree-finish-command.md +27 -0
  230. package/template/docs/designs/2026-05-22-ai-configuration-consolidation.md +248 -0
  231. package/template/docs/designs/ai-configuration-consolidation-mock.html +812 -0
  232. package/template/docs/private-memory-and-pii-policy.md +69 -0
  233. package/template/package.json +2 -1
  234. package/template/scripts/check-private-data.js +201 -0
  235. package/template/shared/sqlite-owner-guard.js +30 -0
  236. package/template/shared/sqlite-owner-write-queue.js +225 -0
  237. package/template/shared/sqlite-storage-policy.js +111 -0
  238. package/template/shared/sqlite-write-lock.js +428 -0
  239. package/template/wall-e/agent-runners/claude-code.js +5 -0
  240. package/template/wall-e/agent.js +166 -22
  241. package/template/wall-e/api-walle.js +524 -70
  242. package/template/wall-e/auth/provider-flows.js +11 -1
  243. package/template/wall-e/bin/walle-mcp-stdio.js +341 -17
  244. package/template/wall-e/brain.js +1614 -141
  245. package/template/wall-e/chat/attachment-blocks.js +96 -0
  246. package/template/wall-e/chat/attachments.js +2 -1
  247. package/template/wall-e/chat/capability-resolver.js +7 -7
  248. package/template/wall-e/chat/context-messages.js +28 -0
  249. package/template/wall-e/chat/conversation-frame.js +630 -0
  250. package/template/wall-e/chat/provider-messages.js +125 -0
  251. package/template/wall-e/chat.js +1002 -233
  252. package/template/wall-e/coding/acceptance-contract.js +170 -0
  253. package/template/wall-e/coding/acp-adapter.js +1 -1
  254. package/template/wall-e/coding/agent-catalog.js +3 -0
  255. package/template/wall-e/coding/artifact-store.js +93 -0
  256. package/template/wall-e/coding/capability-router.js +120 -0
  257. package/template/wall-e/coding/coding-run-controller.js +423 -0
  258. package/template/wall-e/coding/compaction-service.js +157 -12
  259. package/template/wall-e/coding/frontend-verification.js +258 -0
  260. package/template/wall-e/coding/lifecycle-hooks.js +75 -0
  261. package/template/wall-e/coding/local-preview-contract.js +157 -0
  262. package/template/wall-e/coding/permission-service.js +57 -13
  263. package/template/wall-e/coding/prompt-bundle.js +19 -1
  264. package/template/wall-e/coding/prompt-section-registry.js +227 -0
  265. package/template/wall-e/coding/provider-compat.js +15 -0
  266. package/template/wall-e/coding/runtime-events.js +224 -0
  267. package/template/wall-e/coding/runtime-mode.js +3 -0
  268. package/template/wall-e/coding/side-git-snapshot.js +160 -4
  269. package/template/wall-e/coding/snapshot-service.js +143 -1
  270. package/template/wall-e/coding/stream-processor.js +388 -34
  271. package/template/wall-e/coding/task-tool.js +141 -4
  272. package/template/wall-e/coding/tool-execution-controller.js +365 -0
  273. package/template/wall-e/coding/tool-registry.js +43 -5
  274. package/template/wall-e/coding/user-hooks.js +217 -0
  275. package/template/wall-e/coding-orchestrator.js +1330 -221
  276. package/template/wall-e/coding-prompts.js +20 -4
  277. package/template/wall-e/context/context-builder.js +15 -2
  278. package/template/wall-e/decision/confidence.js +1 -1
  279. package/template/wall-e/docs/coding-acceptance-contract.md +41 -0
  280. package/template/wall-e/docs/external-action-controller.md +26 -6
  281. package/template/wall-e/docs/telemetry-lifecycle.md +8 -2
  282. package/template/wall-e/embeddings.js +591 -53
  283. package/template/wall-e/external-action-controller.js +12 -0
  284. package/template/wall-e/http/auth.js +1 -0
  285. package/template/wall-e/http/chat-api.js +46 -11
  286. package/template/wall-e/http/model-admin.js +836 -34
  287. package/template/wall-e/lib/boot-profile.js +88 -0
  288. package/template/wall-e/lib/event-loop-monitor.js +93 -0
  289. package/template/wall-e/lib/service-health.js +194 -0
  290. package/template/wall-e/llm/anthropic.js +130 -5
  291. package/template/wall-e/llm/client.js +266 -63
  292. package/template/wall-e/llm/default-fallback.js +382 -0
  293. package/template/wall-e/llm/health.js +19 -0
  294. package/template/wall-e/llm/message-guard.js +78 -0
  295. package/template/wall-e/llm/model-catalog.js +252 -1
  296. package/template/wall-e/llm/openai.js +26 -4
  297. package/template/wall-e/llm/portkey-sync.js +654 -0
  298. package/template/wall-e/llm/provider-error.js +30 -2
  299. package/template/wall-e/llm/registry.js +5 -1
  300. package/template/wall-e/llm/request-compat.js +67 -0
  301. package/template/wall-e/loops/backfill.js +79 -23
  302. package/template/wall-e/loops/brain-optimize.js +67 -0
  303. package/template/wall-e/loops/ingest.js +25 -10
  304. package/template/wall-e/loops/question-digest.js +160 -0
  305. package/template/wall-e/loops/reflect.js +6 -4
  306. package/template/wall-e/loops/think.js +39 -12
  307. package/template/wall-e/mcp-server.js +318 -36
  308. package/template/wall-e/memory/ctm-context-client.js +52 -14
  309. package/template/wall-e/memory/ctm-operational-context.js +237 -0
  310. package/template/wall-e/memory/ctm-prompt-executions-client.js +128 -0
  311. package/template/wall-e/memory/ctm-session-context.js +111 -63
  312. package/template/wall-e/prompts/coding/deepseek.txt +3 -0
  313. package/template/wall-e/prompts/coding/gemini.txt +6 -0
  314. package/template/wall-e/prompts/coding/gpt.txt +6 -0
  315. package/template/wall-e/prompts/coding/local.txt +7 -0
  316. package/template/wall-e/runtime/decision-hooks.js +115 -0
  317. package/template/wall-e/runtime/devbox-gateway.js +82 -8
  318. package/template/wall-e/runtime/prompt-manifest.js +86 -0
  319. package/template/wall-e/runtime/tool-executor.js +269 -0
  320. package/template/wall-e/runtime/tool-result-envelope.js +138 -0
  321. package/template/wall-e/runtime/transcript-projection.js +60 -0
  322. package/template/wall-e/runtime/walle-runtime.js +224 -0
  323. package/template/wall-e/scripts/db-optimize/migrate.js +162 -0
  324. package/template/wall-e/scripts/db-optimize/recall-eval.js +117 -0
  325. package/template/wall-e/server.js +15 -0
  326. package/template/wall-e/session-files.js +9 -0
  327. package/template/wall-e/skills/_bundled/google-calendar/run.js +1 -1
  328. package/template/wall-e/skills/_bundled/gws-workspace/run.js +1 -1
  329. package/template/wall-e/skills/_bundled/slack-mentions/run.js +76 -6
  330. package/template/wall-e/skills/claude-code-reader.js +7 -3
  331. package/template/wall-e/skills/script-skill-runner.js +10 -0
  332. package/template/wall-e/skills/skill-planner.js +38 -0
  333. package/template/wall-e/tools/builtin-middleware.js +19 -9
  334. package/template/wall-e/tools/local-tools.js +1428 -16
  335. package/template/wall-e/tools/permission-checker.js +73 -5
  336. package/template/wall-e/tools/question-manager.js +117 -7
  337. package/template/wall-e/training/harvester.js +12 -28
  338. package/template/wall-e/training/replay.js +25 -80
  339. package/template/website/index.html +10 -10
  340. package/template/wall-e/eval/ab-test.js +0 -203
  341. package/template/wall-e/eval/agent-runner.js +0 -772
  342. package/template/wall-e/eval/agent-scorer.js +0 -461
  343. package/template/wall-e/eval/aggregator.js +0 -414
  344. package/template/wall-e/eval/allowed-test-commands.js +0 -34
  345. package/template/wall-e/eval/benchmark-generator.js +0 -113
  346. package/template/wall-e/eval/benchmarks/chat-eval.json +0 -1662
  347. package/template/wall-e/eval/benchmarks/chat.json +0 -82
  348. package/template/wall-e/eval/benchmarks/coding-agent-real.json +0 -1
  349. package/template/wall-e/eval/benchmarks/coding-agent.json +0 -1581
  350. package/template/wall-e/eval/benchmarks/coding.json +0 -122
  351. package/template/wall-e/eval/benchmarks/memory-retrieval.json +0 -234
  352. package/template/wall-e/eval/benchmarks/reasoning.json +0 -82
  353. package/template/wall-e/eval/benchmarks/swebench-lite-30.json +0 -212
  354. package/template/wall-e/eval/benchmarks.js +0 -669
  355. package/template/wall-e/eval/cc-replay.js +0 -719
  356. package/template/wall-e/eval/chat-eval.js +0 -525
  357. package/template/wall-e/eval/check-keys.js +0 -15
  358. package/template/wall-e/eval/check-providers.js +0 -42
  359. package/template/wall-e/eval/codex-cli-baseline.js +0 -669
  360. package/template/wall-e/eval/coding-agent-real.js +0 -570
  361. package/template/wall-e/eval/context-compactor.js +0 -251
  362. package/template/wall-e/eval/debug-agent003.js +0 -68
  363. package/template/wall-e/eval/diagnostics.js +0 -216
  364. package/template/wall-e/eval/eval-orchestrator.js +0 -642
  365. package/template/wall-e/eval/evaluate.js +0 -202
  366. package/template/wall-e/eval/evaluator.js +0 -373
  367. package/template/wall-e/eval/exporter.js +0 -212
  368. package/template/wall-e/eval/fixtures/express-basic/package.json +0 -9
  369. package/template/wall-e/eval/fixtures/express-basic/server.js +0 -115
  370. package/template/wall-e/eval/fixtures/express-basic/test.js +0 -83
  371. package/template/wall-e/eval/fixtures/express-buggy/package.json +0 -9
  372. package/template/wall-e/eval/fixtures/express-buggy/server.js +0 -113
  373. package/template/wall-e/eval/fixtures/express-buggy/test.js +0 -83
  374. package/template/wall-e/eval/fixtures/express-buggy-items/package.json +0 -9
  375. package/template/wall-e/eval/fixtures/express-buggy-items/server.js +0 -112
  376. package/template/wall-e/eval/fixtures/express-buggy-items/test.js +0 -83
  377. package/template/wall-e/eval/fixtures/express-buggy-search/package.json +0 -9
  378. package/template/wall-e/eval/fixtures/express-buggy-search/server.js +0 -121
  379. package/template/wall-e/eval/fixtures/express-buggy-search/test.js +0 -83
  380. package/template/wall-e/eval/fixtures/express-rename-data/data.js +0 -34
  381. package/template/wall-e/eval/fixtures/express-rename-data/package.json +0 -9
  382. package/template/wall-e/eval/fixtures/express-rename-data/server.js +0 -97
  383. package/template/wall-e/eval/fixtures/express-rename-data/test.js +0 -88
  384. package/template/wall-e/eval/fixtures/express-xss/package.json +0 -12
  385. package/template/wall-e/eval/fixtures/express-xss/server.js +0 -90
  386. package/template/wall-e/eval/fixtures/express-xss/test.js +0 -67
  387. package/template/wall-e/eval/fixtures/express-xss/views/profile.ejs +0 -9
  388. package/template/wall-e/eval/fixtures/fullstack-app/config/default.js +0 -9
  389. package/template/wall-e/eval/fixtures/fullstack-app/config/test.js +0 -13
  390. package/template/wall-e/eval/fixtures/fullstack-app/package.json +0 -11
  391. package/template/wall-e/eval/fixtures/fullstack-app/public/css/style.css +0 -137
  392. package/template/wall-e/eval/fixtures/fullstack-app/public/index.html +0 -46
  393. package/template/wall-e/eval/fixtures/fullstack-app/public/js/app.js +0 -121
  394. package/template/wall-e/eval/fixtures/fullstack-app/public/js/auth.js +0 -71
  395. package/template/wall-e/eval/fixtures/fullstack-app/public/js/items.js +0 -80
  396. package/template/wall-e/eval/fixtures/fullstack-app/public/js/users.js +0 -46
  397. package/template/wall-e/eval/fixtures/fullstack-app/public/login.html +0 -45
  398. package/template/wall-e/eval/fixtures/fullstack-app/public/register.html +0 -38
  399. package/template/wall-e/eval/fixtures/fullstack-app/scripts/migrate.js +0 -23
  400. package/template/wall-e/eval/fixtures/fullstack-app/scripts/seed.js +0 -46
  401. package/template/wall-e/eval/fixtures/fullstack-app/server/db.js +0 -99
  402. package/template/wall-e/eval/fixtures/fullstack-app/server/index.js +0 -94
  403. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/auth.js +0 -19
  404. package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/logger.js +0 -19
  405. package/template/wall-e/eval/fixtures/fullstack-app/server/router.js +0 -50
  406. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/auth.js +0 -69
  407. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/health.js +0 -23
  408. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/items.js +0 -88
  409. package/template/wall-e/eval/fixtures/fullstack-app/server/routes/users.js +0 -75
  410. package/template/wall-e/eval/fixtures/fullstack-app/server/test.js +0 -198
  411. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/response.js +0 -34
  412. package/template/wall-e/eval/fixtures/fullstack-app/server/utils/validate.js +0 -26
  413. package/template/wall-e/eval/fixtures/fullstack-app/server.js +0 -8
  414. package/template/wall-e/eval/fixtures/fullstack-app/test.js +0 -12
  415. package/template/wall-e/eval/fixtures/monorepo-basic/package.json +0 -8
  416. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/data.js +0 -58
  417. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/middleware.js +0 -46
  418. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/package.json +0 -8
  419. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/routes.js +0 -64
  420. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/server.js +0 -56
  421. package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/test.js +0 -116
  422. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/commands.js +0 -61
  423. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/index.js +0 -62
  424. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/output.js +0 -43
  425. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/package.json +0 -11
  426. package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/test.js +0 -44
  427. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/formatters.js +0 -43
  428. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/index.js +0 -12
  429. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/package.json +0 -5
  430. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/test.js +0 -55
  431. package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/validators.js +0 -29
  432. package/template/wall-e/eval/fixtures/monorepo-basic/test.js +0 -46
  433. package/template/wall-e/eval/fixtures/node-cli/index.js +0 -78
  434. package/template/wall-e/eval/fixtures/node-cli/package.json +0 -10
  435. package/template/wall-e/eval/fixtures/node-cli/test.js +0 -57
  436. package/template/wall-e/eval/fixtures/node-typed/package.json +0 -8
  437. package/template/wall-e/eval/fixtures/node-typed/src/handlers.js +0 -31
  438. package/template/wall-e/eval/fixtures/node-typed/src/utils.js +0 -33
  439. package/template/wall-e/eval/fixtures/node-typed/test.js +0 -36
  440. package/template/wall-e/eval/fixtures/python-flask/app.py +0 -14
  441. package/template/wall-e/eval/fixtures/python-flask/requirements.txt +0 -2
  442. package/template/wall-e/eval/fixtures/python-flask/test_app.py +0 -25
  443. package/template/wall-e/eval/fixtures/wall-e-subset/brain.js +0 -105
  444. package/template/wall-e/eval/fixtures/wall-e-subset/eval/aggregator.js +0 -101
  445. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/chat.json +0 -20
  446. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/coding.json +0 -32
  447. package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks.js +0 -64
  448. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/package.json +0 -6
  449. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/server.js +0 -31
  450. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/test.js +0 -18
  451. package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/utils.js +0 -34
  452. package/template/wall-e/eval/fixtures/wall-e-subset/eval/runner.js +0 -104
  453. package/template/wall-e/eval/fixtures/wall-e-subset/eval/scorer.js +0 -73
  454. package/template/wall-e/eval/fixtures/wall-e-subset/eval/test.js +0 -134
  455. package/template/wall-e/eval/fixtures/wall-e-subset/llm/client.js +0 -99
  456. package/template/wall-e/eval/fixtures/wall-e-subset/llm/providers.js +0 -63
  457. package/template/wall-e/eval/fixtures/wall-e-subset/llm/test.js +0 -70
  458. package/template/wall-e/eval/fixtures/wall-e-subset/package.json +0 -10
  459. package/template/wall-e/eval/fixtures/wall-e-subset/test.js +0 -86
  460. package/template/wall-e/eval/harvester.js +0 -685
  461. package/template/wall-e/eval/head-to-head.js +0 -388
  462. package/template/wall-e/eval/humaneval-adapter.js +0 -321
  463. package/template/wall-e/eval/list-models.js +0 -31
  464. package/template/wall-e/eval/livecodebench-adapter.js +0 -291
  465. package/template/wall-e/eval/mail-integration.js +0 -443
  466. package/template/wall-e/eval/manifest.js +0 -186
  467. package/template/wall-e/eval/meta-harness/adapters/coding-agent.js +0 -57
  468. package/template/wall-e/eval/meta-harness/bootstrap-snapshot.js +0 -149
  469. package/template/wall-e/eval/meta-harness/candidate-store.js +0 -117
  470. package/template/wall-e/eval/meta-harness/cli.js +0 -86
  471. package/template/wall-e/eval/meta-harness/domain-spec.js +0 -154
  472. package/template/wall-e/eval/meta-harness/domains/coding-agent.domain.json +0 -84
  473. package/template/wall-e/eval/meta-harness/examples/env-bootstrap-candidate.js +0 -29
  474. package/template/wall-e/eval/meta-harness/experience-store.js +0 -174
  475. package/template/wall-e/eval/meta-harness/frontier.js +0 -96
  476. package/template/wall-e/eval/meta-harness/harness-interface.js +0 -90
  477. package/template/wall-e/eval/meta-harness/leakage-guard.js +0 -80
  478. package/template/wall-e/eval/meta-harness/optimizer.js +0 -207
  479. package/template/wall-e/eval/meta-harness/proposer-runner.js +0 -110
  480. package/template/wall-e/eval/meta-harness/reporting.js +0 -58
  481. package/template/wall-e/eval/meta-harness/telemetry.js +0 -27
  482. package/template/wall-e/eval/meta-harness/validation.js +0 -81
  483. package/template/wall-e/eval/promoter.js +0 -228
  484. package/template/wall-e/eval/provider-normalizer.js +0 -33
  485. package/template/wall-e/eval/replay.js +0 -395
  486. package/template/wall-e/eval/run-agent-benchmarks.js +0 -386
  487. package/template/wall-e/eval/run-codex-cli-baseline.js +0 -177
  488. package/template/wall-e/eval/run-coding-agent-real.js +0 -187
  489. package/template/wall-e/eval/run-eval.js +0 -435
  490. package/template/wall-e/eval/run-model-comparison.js +0 -142
  491. package/template/wall-e/eval/session-evaluator.js +0 -187
  492. package/template/wall-e/eval/session-miner.js +0 -207
  493. package/template/wall-e/eval/session-retrieval-benchmark.js +0 -150
  494. package/template/wall-e/eval/session-transcripts.js +0 -509
  495. package/template/wall-e/eval/shadow.js +0 -161
  496. package/template/wall-e/eval/swebench-adapter.js +0 -345
  497. package/template/wall-e/eval/swebench-docker.js +0 -192
  498. package/template/wall-e/eval/train.py +0 -320
  499. package/template/wall-e/eval/trainer.js +0 -232
  500. package/template/wall-e/eval/weekly-eval-loop.js +0 -241
@@ -5,28 +5,91 @@ const os = require('os');
5
5
  const db = require('./db');
6
6
  const queueEngine = require('./queue-engine');
7
7
  const harvest = require('./prompt-harvest');
8
- const permissionSync = require('./lib/permission-sync');
8
+ const approvalAgent = require('./approval-agent');
9
+ const escalationReview = require('./lib/escalation-review');
10
+ // permission-sync (Claude/Codex settings mirroring) intentionally removed —
11
+ // CTM permissions are a standalone global config in the CTM DB.
9
12
  const walleClient = require('./lib/walle-client');
10
13
  const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
11
14
  const skillAutocomplete = require('./lib/skill-autocomplete');
15
+ const skillIntentResolver = require('./lib/skill-intent-resolver');
12
16
  const resourceLinks = require('./lib/resource-links');
17
+ const storageMigration = require('./lib/storage-migration');
18
+ const { runSync } = require('./lib/perf-tracker');
13
19
  const {
14
20
  ingestJsonlFile,
15
21
  normalizeProvider: normalizeTranscriptProvider,
16
22
  sourceIdFromPath: transcriptSourceIdFromPath,
17
23
  } = require('./lib/transcript-store');
24
+ const { queryPromptExecutions } = require('./lib/prompt-executions-query');
25
+ const { claudeFileSessionId, _usageMetadata } = require('./lib/jsonl-conversation-parser');
26
+ const structuredCapture = require('./lib/structured-capture');
18
27
  // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
19
28
 
20
29
  let dbMaintenanceRunner = null;
30
+ let imageSaveRunner = null;
21
31
  const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
22
32
  const TRANSCRIPT_IMPORT_MAX_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_MAX_BYTES || 10 * 1024 * 1024));
23
33
  const TRANSCRIPT_IMPORT_LARGE_FILE_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_LARGE_FILE_BYTES || 64 * 1024 * 1024));
24
34
  const CONVERSATION_IMPORT_RETRY_AFTER_MS = 30 * 1000;
35
+ const BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES = 2 * 1024 * 1024;
25
36
 
26
37
  function setDbMaintenanceRunner(fn) {
27
38
  dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
28
39
  }
29
40
 
41
+ function setImageSaveRunner(fn) {
42
+ imageSaveRunner = typeof fn === 'function' ? fn : null;
43
+ }
44
+
45
+ // deferRow=true returns as soon as the file is on disk (id:null), firing the
46
+ // images-table INSERT in the background — used by the terminal paste hot path,
47
+ // which forwards the file path/token to the provider and never needs the row id.
48
+ // Callers that need the real id (feedback attachments, prompt-editor annotation)
49
+ // leave deferRow falsy and get the awaited row.
50
+ async function saveImageViaOwner(promptId, buffer, filename, mimeType, deferRow = false) {
51
+ if (imageSaveRunner) {
52
+ return await imageSaveRunner({ promptId, buffer, filename, mimeType, deferRow });
53
+ }
54
+ return await db.saveImageDecoupled(promptId, buffer, filename, mimeType, { deferRow });
55
+ }
56
+
57
+ // Measurement scaffolding for "image paste sometimes takes a long time". Logs one
58
+ // `[img-upload]` line per save (→ ctm.log) and returns the result with the internal
59
+ // `_timing` stripped so it isn't sent to the client. `roundTripMs` is the full
60
+ // saveImageViaOwner/saveImageFromFile call; `queueMs` ≈ roundTrip minus the
61
+ // worker-side hash/write/insert (i.e. db-owner queue wait + IPC/structured-clone of
62
+ // the buffer). writeMs/copyMs is the suspected Dropbox file-provider cost.
63
+ function _logImageTiming(label, meta, result) {
64
+ try {
65
+ const t = (result && result._timing) || {};
66
+ const workMs = (t.hashMs || 0) + (t.writeMs || 0) + (t.copyMs || 0) + (t.insertMs || 0);
67
+ const rt = meta.roundTripMs;
68
+ const queueMs = rt != null ? Math.max(0, Math.round(rt - workMs)) : null;
69
+ const bytes = meta.bytes != null ? meta.bytes : (t.bytes != null ? t.bytes : '?');
70
+ const writePart = ('writeMs' in t)
71
+ ? `writeMs=${t.writeMs}${t.wrote === false ? '(dedup)' : ''}`
72
+ : ('copyMs' in t ? `copyMs=${t.copyMs}` : 'writeMs=-');
73
+ // `path` (caller source, e.g. terminal/prompt/mobile) + `defer` (was the row INSERT
74
+ // deferred = the terminal fast path) disambiguate which upload route a slow line came
75
+ // from. defer=1 with insertMs=0 + tiny roundTrip is the terminal paste fast path;
76
+ // a slow line with defer=0 is a route that intentionally awaits the row (prompt
77
+ // editor / mobile / feedback).
78
+ const deferMark = (meta.defer != null) ? (meta.defer ? 1 : 0) : (t.deferred ? 1 : null);
79
+ console.log(
80
+ `[img-upload] ${label} bytes=${bytes}`
81
+ + (meta.source ? ` path=${meta.source}` : '')
82
+ + (deferMark != null ? ` defer=${deferMark}` : '')
83
+ + (meta.readMs != null ? ` readMs=${Math.round(meta.readMs)}` : '')
84
+ + (rt != null ? ` roundTripMs=${Math.round(rt)}` : '')
85
+ + (queueMs != null ? ` queueMs=${queueMs}` : '')
86
+ + ` hashMs=${t.hashMs != null ? t.hashMs : '-'} ${writePart} insertMs=${t.insertMs != null ? t.insertMs : '-'}`
87
+ );
88
+ } catch { /* never let logging break an upload */ }
89
+ if (result && result._timing) { const r = { ...result }; delete r._timing; return r; }
90
+ return result;
91
+ }
92
+
30
93
  // Embed a prompt (async, fire-and-forget)
31
94
  async function _embedPrompt(promptId, title, content) {
32
95
  try {
@@ -142,7 +205,9 @@ function handlePromptApi(req, res, url) {
142
205
 
143
206
  // --- Images ---
144
207
  if (p === '/api/images/upload' && m === 'POST') return handleUploadImage(req, res, url);
208
+ if (p === '/api/images/ingest-path' && m === 'POST') return handleIngestImagePath(req, res);
145
209
  if (p === '/api/mobile/attachments/upload' && m === 'POST') return handleUploadMobileAttachment(req, res, url);
210
+ if (p === '/api/session/image-refs' && m === 'POST') return handleSessionImageRefs(req, res);
146
211
  if (p.match(/^\/api\/images\/\d+$/) && m === 'GET') return handleGetImage(req, res, url);
147
212
  if (p.match(/^\/api\/images\/\d+\/annotations$/) && m === 'PUT') return handleUpdateAnnotations(req, res, url);
148
213
  if (p.match(/^\/api\/images\/\d+$/) && m === 'DELETE') return handleDeleteImage(req, res, url);
@@ -194,6 +259,10 @@ function handlePromptApi(req, res, url) {
194
259
  if (p === '/api/backups' && m === 'POST') return handleCreateBackup(req, res);
195
260
  if (p === '/api/backups/restore' && m === 'POST') return handleRestoreBackup(req, res);
196
261
  if (p.match(/^\/api\/backups\/[^/]+$/) && m === 'DELETE') return handleDeleteBackup(req, res, url);
262
+ if (p === '/api/storage/locations' && m === 'GET') return handleGetStorageLocations(req, res);
263
+ if (p === '/api/storage/backup-dirs' && m === 'PUT') return handlePutBackupDirs(req, res);
264
+ if (p === '/api/storage/migration/preview' && m === 'POST') return handlePreviewStorageMigration(req, res);
265
+ if (p === '/api/storage/migration/apply' && m === 'POST') return handleApplyStorageMigration(req, res);
197
266
 
198
267
  // --- Tool Permissions (Claude Code native) ---
199
268
  if (p === '/api/tool-permissions/scan' && m === 'POST') return handleScanToolUsage(req, res);
@@ -228,6 +297,10 @@ function handlePromptApi(req, res, url) {
228
297
  return handleResolveApprovalDecision(req, res, parseInt(p.split('/')[3]));
229
298
  }
230
299
 
300
+ // --- Escalation review (grouped "Needs Review" surface in the Permission page) ---
301
+ if (p === '/api/approval-escalations' && m === 'GET') return handleListEscalations(req, res);
302
+ if (p === '/api/approval-escalations/resolve' && m === 'POST') return handleResolveEscalations(req, res);
303
+
231
304
  // --- Dangerous-command blocklist (opt-in safety net) ---
232
305
  if (p === '/api/approval/blocklist' && m === 'GET') return handleGetBlocklist(req, res);
233
306
  if (p === '/api/approval/blocklist' && m === 'POST') return handleSetBlocklistEnabled(req, res);
@@ -247,7 +320,7 @@ function handlePromptApi(req, res, url) {
247
320
  if (draftMatch && m === 'PUT') return handleSaveQueueDraft(req, res, draftMatch[1]);
248
321
  if (draftMatch && m === 'DELETE') return handleDeleteQueueDraft(req, res, draftMatch[1]);
249
322
  const queueMatch = p.match(/^\/api\/queues\/([^/]+)$/);
250
- const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode)$/);
323
+ const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode|remove|reorder)$/);
251
324
  if (queueMatch && !queueActionMatch && m === 'GET') return handleGetQueue(req, res, queueMatch[1]);
252
325
  if (queueMatch && !queueActionMatch && m === 'DELETE') return handleDeleteQueue(req, res, queueMatch[1]);
253
326
  if (queueActionMatch && m === 'POST') return handleQueueAction(req, res, queueActionMatch[1], queueActionMatch[2]);
@@ -265,6 +338,7 @@ function handlePromptApi(req, res, url) {
265
338
  if (p === '/api/copilot/chat' && m === 'POST') return handleCopilotChat(req, res);
266
339
  if (p === '/api/prompt-quality' && m === 'GET') return handlePromptQuality(req, res, url);
267
340
  if (p === '/api/prompt-executions' && m === 'GET') return handleListExecutions(req, res, url);
341
+ if (p === '/api/prompt-executions/stats' && m === 'GET') return handlePromptExecutionStats(req, res);
268
342
  const execSessionMatch = p.match(/^\/api\/prompt-executions\/session\/([^/]+)$/);
269
343
  if (execSessionMatch && m === 'GET') return handleSessionExecutions(req, res, execSessionMatch[1]);
270
344
  const execOutcomeMatch = p.match(/^\/api\/prompt-executions\/(\d+)\/outcome$/);
@@ -283,6 +357,7 @@ function handlePromptApi(req, res, url) {
283
357
  if (p === '/api/prompts/hybrid-search' && m === 'GET') return handleHybridSearch(req, res, url);
284
358
  if (p === '/api/prompts/improve' && m === 'POST') return handleImprovePrompt(req, res);
285
359
  if (p === '/api/skills/autocomplete' && m === 'GET') return handleSkillAutocomplete(req, res, url);
360
+ if (p === '/api/skills/resolve-intent' && m === 'GET') return handleSkillResolveIntent(req, res, url);
286
361
  if (p === '/api/harvest/stats' && m === 'GET') return handleHarvestStats(req, res);
287
362
  if (p === '/api/prompts/pattern-suggestions' && m === 'GET') return handlePatternSuggestions(req, res);
288
363
 
@@ -646,12 +721,120 @@ async function handleUploadImage(req, res, url) {
646
721
  const promptId = parseInt(url.searchParams.get('prompt_id') || '0');
647
722
  const filename = url.searchParams.get('filename') || 'image.png';
648
723
  const mimeType = req.headers['content-type'] || 'image/png';
724
+ // defer_row=1: return as soon as the file is written (id:null), firing the DB
725
+ // INSERT in the background. The terminal paste path sets this so a paste isn't
726
+ // stuck behind the db-owner write queue.
727
+ const deferRow = url.searchParams.get('defer_row') === '1';
728
+ const _tRead0 = performance.now();
649
729
  const buffer = await readRawBody(req);
650
- const result = await db.saveImage(promptId, buffer, filename, mimeType);
651
- jsonResponse(res, 201, result);
730
+ const _tRead1 = performance.now();
731
+ const result = await saveImageViaOwner(promptId, buffer, filename, mimeType, deferRow);
732
+ const safe = _logImageTiming('upload', {
733
+ bytes: buffer.length, readMs: _tRead1 - _tRead0, roundTripMs: performance.now() - _tRead1,
734
+ source: url.searchParams.get('source') || '', defer: deferRow,
735
+ }, result);
736
+ jsonResponse(res, 201, safe);
652
737
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
653
738
  }
654
739
 
740
+ const INGEST_IMAGE_EXT_MIME = {
741
+ '.png': 'image/png',
742
+ '.jpg': 'image/jpeg',
743
+ '.jpeg': 'image/jpeg',
744
+ '.gif': 'image/gif',
745
+ '.webp': 'image/webp',
746
+ '.heic': 'image/heic',
747
+ '.heif': 'image/heif',
748
+ '.bmp': 'image/bmp',
749
+ '.tif': 'image/tiff',
750
+ '.tiff': 'image/tiff',
751
+ };
752
+
753
+ // Decode a file:// URL or accept a plain absolute path. Returns '' if neither.
754
+ function _ingestPathFromInput(value) {
755
+ const raw = String(value || '').trim();
756
+ if (!raw) return '';
757
+ if (/^file:\/\//i.test(raw)) {
758
+ try { return path.resolve(decodeURIComponent(new URL(raw).pathname || '')); }
759
+ catch { return ''; }
760
+ }
761
+ if (path.isAbsolute(raw)) return path.resolve(raw);
762
+ return '';
763
+ }
764
+
765
+ // Only stabilize image references that live in user-controlled temp/home roots.
766
+ // This is the macOS screenshot/drag case (e.g. /var/folders/.../TemporaryItems/...png).
767
+ // Refuse arbitrary system paths so this localhost-only endpoint can't be coaxed
768
+ // into copying sensitive files into the served images dir.
769
+ //
770
+ // SECURITY: `candidate` MUST already be a realpath-resolved path (symlinks
771
+ // followed) and each root is realpath-resolved here, so a `.png` symlink living
772
+ // inside an allowed temp root but pointing at e.g. /etc/shadow cannot pass the
773
+ // containment check (its real target is outside the allowed roots). A purely
774
+ // string-based path.resolve() check would be bypassable by such a symlink.
775
+ function _ingestSourceAllowed(candidate) {
776
+ const realRoots = [];
777
+ const pushRoot = (p) => {
778
+ if (!p) return;
779
+ // Resolve each root through realpath too (e.g. macOS /tmp -> /private/tmp,
780
+ // /var -> /private/var) so the comparison is symlink-stable on both sides.
781
+ try { realRoots.push(fs.realpathSync(p)); } catch { /* root may not exist */ }
782
+ };
783
+ try { pushRoot(os.tmpdir()); } catch {}
784
+ for (const env of ['TMPDIR', 'HOME']) {
785
+ if (process.env[env]) pushRoot(process.env[env]);
786
+ }
787
+ // macOS per-user temp + screenshot scratch live under /var/folders and /private/var/folders.
788
+ for (const r of ['/var/folders', '/private/var/folders', '/tmp', '/private/tmp']) pushRoot(r);
789
+ return realRoots.some(root => {
790
+ const rel = path.relative(root, candidate);
791
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
792
+ });
793
+ }
794
+
795
+ // Ingest a local image FILE by path (not bytes) and return a stable, served copy.
796
+ // Used by the terminal image-reference paste path so CTM forwards a stable
797
+ // images-dir path to the provider instead of the ephemeral macOS temp path
798
+ // (which leaks the local fs and disappears once the screenshot scratch is reaped).
799
+ async function handleIngestImagePath(req, res) {
800
+ try {
801
+ const body = await readBody(req, 64 * 1024);
802
+ const resolved = _ingestPathFromInput(body && (body.path || body.url || body.reference));
803
+ if (!resolved) return jsonResponse(res, 400, { error: 'invalid_path' });
804
+ const ext = path.extname(resolved).toLowerCase();
805
+ const mimeType = INGEST_IMAGE_EXT_MIME[ext];
806
+ if (!mimeType) return jsonResponse(res, 400, { error: 'unsupported_image_type' });
807
+ // SECURITY: reject symlinks outright, then resolve the real (symlink-followed)
808
+ // path BEFORE the allowlist check. Otherwise a `.png` symlink inside an allowed
809
+ // temp root could point at an arbitrary file (e.g. /etc/shadow) and slip past a
810
+ // string-only containment check, then be copied into the served images dir.
811
+ let lst;
812
+ try { lst = await fs.promises.lstat(resolved); }
813
+ catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
814
+ if (lst.isSymbolicLink()) return jsonResponse(res, 403, { error: 'path_not_allowed' });
815
+ let realPath;
816
+ try { realPath = await fs.promises.realpath(resolved); }
817
+ catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
818
+ if (!_ingestSourceAllowed(realPath)) return jsonResponse(res, 403, { error: 'path_not_allowed' });
819
+ let stat;
820
+ try { stat = await fs.promises.stat(realPath); }
821
+ catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
822
+ if (!stat.isFile() || stat.size <= 0) return jsonResponse(res, 400, { error: 'not_a_file' });
823
+ // Copy (deleteSource omitted → default false): never disturb the user's temp file.
824
+ const _tSave0 = performance.now();
825
+ const result = await db.saveImageFromFile(0, realPath, path.basename(realPath), mimeType);
826
+ _logImageTiming('ingest-path', { bytes: stat.size, roundTripMs: performance.now() - _tSave0 }, result);
827
+ jsonResponse(res, 201, {
828
+ id: result.id,
829
+ filename: result.filename,
830
+ path: result.path,
831
+ url: `/api/images/file/${encodeURIComponent(result.filename)}`,
832
+ });
833
+ } catch (e) {
834
+ jsonResponse(res, 400, { error: e.message });
835
+ }
836
+ }
837
+
655
838
  async function handleUploadMobileAttachment(req, res, url) {
656
839
  try {
657
840
  const kind = url.searchParams.get('kind') === 'image' ? 'image' : 'file';
@@ -664,9 +847,11 @@ async function handleUploadMobileAttachment(req, res, url) {
664
847
  jsonResponse(res, 400, { error: 'attachment_empty' });
665
848
  return;
666
849
  }
667
- const result = await db.saveImage(0, buffer, storedFilename, mimeType);
850
+ const _tSave0 = performance.now();
851
+ const result = await saveImageViaOwner(0, buffer, storedFilename, mimeType);
852
+ const safe = _logImageTiming('mobile', { bytes: buffer.length, roundTripMs: performance.now() - _tSave0 }, result);
668
853
  jsonResponse(res, 201, {
669
- ...result,
854
+ ...safe,
670
855
  kind,
671
856
  originalName: filename,
672
857
  mimeType,
@@ -677,6 +862,18 @@ async function handleUploadMobileAttachment(req, res, url) {
677
862
  }
678
863
  }
679
864
 
865
+ async function handleSessionImageRefs(req, res) {
866
+ try {
867
+ const body = await readBody(req, 256 * 1024);
868
+ const result = await db.recordSessionImageRefs(body || {});
869
+ const safeResult = { ...(result || {}) };
870
+ delete safeResult.refDir;
871
+ jsonResponse(res, 200, { ok: true, ...safeResult });
872
+ } catch (e) {
873
+ jsonResponse(res, 400, { ok: false, error: e.message });
874
+ }
875
+ }
876
+
680
877
  function handleGetImage(req, res, url) {
681
878
  const id = parseInt(url.pathname.split('/').pop());
682
879
  const img = db.getImage(id);
@@ -856,13 +1053,30 @@ function handleListConversations(req, res, url) {
856
1053
 
857
1054
  // Core import logic shared by API handler and auto-import
858
1055
  const { getAllSessionFiles, getAllSessionFilesAsync, parseSessionFile } = require('./session-utils');
1056
+ const walleTranscript = require('./lib/walle-transcript');
1057
+ const { readWalleCtmHistory } = require('./lib/walle-ctm-history');
1058
+ const {
1059
+ persistWalleSessionConversation,
1060
+ WALLE_SESSION_CACHE_PARSER_VERSION,
1061
+ } = require('./lib/walle-session-cache');
859
1062
  const {
860
- codexUserKey,
1063
+ createCodexUserDeduper,
1064
+ parseCodexJsonlFileIntoMessagesAsync,
861
1065
  parseCodexJsonlFileIntoMessages,
862
1066
  parseCodexJsonlIntoMessages,
863
1067
  readCodexRolloutMetadata,
864
1068
  } = require('./lib/session-history');
865
1069
  const fsp = require('fs').promises;
1070
+ const CODEX_CONVERSATION_IMPORT_PARSER_VERSION = 3;
1071
+ const DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION = 1;
1072
+ // Claude Code / Claude Desktop sessions are Anthropic, but their JSONL carries no explicit
1073
+ // `modelProvider` field, so the parser leaves it ''. Storing '' makes the import's
1074
+ // `missingModel` candidate predicate (`!existing.model_provider`) re-select the session on
1075
+ // EVERY scan tick forever — re-stringifying + rewriting the whole conversation each time
1076
+ // (the re-import storm: 75 sessions, db-owner-worker saturation, write-lock contention).
1077
+ // Default to a concrete provider so the row converges after one import. (Codex uses 'openai'
1078
+ // and Cursor 'cursor' at their own call sites.)
1079
+ const CLAUDE_MODEL_PROVIDER = 'anthropic';
866
1080
 
867
1081
  // Parse JSONL content string into conversation messages (sync, CPU-bound but
868
1082
  // called after async file read so only the parse blocks — not disk I/O).
@@ -968,7 +1182,7 @@ function _parseLargeConversationLine(line) {
968
1182
  return null;
969
1183
  }
970
1184
 
971
- async function _parseConversationContent(content) {
1185
+ async function _parseConversationContent(content, opts = {}) {
972
1186
  const messages = [];
973
1187
  const searchMessages = [];
974
1188
  let assistantCount = 0;
@@ -977,6 +1191,29 @@ async function _parseConversationContent(content) {
977
1191
  let firstAssistantText = '';
978
1192
  let renameName = '';
979
1193
 
1194
+ // Cross-file stitching (mirrors parseSessionFiles in lib/jsonl-conversation-parser):
1195
+ // a post-compact Claude file inherits the PARENT file's lines (old sessionId at the
1196
+ // top). When the owner file still exists next to this one, drop the inherited copy
1197
+ // so the owner's import is the only one that stores those turns. Orphan prefixes
1198
+ // (owner deleted) are kept — they are the only surviving copy.
1199
+ const fileSessionId = String(opts.fileSessionId || '');
1200
+ const fileDir = String(opts.fileDir || '');
1201
+ const ownerOnDisk = new Map();
1202
+ const dropInheritedEntry = (entryId) => {
1203
+ if (!fileSessionId || !fileDir || !entryId || entryId === fileSessionId) return false;
1204
+ if (!ownerOnDisk.has(entryId)) {
1205
+ let exists = false;
1206
+ try { exists = fs.existsSync(path.join(fileDir, `${entryId}.jsonl`)); } catch {}
1207
+ ownerOnDisk.set(entryId, exists);
1208
+ }
1209
+ return ownerOnDisk.get(entryId);
1210
+ };
1211
+ const seenUuids = new Set();
1212
+ const summaries = [];
1213
+ // Last contiguous run emitted for one assistant parentUuid (structured
1214
+ // emission replaces the whole run when the same row re-streams).
1215
+ let lastAssistantRun = null;
1216
+
980
1217
  // Yield while scanning so a large JSONL does not monopolize the event loop.
981
1218
  // Avoid content.split('\n') here: building the full line array for a 50MB+
982
1219
  // file blocks before the parser gets its first chance to yield.
@@ -1001,6 +1238,12 @@ async function _parseConversationContent(content) {
1001
1238
  i++;
1002
1239
  try {
1003
1240
  if (line.length > _LARGE_JSONL_LINE_BYTES) {
1241
+ if (dropInheritedEntry(_readJsonStringPrefix(line, '"sessionId":"', 64))) {
1242
+ await maybeYield();
1243
+ continue;
1244
+ }
1245
+ const largeUuid = _readJsonStringPrefix(line, '"uuid":"', 64);
1246
+ if (largeUuid) seenUuids.add(largeUuid);
1004
1247
  const large = _parseLargeConversationLine(line);
1005
1248
  if (large?.role === 'user') {
1006
1249
  messages.push({ role: 'user', text: large.text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: large.timestamp });
@@ -1031,11 +1274,64 @@ async function _parseConversationContent(content) {
1031
1274
  }
1032
1275
 
1033
1276
  const entry = JSON.parse(line);
1034
- if (entry.type === 'user' && entry.message?.role === 'user') {
1277
+ if (dropInheritedEntry(typeof entry.sessionId === 'string' ? entry.sessionId : '')) {
1278
+ await maybeYield();
1279
+ continue;
1280
+ }
1281
+ if (typeof entry.uuid === 'string' && entry.uuid) seenUuids.add(entry.uuid);
1282
+ if (entry.type === 'summary' && typeof entry.summary === 'string' && entry.summary.trim()) {
1283
+ // Async-generated session title ({summary, leafUuid}); not a turn.
1284
+ summaries.push({ summary: entry.summary.trim(), leafUuid: entry.leafUuid || '' });
1285
+ } else if (entry.type === 'system' && entry.subtype === 'compact_boundary'
1286
+ && structuredCapture.structuredCaptureEnabled()) {
1287
+ const compactMeta = entry.compactMetadata || {};
1288
+ const boundary = structuredCapture.compactBoundaryMessage({
1289
+ provider: 'claude',
1290
+ trigger: compactMeta.trigger,
1291
+ preTokens: Number(compactMeta.preTokens),
1292
+ logicalParentUuid: entry.logicalParentUuid,
1293
+ timestamp: entry.timestamp,
1294
+ });
1295
+ messages.push(boundary);
1296
+ searchMessages.push(boundary);
1297
+ } else if (entry.type === 'user' && entry.message?.role === 'user') {
1035
1298
  const c = entry.message.content;
1036
1299
  const text = typeof c === 'string' ? c
1037
1300
  : Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
1038
- if (text) {
1301
+ const captureOn = structuredCapture.structuredCaptureEnabled();
1302
+ if (captureOn && (entry.isCompactSummary || entry.isVisibleInTranscriptOnly)) {
1303
+ // Post-compact synthetic context summary — a collapsed structured
1304
+ // row, never a user prompt (keeps first/last-user title signals clean).
1305
+ if (text) {
1306
+ const summaryMsg = structuredCapture.compactSummaryMessage({ provider: 'claude', text, timestamp: entry.timestamp });
1307
+ messages.push(summaryMsg);
1308
+ searchMessages.push(summaryMsg);
1309
+ }
1310
+ } else if (captureOn && Array.isArray(c) && c.some(b => b && b.type === 'tool_result')) {
1311
+ // Mirrors lib/jsonl-conversation-parser: one structured tool_result
1312
+ // row per block, enriched from the top-level toolUseResult.
1313
+ const resultBlocks = c.filter(b => b && b.type === 'tool_result');
1314
+ const tur = resultBlocks.length === 1 && entry.toolUseResult && typeof entry.toolUseResult === 'object'
1315
+ ? entry.toolUseResult : null;
1316
+ for (const block of resultBlocks) {
1317
+ const blockText = typeof block.content === 'string' ? block.content
1318
+ : Array.isArray(block.content)
1319
+ ? block.content.filter(b => b && b.type === 'text' && b.text).map(b => b.text).join('\n')
1320
+ : '';
1321
+ const resultMsg = structuredCapture.toolResultMessage({
1322
+ provider: 'claude',
1323
+ callId: block.tool_use_id,
1324
+ output: blockText || (tur && typeof tur.stdout === 'string' ? tur.stdout : ''),
1325
+ isError: block.is_error === true,
1326
+ filePath: tur ? (tur.filePath || tur.file_path) : undefined,
1327
+ durationMs: tur ? Number(tur.durationMs) : undefined,
1328
+ structuredPatch: tur ? tur.structuredPatch : undefined,
1329
+ timestamp: entry.timestamp,
1330
+ });
1331
+ messages.push(resultMsg);
1332
+ searchMessages.push(resultMsg);
1333
+ }
1334
+ } else if (text) {
1039
1335
  messages.push({ role: 'user', text: text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: entry.timestamp });
1040
1336
  searchMessages.push({ role: 'user', text, timestamp: entry.timestamp });
1041
1337
  if (!firstUserContent) firstUserContent = text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
@@ -1047,6 +1343,80 @@ async function _parseConversationContent(content) {
1047
1343
  } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
1048
1344
  const c = entry.message.content;
1049
1345
  if (!Array.isArray(c)) continue;
1346
+ if (structuredCapture.structuredCaptureEnabled()) {
1347
+ // Mirrors lib/jsonl-conversation-parser: thinking → reasoning rows,
1348
+ // tool_use → tool_call rows, text blocks → ONE assistant message
1349
+ // (parentUuid + usage/model metadata), in block order. A re-streamed
1350
+ // parentUuid replaces the previous contiguous run.
1351
+ const run = [];
1352
+ const textParts = [];
1353
+ let textInsertIndex = -1;
1354
+ for (const block of c) {
1355
+ if (!block || typeof block !== 'object') continue;
1356
+ if (block.type === 'text' && block.text) {
1357
+ if (textInsertIndex === -1) textInsertIndex = run.length;
1358
+ textParts.push(block.text);
1359
+ } else if (block.type === 'thinking' && (block.thinking || block.text)) {
1360
+ run.push(structuredCapture.reasoningMessage({ provider: 'claude', text: block.thinking || block.text, timestamp: entry.timestamp }));
1361
+ } else if (block.type === 'tool_use') {
1362
+ run.push(structuredCapture.toolCallMessage({
1363
+ provider: 'claude', tool: block.name, callId: block.id, args: block.input, timestamp: entry.timestamp,
1364
+ }));
1365
+ }
1366
+ }
1367
+ let searchText = '';
1368
+ if (textParts.length) {
1369
+ searchText = textParts.join('\n');
1370
+ if (!firstAssistantText) firstAssistantText = searchText.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
1371
+ const msg = {
1372
+ role: 'assistant',
1373
+ text: searchText.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT),
1374
+ timestamp: entry.timestamp,
1375
+ parentUuid: entry.parentUuid,
1376
+ _parent: entry.parentUuid,
1377
+ };
1378
+ const usage = _usageMetadata(entry.message.usage);
1379
+ const model = typeof entry.message.model === 'string' ? entry.message.model : '';
1380
+ if (usage || model) {
1381
+ msg.metadata = {};
1382
+ if (usage) msg.metadata.usage = usage;
1383
+ if (model) msg.metadata.model = model;
1384
+ }
1385
+ run.splice(textInsertIndex === -1 ? run.length : textInsertIndex, 0, msg);
1386
+ }
1387
+ if (run.length) {
1388
+ if (entry.parentUuid && lastAssistantRun
1389
+ && lastAssistantRun.parent === entry.parentUuid
1390
+ && lastAssistantRun.mEnd === messages.length
1391
+ && lastAssistantRun.sEnd === searchMessages.length) {
1392
+ messages.splice(lastAssistantRun.mStart);
1393
+ searchMessages.splice(lastAssistantRun.sStart);
1394
+ assistantCount -= lastAssistantRun.assistantCount;
1395
+ }
1396
+ const mStart = messages.length;
1397
+ const sStart = searchMessages.length;
1398
+ let runAssistantCount = 0;
1399
+ for (const m of run) {
1400
+ messages.push(m);
1401
+ if (m.role === 'assistant') {
1402
+ runAssistantCount++;
1403
+ // Search index keeps the FULL text (the messages copy is capped).
1404
+ searchMessages.push({ ...m, text: searchText });
1405
+ } else {
1406
+ searchMessages.push(m);
1407
+ }
1408
+ }
1409
+ assistantCount += runAssistantCount;
1410
+ lastAssistantRun = {
1411
+ parent: entry.parentUuid,
1412
+ mStart, mEnd: messages.length,
1413
+ sStart, sEnd: searchMessages.length,
1414
+ assistantCount: runAssistantCount,
1415
+ };
1416
+ }
1417
+ await maybeYield();
1418
+ continue;
1419
+ }
1050
1420
  const parts = [];
1051
1421
  for (const block of c) {
1052
1422
  if (block.type === 'text' && block.text) parts.push(block.text);
@@ -1081,6 +1451,12 @@ async function _parseConversationContent(content) {
1081
1451
  }
1082
1452
  messages.forEach(m => delete m._parent);
1083
1453
  searchMessages.forEach(m => delete m._parent);
1454
+ // Title candidate (lowest precedence): the last summary whose leafUuid we
1455
+ // actually saw in this content — or one with no leafUuid at all.
1456
+ let summaryTitle = '';
1457
+ for (const s of summaries) {
1458
+ if (!s.leafUuid || seenUuids.has(s.leafUuid)) summaryTitle = s.summary.slice(0, 200);
1459
+ }
1084
1460
  return {
1085
1461
  messages,
1086
1462
  searchMessages,
@@ -1089,6 +1465,7 @@ async function _parseConversationContent(content) {
1089
1465
  lastUserContent,
1090
1466
  firstAssistantText,
1091
1467
  renameName,
1468
+ summaryTitle,
1092
1469
  };
1093
1470
  }
1094
1471
 
@@ -1144,8 +1521,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1144
1521
  fsp.readFile(jsonlPath, 'utf8').catch(() => ''),
1145
1522
  ]);
1146
1523
 
1147
- const bakParsed = await _parseConversationContent(bakContent);
1148
- const jsonlParsed = await _parseConversationContent(jsonlContent);
1524
+ const stitchOpts = { fileSessionId: claudeFileSessionId(jsonlPath), fileDir: path.dirname(jsonlPath) };
1525
+ const bakParsed = await _parseConversationContent(bakContent, stitchOpts);
1526
+ const jsonlParsed = await _parseConversationContent(jsonlContent, stitchOpts);
1149
1527
 
1150
1528
  // Concatenate then sort by timestamp so out-of-order writes (rare but
1151
1529
  // possible mid-compact) end up in chronological order.
@@ -1176,7 +1554,7 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1176
1554
  search_messages: allSearchMessages,
1177
1555
  user_msg_count: signals.userCount,
1178
1556
  assistant_msg_count: signals.assistantCount,
1179
- title: parsed.title || (existing && existing.title) || '',
1557
+ title: parsed.title || (existing && existing.title) || jsonlParsed.summaryTitle || bakParsed.summaryTitle || '',
1180
1558
  first_message: mergedFirstUser,
1181
1559
  last_user_content: mergedLastUser,
1182
1560
  first_assistant_text: mergedFirstAssistant,
@@ -1185,8 +1563,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
1185
1563
  file_size: totalSize,
1186
1564
  session_created_at: parsed.timestamp,
1187
1565
  hostname: parsed.hostname,
1188
- model_provider: parsed.modelProvider || (existing && existing.model_provider) || '',
1566
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1189
1567
  model_id: parsed.modelId || (existing && existing.model_id) || '',
1568
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1190
1569
  });
1191
1570
  return true;
1192
1571
  }
@@ -1219,15 +1598,8 @@ function _loadIndexedSessionMessages(sessionId) {
1219
1598
  }
1220
1599
  }
1221
1600
 
1222
- function _codexSeenUsersFromMessages(messages) {
1223
- const seen = new Set();
1224
- for (const msg of Array.isArray(messages) ? messages : []) {
1225
- if (msg && msg.role === 'user') {
1226
- const key = codexUserKey(msg.text || msg.content || '');
1227
- if (key) seen.add(key);
1228
- }
1229
- }
1230
- return seen;
1601
+ function _codexUserDeduperFromMessages(messages) {
1602
+ return createCodexUserDeduper((Array.isArray(messages) ? messages : []).filter(msg => msg && msg.role === 'user'));
1231
1603
  }
1232
1604
 
1233
1605
  async function _readFileRange(filePath, start, length) {
@@ -1242,21 +1614,28 @@ async function _readFileRange(filePath, start, length) {
1242
1614
  }
1243
1615
 
1244
1616
  function _conversationImportIndexRows() {
1245
- const conversations = new Map();
1246
- const linkedAgentIds = new Set();
1247
- try {
1248
- const rows = db.getDb().prepare(
1249
- 'SELECT ctm_session_id, file_size, model_provider FROM session_conversations'
1250
- ).all();
1251
- for (const row of rows) conversations.set(row.ctm_session_id, row);
1252
- } catch {}
1253
- try {
1254
- const rows = db.getDb().prepare(
1255
- 'SELECT agent_session_id FROM agent_sessions WHERE agent_session_id IS NOT NULL AND agent_session_id != ""'
1256
- ).all();
1257
- for (const row of rows) linkedAgentIds.add(row.agent_session_id);
1258
- } catch {}
1259
- return { conversations, linkedAgentIds };
1617
+ // Attribution: two full-table index scans built on every conversation-import
1618
+ // tick. They run as the sync prefix of _conversationImportCandidates, which is
1619
+ // itself called AFTER the job's `await getAllSessionFilesAsync()` — so the
1620
+ // scheduler's tag is already cleared and a slow scan would log as `(unknown)`.
1621
+ // Phase 2.
1622
+ return runSync('conversation-import:buildIndexRows', () => {
1623
+ const conversations = new Map();
1624
+ const linkedAgentIds = new Set();
1625
+ try {
1626
+ const rows = db.getDb().prepare(
1627
+ 'SELECT ctm_session_id, file_size, model_provider, import_parser_version FROM session_conversations'
1628
+ ).all();
1629
+ for (const row of rows) conversations.set(row.ctm_session_id, row);
1630
+ } catch {}
1631
+ try {
1632
+ const rows = db.getDb().prepare(
1633
+ 'SELECT agent_session_id FROM agent_sessions WHERE agent_session_id IS NOT NULL AND agent_session_id != ""'
1634
+ ).all();
1635
+ for (const row of rows) linkedAgentIds.add(row.agent_session_id);
1636
+ } catch {}
1637
+ return { conversations, linkedAgentIds };
1638
+ });
1260
1639
  }
1261
1640
 
1262
1641
  async function _conversationImportEffectiveSize(filePath, stat) {
@@ -1283,14 +1662,29 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1283
1662
  const stat = await fsp.stat(claudeDesktopSessions.sourcePathForStat(filePath));
1284
1663
  if (!stat.isFile()) continue;
1285
1664
  const sessionId = listedSessionId || path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
1665
+ // Desktop sessions re-candidate forever via `linkedMissingCache`: importSessionFile
1666
+ // returns false for an empty desktop session (no messages), so no session_conversations
1667
+ // row is ever written, so `!existing` stays true and the session is re-selected every
1668
+ // import tick. Skip dead/empty desktop sessions here — there is nothing to import, and a
1669
+ // healthy one (messages present) writes a cache row and stops matching this guard.
1670
+ if (claudeDesktopSessions.isVirtualSessionPath(filePath)
1671
+ && (claudeDesktopSessions.getMessages(sessionId) || []).length === 0) {
1672
+ continue;
1673
+ }
1286
1674
  const existing = conversations.get(sessionId);
1287
1675
  const existingSize = Number(existing?.file_size || 0);
1288
1676
  const { effectiveSize, hasCompactSibling } = await _conversationImportEffectiveSize(filePath, stat);
1677
+ const isCodexRollout = projectEntry === 'codex' || String(filePath || '').includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
1678
+ const isWalleTranscript = projectEntry === walleTranscript.WALLE_PROJECT_ENTRY;
1289
1679
  const changedSinceScan = stat.mtimeMs > lastScanAt;
1290
1680
  const cacheBehind = !!existing && effectiveSize > existingSize;
1291
1681
  const cacheShrankAfterChange = !!existing && effectiveSize < existingSize && changedSinceScan;
1292
1682
  const linkedMissingCache = linkedAgentIds.has(sessionId) && !existing;
1293
1683
  const missingModel = !!existing && !existing.model_provider;
1684
+ const staleParser = !!existing && (
1685
+ (isCodexRollout && Number(existing.import_parser_version || 0) < CODEX_CONVERSATION_IMPORT_PARSER_VERSION) ||
1686
+ (isWalleTranscript && Number(existing.import_parser_version || 0) < WALLE_SESSION_CACHE_PARSER_VERSION)
1687
+ );
1294
1688
  const changedColdFile = changedSinceScan && !existing;
1295
1689
 
1296
1690
  if (
@@ -1298,18 +1692,20 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1298
1692
  !cacheBehind &&
1299
1693
  !cacheShrankAfterChange &&
1300
1694
  !linkedMissingCache &&
1301
- !missingModel
1695
+ !missingModel &&
1696
+ !staleParser
1302
1697
  ) {
1303
1698
  continue;
1304
1699
  }
1305
1700
 
1306
1701
  let priority = 6;
1307
1702
  if (cacheBehind) priority = 0;
1308
- else if (linkedMissingCache) priority = 1;
1309
- else if (cacheShrankAfterChange) priority = 2;
1310
- else if (hasCompactSibling && changedColdFile) priority = 3;
1311
- else if (changedColdFile) priority = 4;
1312
- else if (missingModel) priority = 5;
1703
+ else if (staleParser) priority = 1;
1704
+ else if (linkedMissingCache) priority = 2;
1705
+ else if (cacheShrankAfterChange) priority = 3;
1706
+ else if (hasCompactSibling && changedColdFile) priority = 4;
1707
+ else if (changedColdFile) priority = 5;
1708
+ else if (missingModel) priority = 6;
1313
1709
 
1314
1710
  candidates.push({
1315
1711
  filePath,
@@ -1343,32 +1739,79 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
1343
1739
  return candidates;
1344
1740
  }
1345
1741
 
1346
- async function _importCodexSessionFile(parsed, filePath) {
1742
+ function _backgroundTranscriptImportMaxBytes() {
1743
+ const raw = Number(process.env.CTM_BACKGROUND_TRANSCRIPT_IMPORT_MAX_BYTES || BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
1744
+ return Math.max(256 * 1024, Number.isFinite(raw) ? raw : BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
1745
+ }
1746
+
1747
+ function _codexImportedFileSize(parsedFileSize, prevFileSize, parsedTail) {
1748
+ const fileSize = Math.max(0, Number(parsedFileSize || 0));
1749
+ const prev = Math.max(0, Number(prevFileSize || 0));
1750
+ const base = prev > 0 && fileSize >= prev ? prev : 0;
1751
+ const rawConsumed = Number.isFinite(Number(parsedTail?.completeBytesRead))
1752
+ ? Number(parsedTail.completeBytesRead)
1753
+ : Number(parsedTail?.bytesRead || 0);
1754
+ const consumed = Math.max(0, Number.isFinite(rawConsumed) ? rawConsumed : 0);
1755
+ return Math.min(fileSize, base + consumed);
1756
+ }
1757
+
1758
+ async function _importCodexSessionFile(parsed, filePath, options = {}) {
1347
1759
  const sessionId = parsed.sessionId;
1348
1760
  if (!sessionId) return false;
1349
1761
 
1350
1762
  const existing = db.getSessionConversation(sessionId);
1351
- if (existing && existing.file_size === parsed.fileSize && existing.model_provider) return false;
1352
-
1353
- const prevFileSize = Number(existing?.file_size || 0);
1354
- const baseMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1355
- ? _safeParseMessagesJson(existing.messages)
1763
+ const parserVersion = CODEX_CONVERSATION_IMPORT_PARSER_VERSION;
1764
+ const parserStale = !!existing && Number(existing.import_parser_version || 0) < parserVersion;
1765
+ if (existing && existing.file_size === parsed.fileSize && existing.model_provider && !parserStale) return false;
1766
+
1767
+ const prevFileSize = parserStale ? 0 : Number(existing?.file_size || 0);
1768
+ // Hard size cap (fail-open): a multi-GB transcript would make the existing-blob
1769
+ // parse + whole-array concat/sort below block the loop and exceed V8's ~512MB
1770
+ // string limit. Over cap ⇒ don't load the full base from the blob; import only
1771
+ // the freshly-read tail (the downstream blob write is also capped to '[]' and
1772
+ // the transcript-store rows carry full history).
1773
+ let _isOverParseCap = null;
1774
+ try { _isOverParseCap = require('./lib/size-cap').isOverParseCap; } catch { /* optional */ }
1775
+ const _overCap = typeof _isOverParseCap === 'function' ? _isOverParseCap(parsed.fileSize) : false;
1776
+ const _loadBase = !_overCap && prevFileSize > 0 && parsed.fileSize > prevFileSize;
1777
+ // Phase 6: load the incremental base from the faithful per-message rows when they're
1778
+ // known-complete (column read, no multi-MB JSON.parse of the blob — and it survives blob
1779
+ // retirement in Phase 7). Falls back to the blob when rows are absent/half-migrated.
1780
+ const _rowsComplete = _loadBase
1781
+ && typeof db.sessionContentRowsAvailable === 'function'
1782
+ && db.sessionContentRowsAvailable(sessionId);
1783
+ const baseMessages = _loadBase
1784
+ ? (_rowsComplete ? db.getSessionMessagesArray(sessionId, { fallbackToBlob: false }) : _safeParseMessagesJson(existing.messages))
1356
1785
  : [];
1357
- const indexedMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1358
- ? _loadIndexedSessionMessages(sessionId)
1359
- : [];
1360
- const baseSearchMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
1786
+ const indexedMessages = _loadBase ? _loadIndexedSessionMessages(sessionId) : [];
1787
+ const baseSearchMessages = _loadBase
1361
1788
  ? (indexedMessages.length ? indexedMessages : baseMessages)
1362
1789
  : [];
1363
- const seenUsers = _codexSeenUsersFromMessages(baseMessages);
1790
+ const codexUserDeduper = _codexUserDeduperFromMessages(baseMessages);
1364
1791
 
1365
1792
  const newMessages = [];
1366
1793
  let parsedTail;
1367
1794
  if (prevFileSize > 0 && parsed.fileSize > prevFileSize) {
1368
1795
  const content = await _readFileRange(filePath, prevFileSize, parsed.fileSize - prevFileSize);
1369
- parsedTail = parseCodexJsonlIntoMessages(content, newMessages, { seenUsers });
1796
+ parsedTail = parseCodexJsonlIntoMessages(content, newMessages, { codexUserDeduper });
1797
+ } else if (options.cooperative) {
1798
+ parsedTail = await parseCodexJsonlFileIntoMessagesAsync(filePath, newMessages, {
1799
+ codexUserDeduper,
1800
+ yieldAfterMs: options.yieldAfterMs || 25,
1801
+ });
1370
1802
  } else {
1371
- parsedTail = parseCodexJsonlFileIntoMessages(filePath, newMessages, { seenUsers });
1803
+ parsedTail = parseCodexJsonlFileIntoMessages(filePath, newMessages, { codexUserDeduper });
1804
+ }
1805
+
1806
+ // Re-import storm guard: codex `importedFileSize` tracks CONSUMED bytes, which can stay below
1807
+ // parsed.fileSize indefinitely (trailing non-message lines / a partial record still being
1808
+ // written), so the candidate selector's `cacheBehind` (effectiveSize > stored file_size) never
1809
+ // converges and this importer is re-run every scan tick. When the grown tail held NO new
1810
+ // messages, the whole-conversation re-stringify + replaceSessionMessages below is pure waste
1811
+ // that saturates the db-owner worker — skip it. The resume offset (stored file_size) is left
1812
+ // unchanged, so a record that only completes later is still picked up on a subsequent pass.
1813
+ if (prevFileSize > 0 && parsed.fileSize > prevFileSize && newMessages.length === 0) {
1814
+ return false;
1372
1815
  }
1373
1816
 
1374
1817
  const allMessages = baseMessages.concat(newMessages);
@@ -1379,6 +1822,8 @@ async function _importCodexSessionFile(parsed, filePath) {
1379
1822
  const assistantMessages = allMessages.filter(m => m.role === 'assistant' && (m.text || m.content));
1380
1823
  if (allMessages.length === 0 || userMessages.length === 0) return false;
1381
1824
 
1825
+ const importedFileSize = _codexImportedFileSize(parsed.fileSize, prevFileSize, parsedTail);
1826
+
1382
1827
  const fileMeta = readCodexRolloutMetadata(filePath) || {};
1383
1828
  const meta = parsedTail.sessionMeta || fileMeta || {};
1384
1829
  const firstUser = userMessages[0]?.text || userMessages[0]?.content || '';
@@ -1404,14 +1849,45 @@ async function _importCodexSessionFile(parsed, filePath) {
1404
1849
  first_assistant_text: firstAssistant.slice(0, 500),
1405
1850
  rename_name: existing?.rename_name || '',
1406
1851
  git_branch: meta.git_branch || parsed.gitBranch || '',
1407
- file_size: parsed.fileSize,
1852
+ file_size: importedFileSize,
1408
1853
  session_created_at: meta.timestamp || parsed.timestamp || '',
1409
1854
  hostname: parsed.hostname,
1410
1855
  model_provider: modelProvider,
1411
1856
  model_id: model || (existing && existing.model_id) || '',
1857
+ import_parser_version: parserVersion,
1412
1858
  });
1413
1859
 
1414
1860
  try {
1861
+ const threadSource = String(meta.thread_source || meta.threadSource || '').trim().toLowerCase();
1862
+ const parentAgentSessionId = String(
1863
+ meta.parent_agent_session_id
1864
+ || meta.parentAgentSessionId
1865
+ || meta.parent_thread_id
1866
+ || meta.parentThreadId
1867
+ || ''
1868
+ ).trim();
1869
+ if (threadSource === 'subagent' && parentAgentSessionId) {
1870
+ db.upsertAgentSessionIdentity(sessionId, {
1871
+ provider: 'codex',
1872
+ providerResumeId: sessionId,
1873
+ cwd: projectPath,
1874
+ projectPath,
1875
+ title,
1876
+ jsonlPath: filePath,
1877
+ fileSize: parsed.fileSize,
1878
+ modifiedAt: parsed.modifiedAt,
1879
+ model,
1880
+ gitBranch: meta.git_branch || parsed.gitBranch || '',
1881
+ hostname: parsed.hostname,
1882
+ userMsgCount: userMessages.length,
1883
+ firstMessage: firstUser.slice(0, 500),
1884
+ threadSource,
1885
+ parentAgentSessionId,
1886
+ agentNickname: meta.agent_nickname || meta.agentNickname || '',
1887
+ agentRole: meta.agent_role || meta.agentRole || '',
1888
+ });
1889
+ return true;
1890
+ }
1415
1891
  const owner = db.getDb().prepare('SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?').get(sessionId);
1416
1892
  db.upsertSession(owner?.ctm_session_id || sessionId, {
1417
1893
  agentSessionId: sessionId,
@@ -1436,21 +1912,28 @@ async function _importCodexSessionFile(parsed, filePath) {
1436
1912
  return true;
1437
1913
  }
1438
1914
 
1439
- function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
1915
+ function _ingestTranscriptStoreForParsedFile(filePath, parsed, options = {}) {
1916
+ // Claude Desktop sessions use a virtual path (`…#ctm-claude-desktop=<uuid>`) that is never a
1917
+ // real file on disk — fs.statSync / ingestJsonlFile always throw ENOENT on it. Their
1918
+ // transcript data lives in the Desktop cache (read via getMessages), not the JSONL transcript
1919
+ // store, so this ingest can only ever fail for them. Skipping removes a guaranteed-failing
1920
+ // synchronous statSync that was firing ~97×/sec in a hot loop on dead desktop sessions.
1921
+ if (claudeDesktopSessions.isVirtualSessionPath(filePath)) return null;
1440
1922
  try {
1441
1923
  const size = Number(parsed?.fileSize || fs.statSync(filePath).size || 0);
1442
1924
  const agentSessionId = String(parsed?.sessionId || transcriptSourceIdFromPath(filePath) || '').trim();
1443
1925
  if (!agentSessionId) return null;
1444
1926
  const provider = normalizeTranscriptProvider(parsed?.agent || parsed?.modelProvider || '', filePath);
1445
1927
  const largeColdMode = size >= TRANSCRIPT_IMPORT_LARGE_FILE_BYTES ? 'tail' : undefined;
1928
+ const maxBytes = Math.max(256 * 1024, Number(options.transcriptMaxBytes || TRANSCRIPT_IMPORT_MAX_BYTES));
1446
1929
  const result = ingestJsonlFile(db.getDb(), {
1447
1930
  filePath,
1448
1931
  agentSessionId,
1449
1932
  ctmSessionId: agentSessionId,
1450
1933
  provider,
1451
1934
  mode: largeColdMode,
1452
- initialTailBytes: TRANSCRIPT_IMPORT_MAX_BYTES,
1453
- maxBytes: largeColdMode ? TRANSCRIPT_IMPORT_MAX_BYTES : Math.min(size || TRANSCRIPT_IMPORT_MAX_BYTES, TRANSCRIPT_IMPORT_MAX_BYTES),
1935
+ initialTailBytes: maxBytes,
1936
+ maxBytes: largeColdMode ? maxBytes : Math.min(size || maxBytes, maxBytes),
1454
1937
  });
1455
1938
  if ((result.inserted || 0) > 0 || (result.bytesRead || 0) > 0) {
1456
1939
  console.log(
@@ -1465,11 +1948,55 @@ function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
1465
1948
  }
1466
1949
  }
1467
1950
 
1468
- async function importSessionFile(filePath, projectPath, projectEntry) {
1951
+ async function _importWalleSessionFile(parsed, filePath) {
1952
+ const sessionId = String(parsed?.sessionId || '').trim();
1953
+ if (!sessionId) return false;
1954
+
1955
+ const existing = db.getSessionConversation(sessionId);
1956
+ const fileStat = await fsp.stat(filePath).catch(() => null);
1957
+ if (!fileStat || !fileStat.isFile()) return false;
1958
+ const parserStale = !!existing &&
1959
+ Number(existing.import_parser_version || 0) < WALLE_SESSION_CACHE_PARSER_VERSION;
1960
+ if (existing && existing.file_size === fileStat.size && existing.model_provider && !parserStale) {
1961
+ return false;
1962
+ }
1963
+
1964
+ const history = readWalleCtmHistory(filePath);
1965
+ if (!history.some((message) => message && message.role === 'user' && (message.content || message.text))) {
1966
+ return false;
1967
+ }
1968
+ const meta = walleTranscript.readSessionMeta(filePath) || {};
1969
+ const chatSessionId = String(meta.chatSessionId || meta.sessionId || sessionId).trim() || sessionId;
1970
+ const wrote = persistWalleSessionConversation({
1971
+ db,
1972
+ source: {
1973
+ ...parsed,
1974
+ ctmSessionId: sessionId,
1975
+ conversationSessionId: sessionId,
1976
+ agentSessionId: sessionId,
1977
+ chatSessionId,
1978
+ jsonlPath: filePath,
1979
+ cwd: meta.cwd || parsed.cwd || parsed.project || '',
1980
+ title: meta.label || parsed.title || '',
1981
+ label: meta.label || parsed.title || '',
1982
+ model_provider: meta.modelProvider || parsed.modelProvider || '',
1983
+ model_id: meta.modelId || parsed.modelId || '',
1984
+ },
1985
+ history,
1986
+ fileStat,
1987
+ hostname: parsed.hostname,
1988
+ });
1989
+ return wrote > 0;
1990
+ }
1991
+
1992
+ async function importSessionFile(filePath, projectPath, projectEntry, options = {}) {
1469
1993
  const parsed = parseSessionFile(filePath, projectPath, projectEntry);
1470
- _ingestTranscriptStoreForParsedFile(filePath, parsed);
1994
+ _ingestTranscriptStoreForParsedFile(filePath, parsed, options);
1471
1995
  if (parsed.agent === 'codex') {
1472
- return _importCodexSessionFile(parsed, filePath);
1996
+ return _importCodexSessionFile(parsed, filePath, options);
1997
+ }
1998
+ if (parsed.agent === 'walle') {
1999
+ return _importWalleSessionFile(parsed, filePath);
1473
2000
  }
1474
2001
  if (parsed.agent === claudeDesktopSessions.DESKTOP_AGENT) {
1475
2002
  const messages = claudeDesktopSessions.getMessages(parsed.sessionId) || [];
@@ -1497,8 +2024,9 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1497
2024
  file_size: parsed.fileSize,
1498
2025
  session_created_at: parsed.timestamp,
1499
2026
  hostname: parsed.hostname,
1500
- model_provider: parsed.modelProvider || (existing && existing.model_provider) || '',
2027
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1501
2028
  model_id: parsed.modelId || (existing && existing.model_id) || '',
2029
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1502
2030
  });
1503
2031
  return true;
1504
2032
  }
@@ -1542,8 +2070,16 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1542
2070
  } finally {
1543
2071
  await fh.close();
1544
2072
  }
1545
- // Carry forward existing parsed messages
1546
- try { baseMessages = JSON.parse(existing.messages || '[]'); } catch {}
2073
+ // Carry forward existing parsed messages. Phase 6: load the base from the faithful
2074
+ // per-message rows when known-complete (column read, no multi-MB JSON.parse and it
2075
+ // survives blob retirement in Phase 7); fall back to the blob when rows aren't ready.
2076
+ try {
2077
+ if (typeof db.sessionContentRowsAvailable === 'function' && db.sessionContentRowsAvailable(parsed.sessionId)) {
2078
+ baseMessages = db.getSessionMessagesArray(parsed.sessionId, { fallbackToBlob: false });
2079
+ } else {
2080
+ baseMessages = JSON.parse(existing.messages || '[]');
2081
+ }
2082
+ } catch {}
1547
2083
  baseAssistantCount = existing.assistant_msg_count || 0;
1548
2084
  } else {
1549
2085
  // Full read for new files or when file shrank (truncated/rotated)
@@ -1555,7 +2091,11 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1555
2091
  searchMessages: newSearchMessages,
1556
2092
  firstUserContent: parsedFirstUser, lastUserContent: parsedLastUser,
1557
2093
  firstAssistantText: parsedFirstAssistant, renameName: parsedRename,
1558
- } = await _parseConversationContent(content);
2094
+ summaryTitle: parsedSummaryTitle,
2095
+ } = await _parseConversationContent(content, {
2096
+ fileSessionId: claudeFileSessionId(jsonlPath),
2097
+ fileDir: path.dirname(jsonlPath),
2098
+ });
1559
2099
  const allMessages = baseMessages.concat(newMessages);
1560
2100
  const indexedMessages = prevFileSize > 0 && parsed.fileSize > prevFileSize
1561
2101
  ? _loadIndexedSessionMessages(parsed.sessionId)
@@ -1587,7 +2127,7 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1587
2127
  search_messages: allSearchMessages,
1588
2128
  user_msg_count: signals.userCount,
1589
2129
  assistant_msg_count: signals.assistantCount || baseAssistantCount + newAssistants,
1590
- title: parsed.title || (existing && existing.title) || '',
2130
+ title: parsed.title || (existing && existing.title) || parsedSummaryTitle || '',
1591
2131
  first_message: mergedFirstUser,
1592
2132
  last_user_content: mergedLastUser,
1593
2133
  first_assistant_text: mergedFirstAssistant,
@@ -1596,8 +2136,9 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
1596
2136
  file_size: parsed.fileSize,
1597
2137
  session_created_at: parsed.timestamp,
1598
2138
  hostname: parsed.hostname,
1599
- model_provider: parsed.modelProvider || (existing && existing.model_provider) || '',
2139
+ model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
1600
2140
  model_id: parsed.modelId || (existing && existing.model_id) || '',
2141
+ import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
1601
2142
  });
1602
2143
  return true;
1603
2144
  }
@@ -1653,7 +2194,10 @@ async function runIncrementalConversationImport() {
1653
2194
  try {
1654
2195
  if (error) throw error;
1655
2196
  scanned++;
1656
- if (await importSessionFile(filePath, projectPath, projectEntry)) imported++;
2197
+ if (await importSessionFile(filePath, projectPath, projectEntry, {
2198
+ cooperative: true,
2199
+ transcriptMaxBytes: _backgroundTranscriptImportMaxBytes(),
2200
+ })) imported++;
1657
2201
  const importLimited = imported >= maxImportedPerRun;
1658
2202
  const processedLimited = scanned >= maxProcessedPerRun;
1659
2203
  if ((importLimited || processedLimited) && scanned < candidates.length) {
@@ -1695,6 +2239,108 @@ async function runIncrementalConversationImport() {
1695
2239
  }
1696
2240
  }
1697
2241
 
2242
+ // --- Cursor conversation import ----------------------------------------------
2243
+ // Cursor stores conversations as a content-addressed blob graph in its own SQLite
2244
+ // store (~/.cursor/chats/<ws>/<agent>/store.db), NOT a JSONL append log. Reconstructing
2245
+ // it is expensive (blob-graph BFS + per-blob JSON.parse), so — exactly like the JSONL
2246
+ // importer — import once into session_conversations and let the normal cache read path
2247
+ // serve it, instead of reconstructing on every UI poll (the old design; see the
2248
+ // _orderedBlobRefs CPU-profile finding). Signature-gated (store size:mtime) so an unchanged
2249
+ // store is skipped; the store path is resolved once per session and cached on a CONFIDENT
2250
+ // (agentId) match. Runs on the db-owner worker (off the main event loop) like the JSONL import.
2251
+ const CURSOR_IMPORT_PARSER_VERSION = 1; // bump to force a full cursor re-import on shape change
2252
+ const _cursorImportSignatures = new Map(); // ctm_session_id → last-imported store signature
2253
+ const _cursorResolvedStorePaths = new Map(); // ctm_session_id → resolved store.db path (confident match only)
2254
+
2255
+ async function runCursorConversationImport({ cursorHome } = {}) {
2256
+ let imported = 0;
2257
+ let scanned = 0;
2258
+ let skipped = 0;
2259
+ let failed = 0;
2260
+ let total = 0;
2261
+ let cursorStore;
2262
+ try {
2263
+ cursorStore = require('./lib/cursor-conversation-store');
2264
+ } catch (e) {
2265
+ return { imported, scanned, skipped, failed, total, error: `cursor store unavailable: ${e.message}` };
2266
+ }
2267
+ try {
2268
+ const sessions = db.getDb().prepare(
2269
+ "SELECT id, cwd, project_path, created_at, updated_at FROM ctm_sessions WHERE provider = 'cursor'"
2270
+ ).all();
2271
+ total = sessions.length;
2272
+ for (const s of sessions) {
2273
+ try {
2274
+ scanned += 1;
2275
+ const ctmId = String(s.id);
2276
+ const cwd = String(s.cwd || s.project_path || '').trim();
2277
+ const agentRows = db.getDb().prepare(
2278
+ "SELECT agent_session_id FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id IS NOT NULL AND agent_session_id != ''"
2279
+ ).all(ctmId);
2280
+ const agentSessionIds = agentRows.map((r) => String(r.agent_session_id)).filter(Boolean);
2281
+
2282
+ // Resolve the store. If we already have a confident path, gate on its cheap signature
2283
+ // BEFORE the expensive reconstruct; skip when unchanged since the last import.
2284
+ let storeDbPath = _cursorResolvedStorePaths.get(ctmId) || '';
2285
+ if (storeDbPath) {
2286
+ const sig = cursorStore.statSignature(storeDbPath);
2287
+ if (sig && _cursorImportSignatures.get(ctmId) === sig) { skipped += 1; continue; }
2288
+ }
2289
+
2290
+ let store = null;
2291
+ if (storeDbPath) {
2292
+ try { store = cursorStore.loadCursorStore(storeDbPath); } catch { store = null; }
2293
+ }
2294
+ if (!store) {
2295
+ // No cached path (or its load failed): resolve via match. loadCursorStore is itself
2296
+ // size:mtime-cached, so the enumeration is cheap for idle stores.
2297
+ store = cursorStore.loadCursorConversationForSession({
2298
+ cwd, createdAt: s.created_at, updatedAt: s.updated_at, agentSessionIds, cursorHome,
2299
+ });
2300
+ if (store && store.storeDbPath) {
2301
+ storeDbPath = store.storeDbPath;
2302
+ // Persist the resolved path ONLY on a confident (agentId) match — cwd+time alone is
2303
+ // a heuristic that could pin the wrong store.
2304
+ if (store.agentId && agentSessionIds.includes(store.agentId)) {
2305
+ _cursorResolvedStorePaths.set(ctmId, storeDbPath);
2306
+ }
2307
+ }
2308
+ }
2309
+ if (!store || !Array.isArray(store.messages) || store.messages.length === 0) continue;
2310
+
2311
+ const sig = storeDbPath ? cursorStore.statSignature(storeDbPath) : '';
2312
+ if (sig && _cursorImportSignatures.get(ctmId) === sig) { skipped += 1; continue; }
2313
+
2314
+ const fields = cursorStore.cursorStoreToConversationFields(store);
2315
+ db.importSessionConversation({
2316
+ session_id: ctmId,
2317
+ ...fields,
2318
+ title: '',
2319
+ git_branch: '',
2320
+ file_size: 0, // cursor has no single jsonl footprint; freshness is gated by `sig`
2321
+ session_created_at: fields.session_created_at || s.created_at || '',
2322
+ hostname: '',
2323
+ import_parser_version: CURSOR_IMPORT_PARSER_VERSION,
2324
+ rename_name: '',
2325
+ });
2326
+ if (sig) _cursorImportSignatures.set(ctmId, sig);
2327
+ imported += 1;
2328
+ await new Promise((resolve) => setImmediate(resolve));
2329
+ } catch (e) {
2330
+ failed += 1;
2331
+ console.error(`[cursor-import] ${String(s.id).slice(0, 8)}: ${e.message}`);
2332
+ }
2333
+ }
2334
+ if (imported || failed) {
2335
+ console.log(`[cursor-import] imported ${imported}, skipped ${skipped}, failed ${failed} (scanned ${scanned}/${total})`);
2336
+ }
2337
+ return { imported, scanned, skipped, failed, total };
2338
+ } catch (e) {
2339
+ console.error('[cursor-import] Top-level error:', e.message);
2340
+ return { imported, scanned, skipped, failed, total, error: e.message };
2341
+ }
2342
+ }
2343
+
1698
2344
  function handleGetConversation(req, res, url) {
1699
2345
  const sessionId = url.pathname.split('/').pop();
1700
2346
  const conv = db.getSessionConversation(sessionId);
@@ -1741,7 +2387,7 @@ async function handleGetSettings(req, res, url) {
1741
2387
  for (const r of rows) result[r.key] = r.value;
1742
2388
  return result;
1743
2389
  }
1744
- const allKeys = ['db_path', 'images_dir', 'default_context_type', 'editor_theme', 'auto_version'];
2390
+ const allKeys = ['db_path', 'images_dir', 'backup_dir', 'default_context_type', 'editor_theme', 'auto_version'];
1745
2391
  const result = {};
1746
2392
  for (const k of allKeys) result[k] = db.getSetting(k);
1747
2393
  return result;
@@ -1759,7 +2405,11 @@ async function handlePutSettings(req, res) {
1759
2405
  const changedKeys = await withSqliteBusyRetry(() => {
1760
2406
  const keys = [];
1761
2407
  for (const [k, v] of Object.entries(data)) {
1762
- db.setSetting(k, v);
2408
+ if (k === 'backup_dir' && typeof db.setBackupDir === 'function') {
2409
+ db.setBackupDir(v, { persist: true });
2410
+ } else {
2411
+ db.setSetting(k, v);
2412
+ }
1763
2413
  keys.push(k);
1764
2414
  }
1765
2415
  return keys;
@@ -1896,29 +2546,50 @@ function handleHotkeyUninstall(req, res) {
1896
2546
 
1897
2547
  // --- Screenshot (macOS) ---
1898
2548
  async function handleScreenshot(req, res) {
2549
+ // Self-describing diagnostics for the "press hotkey / take screenshot → nothing
2550
+ // happens" reports. Logs one [screenshot] line per request with where the time
2551
+ // went (capture vs DB write), the live session count, and the write outcome —
2552
+ // so a stall can be attributed to a blocked loop, a busy lock, or a cancel.
2553
+ const t0 = Date.now();
2554
+ const { sessions, sessionEvents } = require('./server-state');
2555
+ const sessionCount = sessions ? sessions.size : -1;
2556
+ let captureMs = 0;
2557
+ let writeMs = 0;
1899
2558
  try {
1900
2559
  const { execFile } = require('child_process');
1901
2560
  const tmpFile = path.join(db.DEFAULT_IMAGES_DIR, `screenshot-${Date.now()}.png`);
1902
2561
  // Use async execFile so the event loop stays alive — this lets the browser
1903
2562
  // remain responsive and allows screencapture to work across all monitors.
2563
+ const tCap = Date.now();
1904
2564
  await new Promise((resolve, reject) => {
1905
2565
  execFile('/usr/sbin/screencapture', ['-i', tmpFile], { timeout: 30000 }, (err) => {
1906
2566
  if (err) reject(err); else resolve();
1907
2567
  });
1908
2568
  });
1909
- if (!fs.existsSync(tmpFile)) {
2569
+ captureMs = Date.now() - tCap;
2570
+ try {
2571
+ await fs.promises.access(tmpFile, fs.constants.R_OK);
2572
+ } catch (err) {
2573
+ if (err && err.code !== 'ENOENT') throw err;
2574
+ console.warn(`[screenshot] cancelled — capture=${captureMs}ms sessions=${sessionCount}`);
1910
2575
  return jsonResponse(res, 400, { error: 'Screenshot cancelled' });
1911
2576
  }
1912
- const buffer = fs.readFileSync(tmpFile);
1913
- const result = await db.saveImage(0, buffer, path.basename(tmpFile), 'image/png');
2577
+ const tWrite = Date.now();
2578
+ const result = await db.saveImageFromFile(0, tmpFile, path.basename(tmpFile), 'image/png', { deleteSource: true });
2579
+ writeMs = Date.now() - tWrite;
2580
+ if (result && result._timing) delete result._timing; // internal timing — don't emit/return it (this path has its own [screenshot] log)
1914
2581
  // If triggered by hotkey daemon, notify browser clients to open the editor
1915
2582
  const url = new URL(req.url, 'http://localhost');
1916
2583
  if (!url.searchParams.get('token')) {
1917
- const { sessionEvents } = require('./server-state');
1918
2584
  sessionEvents.emit('screenshot-captured', result);
1919
2585
  }
2586
+ console.warn(`[screenshot] ok id=${result.id} total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount}`);
1920
2587
  jsonResponse(res, 201, result);
1921
- } catch (e) { jsonResponse(res, 500, { error: e.message }); }
2588
+ } catch (e) {
2589
+ const busy = /SQLITE_BUSY|write lock|database is locked/i.test(e && e.message || '');
2590
+ console.warn(`[screenshot] FAILED total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount} busy=${busy} err=${e && e.message}`);
2591
+ jsonResponse(res, 500, { error: e.message });
2592
+ }
1922
2593
  }
1923
2594
 
1924
2595
  // --- Backups ---
@@ -1927,7 +2598,19 @@ function handleListBackups(req, res) {
1927
2598
  const dbPath = db.getDbPath();
1928
2599
  let dbSize = 0;
1929
2600
  try { dbSize = require('fs').statSync(dbPath).size; } catch {}
1930
- jsonResponse(res, 200, { backups, db_path: dbPath, backup_dir: require('path').join(require('path').dirname(dbPath), 'backups'), db_size: dbSize });
2601
+ const backupInfo = typeof db.getBackupDirInfo === 'function'
2602
+ ? db.getBackupDirInfo()
2603
+ : { backup_dir: db.getBackupDir() };
2604
+ jsonResponse(res, 200, {
2605
+ backups,
2606
+ db_path: dbPath,
2607
+ data_dir: dbPath ? path.dirname(dbPath) : '',
2608
+ backup_dir: backupInfo.backup_dir || db.getBackupDir(),
2609
+ default_backup_dir: backupInfo.default_backup_dir || '',
2610
+ configured_backup_dir: backupInfo.configured_backup_dir || '',
2611
+ backup_dir_source: backupInfo.source || 'default',
2612
+ db_size: dbSize,
2613
+ });
1931
2614
  }
1932
2615
 
1933
2616
  async function handleCreateBackup(req, res) {
@@ -1959,80 +2642,192 @@ function handleDeleteBackup(req, res, url) {
1959
2642
  jsonResponse(res, 200, { ok: true });
1960
2643
  }
1961
2644
 
1962
- // ============================================================
1963
- // Tool Permissions (reads/writes .claude/settings.local.json)
1964
- // ============================================================
1965
- // Claude Code reads pre-authorized permissions from:
1966
- // Global: ~/.claude/settings.local.json -> permissions.allow[]
1967
- // Per-project: <project>/.claude/settings.local.json -> permissions.allow[]
1968
- // Legacy: ~/.claude.json -> projects[path].allowedTools[] (session approval history, read-only)
2645
+ function _ctmStorageInfo() {
2646
+ const dbPath = db.getDbPath();
2647
+ const backupInfo = typeof db.getBackupDirInfo === 'function'
2648
+ ? db.getBackupDirInfo()
2649
+ : { backup_dir: db.getBackupDir() };
2650
+ let dbSize = 0;
2651
+ try { dbSize = require('fs').statSync(dbPath).size; } catch {}
2652
+ return {
2653
+ service: 'ctm',
2654
+ label: 'CTM',
2655
+ db_path: dbPath,
2656
+ data_dir: dbPath ? path.dirname(dbPath) : '',
2657
+ db_size: dbSize,
2658
+ backup_dir: backupInfo.backup_dir || db.getBackupDir(),
2659
+ default_backup_dir: backupInfo.default_backup_dir || '',
2660
+ configured_backup_dir: backupInfo.configured_backup_dir || '',
2661
+ backup_dir_source: backupInfo.source || 'default',
2662
+ };
2663
+ }
1969
2664
 
1970
- const CLAUDE_JSON_PATH = permissionSync.getClaudeJsonPath();
1971
- const { collectJsonRules, readJsonFile } = permissionSync;
2665
+ async function _requestWalleData(pathname, opts = {}, timeoutMs = 5000) {
2666
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
2667
+ let timer = null;
2668
+ if (controller) timer = setTimeout(() => controller.abort(), timeoutMs);
2669
+ try {
2670
+ const upstream = await walleClient.requestJson(pathname, { ...opts, signal: controller ? controller.signal : null });
2671
+ if (upstream.status < 200 || upstream.status >= 300 || upstream.json?.error) {
2672
+ const err = new Error(upstream.json?.error || upstream.body || `Wall-E returned ${upstream.status}`);
2673
+ err.status = upstream.status;
2674
+ throw err;
2675
+ }
2676
+ return upstream.json?.data || upstream.json || {};
2677
+ } finally {
2678
+ if (timer) clearTimeout(timer);
2679
+ }
2680
+ }
1972
2681
 
1973
- function importPermissionsToDb(options) {
1974
- return permissionSync.importPermissionsToDb(db, options);
2682
+ async function _storageLocations() {
2683
+ const ctm = _ctmStorageInfo();
2684
+ let walle = null;
2685
+ let walleError = '';
2686
+ try {
2687
+ walle = await _requestWalleData('/api/wall-e/storage', {}, 5000);
2688
+ walle.service = 'walle';
2689
+ walle.label = 'Wall-E';
2690
+ } catch (e) {
2691
+ walleError = e.message || String(e);
2692
+ }
2693
+ return { ctm, walle, walle_error: walleError };
1975
2694
  }
1976
2695
 
1977
- function syncDbToJsonFiles(options) {
1978
- return permissionSync.syncDbToJsonFiles(db, options);
2696
+ async function handleGetStorageLocations(req, res) {
2697
+ try {
2698
+ const locations = await _storageLocations();
2699
+ jsonResponse(res, 200, { ok: true, ...locations, migration: storageMigration.readActiveMigration() });
2700
+ } catch (e) {
2701
+ jsonResponse(res, 500, { error: e.message });
2702
+ }
1979
2703
  }
1980
2704
 
1981
- function handleGetProjects(req, res) {
1982
- const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
1983
- const projects = claudeJson.projects || {};
1984
- const dbRules = db.listPermRules({ listType: 'allow' });
1985
- const result = Object.entries(projects).map(([projectPath]) => {
1986
- const allowedTools = dbRules.filter(r => r.project === projectPath).map(r => r.rule);
1987
- return { path: projectPath, allowedTools };
2705
+ async function handlePutBackupDirs(req, res) {
2706
+ try {
2707
+ const data = await readBody(req, 64 * 1024);
2708
+ const result = await _applyBackupDirChanges(data);
2709
+ jsonResponse(res, result.ok ? 200 : 207, result);
2710
+ } catch (e) {
2711
+ jsonResponse(res, 400, { error: e.message });
2712
+ }
2713
+ }
2714
+
2715
+ async function _applyBackupDirChanges(data = {}) {
2716
+ const moveExisting = data.move_existing !== false && data.moveExisting !== false;
2717
+ const result = { ok: true, ctm: null, walle: null, errors: [] };
2718
+ if (Object.prototype.hasOwnProperty.call(data, 'ctm_backup_dir')) {
2719
+ result.ctm = db.setBackupDir(data.ctm_backup_dir || '', { persist: true, moveExisting });
2720
+ }
2721
+ if (Object.prototype.hasOwnProperty.call(data, 'walle_backup_dir')) {
2722
+ try {
2723
+ result.walle = await _requestWalleData('/api/wall-e/storage/backup-dir', {
2724
+ method: 'PUT',
2725
+ body: { backup_dir: data.walle_backup_dir || '', move_existing: moveExisting },
2726
+ }, 10000);
2727
+ } catch (e) {
2728
+ result.ok = false;
2729
+ result.errors.push({ service: 'walle', error: e.message || String(e) });
2730
+ }
2731
+ }
2732
+ return result;
2733
+ }
2734
+
2735
+ async function _buildPlanFromRequest(data) {
2736
+ const locations = await _storageLocations();
2737
+ return storageMigration.buildStorageMigrationPlan(data || {}, {
2738
+ dbModule: db,
2739
+ ctmCurrent: locations.ctm,
2740
+ walleCurrent: locations.walle || {
2741
+ data_dir: '',
2742
+ db_path: '',
2743
+ backup_dir: '',
2744
+ },
1988
2745
  });
1989
- jsonResponse(res, 200, result);
1990
2746
  }
1991
2747
 
1992
- function handleGetToolPermRules(req, res) {
1993
- // Merge any new rules Claude Code may have added to JSON files
1994
- for (const r of collectJsonRules()) {
1995
- db.addPermRule(r); // INSERT OR IGNORE
2748
+ async function handlePreviewStorageMigration(req, res) {
2749
+ try {
2750
+ const data = await readBody(req, 128 * 1024);
2751
+ const plan = await _buildPlanFromRequest(data);
2752
+ jsonResponse(res, 200, { ok: true, plan });
2753
+ } catch (e) {
2754
+ jsonResponse(res, 400, { error: e.message });
1996
2755
  }
1997
- // Read from SQLite (source of truth)
1998
- const allRules = db.listPermRules();
2756
+ }
2757
+
2758
+ async function handleApplyStorageMigration(req, res) {
2759
+ try {
2760
+ const data = await readBody(req, 128 * 1024);
2761
+ if (data.confirm !== true) return jsonResponse(res, 400, { error: 'confirmation_required' });
2762
+ const plan = await _buildPlanFromRequest(data);
2763
+ if (!plan.requires_restart) {
2764
+ const services = data.services || {};
2765
+ const backupInput = {
2766
+ move_existing: services.ctm?.move_backups !== false && services.walle?.move_backups !== false,
2767
+ };
2768
+ if (services.ctm && (Object.prototype.hasOwnProperty.call(services.ctm, 'backup_dir') || Object.prototype.hasOwnProperty.call(services.ctm, 'backupDir'))) {
2769
+ backupInput.ctm_backup_dir = services.ctm.backup_dir || services.ctm.backupDir || '';
2770
+ }
2771
+ if (services.walle && (Object.prototype.hasOwnProperty.call(services.walle, 'backup_dir') || Object.prototype.hasOwnProperty.call(services.walle, 'backupDir'))) {
2772
+ backupInput.walle_backup_dir = services.walle.backup_dir || services.walle.backupDir || '';
2773
+ }
2774
+ const backup = await _applyBackupDirChanges(backupInput);
2775
+ return jsonResponse(res, backup.ok ? 200 : 207, { ok: backup.ok, plan, backup });
2776
+ }
2777
+ const started = storageMigration.spawnStorageMigrationSupervisor(plan);
2778
+ jsonResponse(res, 202, { ok: true, plan, migration: started });
2779
+ } catch (e) {
2780
+ jsonResponse(res, 400, { error: e.message });
2781
+ }
2782
+ }
1999
2783
 
2784
+ // ============================================================
2785
+ // Tool Permissions — CTM's standalone, global permission config.
2786
+ // ============================================================
2787
+ // CTM permissions live ONLY in the CTM DB (perm_rules table) and are global
2788
+ // (apply to all projects/sessions). They are intentionally decoupled from
2789
+ // Claude Code / Codex settings files — CTM no longer reads or writes
2790
+ // ~/.claude/settings*.json, project .claude/settings.json, or ~/.claude.json.
2791
+ // The shadow approver (approval-agent.js + lib/permission-match.js) enforces them.
2792
+
2793
+ function handleGetProjects(req, res) {
2794
+ // CTM permissions are global — no per-project scoping, no Claude config read.
2795
+ jsonResponse(res, 200, []);
2796
+ }
2797
+
2798
+ async function handleGetToolPermRules(req, res) {
2799
+ // CTM permissions are a standalone GLOBAL config stored only in the CTM DB —
2800
+ // no reading from Claude/Codex settings files. Every rule is global.
2801
+ const allRules = db.listPermRules();
2000
2802
  const rules = [];
2001
2803
  const denyRules = [];
2002
- const projectSet = new Set();
2003
-
2004
2804
  for (const r of allRules) {
2005
- const entry = { scope: r.scope, project: r.project, rule: r.rule };
2006
- if (r.list_type === 'allow') rules.push(entry);
2007
- else denyRules.push(entry);
2008
- if (r.project !== '__global__') projectSet.add(r.project);
2009
- }
2010
-
2011
- // Also include projects from ~/.claude.json that might not have rules
2012
- const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
2013
- for (const p of Object.keys(claudeJson.projects || {})) {
2014
- projectSet.add(p);
2805
+ const entry = { scope: 'global', project: '__global__', rule: r.rule };
2806
+ if (r.list_type === 'deny') denyRules.push(entry);
2807
+ else rules.push(entry);
2015
2808
  }
2016
-
2017
- jsonResponse(res, 200, { rules, denyRules, projects: Array.from(projectSet) });
2809
+ jsonResponse(res, 200, { rules, denyRules, projects: [] });
2018
2810
  }
2019
2811
 
2020
2812
  async function handleSetToolPermRules(req, res) {
2021
2813
  try {
2022
2814
  const body = await readBody(req);
2023
- const { action, rule, project, listType } = body;
2815
+ const { action, rule, listType } = body;
2024
2816
  const lt = listType === 'deny' ? 'deny' : 'allow';
2025
- const proj = project || '__global__';
2026
- const scope = proj === '__global__' ? 'global' : 'project';
2027
2817
 
2028
2818
  if (action === 'add') {
2029
- db.addPermRule({ rule, listType: lt, scope, project: proj });
2819
+ // Permission policy: rules apply globally to ALL projects. Every add is
2820
+ // stored as a global rule (~/.claude/settings.json) regardless of the UI
2821
+ // scope, so a permission granted once is honored everywhere.
2822
+ db.addPermRule({ rule, listType: lt, scope: 'global', project: '__global__' });
2030
2823
  } else if (action === 'remove') {
2031
- db.removePermRule({ rule, listType: lt, project: proj });
2824
+ // Rules are global; remove the global row (legacy per-project rows are
2825
+ // collapsed to global by the db migration).
2826
+ db.removePermRule({ rule, listType: lt, project: '__global__' });
2032
2827
  }
2033
2828
 
2034
- // Sync DB JSON files
2035
- syncDbToJsonFiles();
2829
+ // CTM permissions are a standalone config in the CTM DB intentionally NOT
2830
+ // synced to Claude/Codex settings files. The shadow approver enforces them.
2036
2831
 
2037
2832
  jsonResponse(res, 200, { ok: true });
2038
2833
  } catch (e) {
@@ -2569,20 +3364,21 @@ function handleListQueues(req, res) {
2569
3364
  async function handleCreateQueue(req, res) {
2570
3365
  try {
2571
3366
  const body = await readBody(req);
2572
- const { sessionId, mode, items, idleTimeoutMs, autoStart } = body;
3367
+ const { sessionId, mode, items, idleTimeoutMs, autoStart, append, strategy } = body;
2573
3368
  if (!sessionId || !Array.isArray(items) || items.length === 0) {
2574
3369
  return jsonResponse(res, 400, { error: 'sessionId and non-empty items[] required' });
2575
3370
  }
2576
- let state = queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
2577
- if (autoStart) {
2578
- state = queueEngine.start(sessionId) || state;
2579
- }
3371
+ const appendMode = append === true || strategy === 'append';
3372
+ let state = appendMode
3373
+ ? queueEngine.appendItems(sessionId, { mode, items, idleTimeoutMs, autoStart })
3374
+ : queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
3375
+ if (!appendMode && autoStart) state = queueEngine.start(sessionId) || state;
2580
3376
  jsonResponse(res, 201, state);
2581
3377
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
2582
3378
  }
2583
3379
 
2584
3380
  function handleGetQueue(req, res, sessionId) {
2585
- const state = queueEngine.getState(sessionId);
3381
+ const state = queueEngine.getState(sessionId) || queueEngine.getPersistedState(sessionId);
2586
3382
  if (!state) {
2587
3383
  res.writeHead(204);
2588
3384
  res.end();
@@ -2602,8 +3398,23 @@ async function handleQueueAction(req, res, sessionId, action) {
2602
3398
  case 'start': state = queueEngine.start(sessionId); break;
2603
3399
  case 'pause': state = queueEngine.pause(sessionId); break;
2604
3400
  case 'resume': state = queueEngine.resume(sessionId); break;
2605
- case 'next': state = queueEngine.sendNext(sessionId); break;
3401
+ case 'next': state = queueEngine.wake(sessionId, 'manual-next'); break;
2606
3402
  case 'skip': state = queueEngine.skip(sessionId); break;
3403
+ case 'remove': {
3404
+ try {
3405
+ const body = await readBody(req);
3406
+ state = queueEngine.removeItem(sessionId, String(body.itemId || body.id || ''));
3407
+ } catch (e) { return jsonResponse(res, 400, { error: e.message }); }
3408
+ break;
3409
+ }
3410
+ case 'reorder': {
3411
+ try {
3412
+ const body = await readBody(req);
3413
+ const ids = Array.isArray(body.order) ? body.order : (Array.isArray(body.ids) ? body.ids : []);
3414
+ state = queueEngine.reorderItems(sessionId, ids.map(String));
3415
+ } catch (e) { return jsonResponse(res, 400, { error: e.message }); }
3416
+ break;
3417
+ }
2607
3418
  case 'stop': state = queueEngine.stop(sessionId); break;
2608
3419
  case 'mode': {
2609
3420
  try {
@@ -2613,7 +3424,13 @@ async function handleQueueAction(req, res, sessionId, action) {
2613
3424
  break;
2614
3425
  }
2615
3426
  }
2616
- if (!state) return jsonResponse(res, 404, { error: 'No queue for this session' });
3427
+ if (!state) {
3428
+ const persisted = queueEngine.getPersistedState(sessionId);
3429
+ if (action === 'next' && persisted && persisted.status === 'done') {
3430
+ return jsonResponse(res, 200, persisted);
3431
+ }
3432
+ return jsonResponse(res, 404, { error: 'No queue for this session' });
3433
+ }
2617
3434
  jsonResponse(res, 200, state);
2618
3435
  }
2619
3436
 
@@ -2651,6 +3468,35 @@ function handleDeleteQueueLinkedItems(req, res, promptId) {
2651
3468
 
2652
3469
  // --- Queue Draft (per-session builder state persisted to DB) ---
2653
3470
 
3471
+ function queueDraftStatusTimestamp(value) {
3472
+ const n = Number(value);
3473
+ return Number.isFinite(n) && n > 0 ? n : 0;
3474
+ }
3475
+
3476
+ function mergeQueueDraftItemsByStatusTimestamp(existingItems, incomingItems) {
3477
+ const incoming = Array.isArray(incomingItems) ? incomingItems : [];
3478
+ const existing = Array.isArray(existingItems) ? existingItems : [];
3479
+ const existingById = new Map();
3480
+ for (const item of existing) {
3481
+ if (item && item.id) existingById.set(item.id, item);
3482
+ }
3483
+ return incoming.map((item) => {
3484
+ if (!item || typeof item !== 'object') return item;
3485
+ const previous = item.id ? existingById.get(item.id) : null;
3486
+ if (!previous) return item;
3487
+ const previousStatusAt = queueDraftStatusTimestamp(previous.statusUpdatedAt);
3488
+ const incomingStatusAt = queueDraftStatusTimestamp(item.statusUpdatedAt);
3489
+ if (previousStatusAt > incomingStatusAt && previous.status) {
3490
+ return {
3491
+ ...item,
3492
+ status: previous.status,
3493
+ statusUpdatedAt: previous.statusUpdatedAt,
3494
+ };
3495
+ }
3496
+ return item;
3497
+ });
3498
+ }
3499
+
2654
3500
  function handleGetQueueDraft(req, res, sessionId) {
2655
3501
  const key = 'queue_draft_' + sessionId;
2656
3502
  const draft = db.getSetting(key, { items: [], mode: 'manual' });
@@ -2662,7 +3508,10 @@ async function handleSaveQueueDraft(req, res, sessionId) {
2662
3508
  const body = await readBody(req);
2663
3509
  const key = 'queue_draft_' + sessionId;
2664
3510
  const existing = db.getSetting(key, { items: [], mode: 'manual' });
2665
- if (body.items !== undefined) existing.items = body.items;
3511
+ if (body.items !== undefined) {
3512
+ existing.items = mergeQueueDraftItemsByStatusTimestamp(existing.items, body.items);
3513
+ if (body.savedAt !== undefined) existing.savedAt = body.savedAt;
3514
+ }
2666
3515
  if (body.mode !== undefined) existing.mode = body.mode;
2667
3516
  db.setSetting(key, existing);
2668
3517
  jsonResponse(res, 200, { ok: true });
@@ -2684,6 +3533,94 @@ function handleListApprovalRules(req, res) {
2684
3533
  });
2685
3534
  }
2686
3535
 
3536
+ // Grouping key for an escalation row: prefer the recorded signature; for legacy
3537
+ // rows (recorded before signatures were stored) derive it from the captured
3538
+ // context via the same helper the approver uses, so old + new rows group together.
3539
+ function _escalationKeyFn(row) {
3540
+ if (row && row.command_signature) return row.command_signature;
3541
+ try {
3542
+ return approvalAgent.escalationCommandParts({
3543
+ toolName: row && row.tool_name,
3544
+ command: '',
3545
+ fullContext: row && row.full_context,
3546
+ }).signature;
3547
+ } catch { return ''; }
3548
+ }
3549
+
3550
+ // GET /api/approval-escalations — escalated commands grouped by TYPE for the
3551
+ // Permission "Needs Review" surface (collapses a huge pile into a few reviewable
3552
+ // types, secrets masked).
3553
+ function handleListEscalations(req, res) {
3554
+ try {
3555
+ const rows = db.getPendingEscalations() || [];
3556
+ const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
3557
+ jsonResponse(res, 200, { groups, total: rows.length, typeCount: groups.length });
3558
+ } catch (e) {
3559
+ console.error('[escalations] list error:', e.message);
3560
+ jsonResponse(res, 500, { error: e.message });
3561
+ }
3562
+ }
3563
+
3564
+ // POST /api/approval-escalations/resolve — resolve a whole TYPE at once.
3565
+ // body: { action: 'whitelist'|'block'|'dismiss'|'dismiss-all', ids:[...], rule? }
3566
+ // whitelist/block create an authoritative perm_rules allow/deny (honored by the
3567
+ // approver + shown in the rule list); all matching escalated rows are cleared.
3568
+ async function handleResolveEscalations(req, res) {
3569
+ try {
3570
+ const body = await readBody(req);
3571
+ const action = String(body.action || '').toLowerCase();
3572
+ const rule = String(body.rule || '').trim();
3573
+ if (!['whitelist', 'block', 'dismiss', 'dismiss-all', 'approve-all'].includes(action)) {
3574
+ return jsonResponse(res, 400, { error: 'invalid_action' });
3575
+ }
3576
+ // Bulk approve: whitelist EVERY pending type (one allow rule per group from its
3577
+ // suggested narrow pattern) and clear the queue. Re-group server-side so the
3578
+ // action is atomic and can't be skewed by a stale client view.
3579
+ if (action === 'approve-all') {
3580
+ const rows = db.getPendingEscalations() || [];
3581
+ const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
3582
+ let resolved = 0;
3583
+ let rulesCreated = 0;
3584
+ for (const g of groups) {
3585
+ const r = String(g.suggestedRule || '').trim();
3586
+ if (/^[A-Za-z]+\([^)]*\)$/.test(r)) {
3587
+ try {
3588
+ db.addPermRule({ rule: r, listType: 'allow', scope: 'global', project: '__global__' });
3589
+ rulesCreated += 1;
3590
+ } catch (e) { /* rule may already exist — still clear the rows below */ }
3591
+ }
3592
+ for (const id of (g.ids || [])) {
3593
+ try { db.resolveApprovalDecision(id, 'approved'); resolved += 1; } catch (e) { /* skip */ }
3594
+ }
3595
+ }
3596
+ return jsonResponse(res, 200, { ok: true, action, resolved, rulesCreated, typeCount: groups.length });
3597
+ }
3598
+ let createdRule = null;
3599
+ if (action === 'whitelist' || action === 'block') {
3600
+ if (!/^[A-Za-z]+\([^)]*\)$/.test(rule)) {
3601
+ return jsonResponse(res, 400, { error: 'rule must look like Bash(cmd:*)' });
3602
+ }
3603
+ db.addPermRule({ rule, listType: action === 'whitelist' ? 'allow' : 'deny', scope: 'global', project: '__global__' });
3604
+ createdRule = rule;
3605
+ }
3606
+ let targetIds;
3607
+ if (action === 'dismiss-all') {
3608
+ targetIds = (db.getPendingEscalations() || []).map((r) => r.id);
3609
+ } else {
3610
+ targetIds = (Array.isArray(body.ids) ? body.ids : []).map(Number).filter(Number.isFinite);
3611
+ if (!targetIds.length) return jsonResponse(res, 400, { error: 'ids_required' });
3612
+ }
3613
+ const resolveDecision = action === 'block' ? 'denied' : action === 'whitelist' ? 'approved' : 'dismissed';
3614
+ let resolved = 0;
3615
+ for (const id of targetIds) {
3616
+ try { db.resolveApprovalDecision(id, resolveDecision); resolved += 1; } catch (e) { /* skip */ }
3617
+ }
3618
+ jsonResponse(res, 200, { ok: true, action, resolved, rule: createdRule });
3619
+ } catch (e) {
3620
+ jsonResponse(res, 400, { error: e.message });
3621
+ }
3622
+ }
3623
+
2687
3624
  async function handleUpsertApprovalRule(req, res) {
2688
3625
  try {
2689
3626
  const body = await readBody(req);
@@ -2737,23 +3674,84 @@ async function handleResolveApprovalDecision(req, res, id) {
2737
3674
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
2738
3675
  }
2739
3676
 
2740
- // --- Dangerous-command blocklist (opt-in) ---
2741
- // GET /api/approval/blocklist -> { enabled: bool, patterns: [...] }
2742
- // POST /api/approval/blocklist { enabled: bool } -> { enabled }
3677
+ // --- Dangerous-command blocklist (configurable; default ON) ---
3678
+ // GET /api/approval/blocklist
3679
+ // -> { enabled, patterns: [defaults], disabledIds: [int], custom: [{source,flags,reason,category,id}] }
3680
+ // POST /api/approval/blocklist { enabled?, disabledIds?, customPatterns? }
3681
+ // -> the same shape as GET (the merged view). Any field omitted is left as-is.
3682
+ // Custom patterns are validated server-side; an invalid one returns 400 and
3683
+ // nothing is persisted. The hard floor is editable but never silently broken.
3684
+ //
3685
+ // Persistence: `approval_blocklist_enabled` (bool) + `approval_blocklist_config`
3686
+ // ({ disabledIds:[int], customPatterns:[{source,flags,reason,category}] }).
3687
+ // The agent reads both on every check (approval-agent isBlocklistEnabled /
3688
+ // getBlocklistConfig), so edits take effect without a restart.
3689
+ function _blocklistView() {
3690
+ const { PATTERN_META } = require('./workers/approval-blocklist');
3691
+ const enabled = db.getSetting('approval_blocklist_enabled', true) !== false;
3692
+ const cfg = db.getSetting('approval_blocklist_config', null) || {};
3693
+ const disabledIds = Array.isArray(cfg.disabledIds)
3694
+ ? cfg.disabledIds.filter((n) => Number.isInteger(n) && n >= 0 && n < PATTERN_META.length)
3695
+ : [];
3696
+ const custom = Array.isArray(cfg.customPatterns)
3697
+ ? cfg.customPatterns.map((p, i) => ({
3698
+ id: (p && p.id != null) ? p.id : `c${i}`,
3699
+ source: String((p && p.source) || ''),
3700
+ flags: String((p && p.flags) || ''),
3701
+ reason: String((p && p.reason) || 'Custom blocklist pattern'),
3702
+ category: String((p && p.category) || 'custom'),
3703
+ }))
3704
+ : [];
3705
+ return { enabled, patterns: PATTERN_META, disabledIds, custom };
3706
+ }
2743
3707
  function handleGetBlocklist(_req, res) {
2744
3708
  try {
2745
- const { PATTERN_META } = require('./workers/approval-blocklist');
2746
- const enabled = !!db.getSetting('approval_blocklist_enabled', false);
2747
- jsonResponse(res, 200, { enabled, patterns: PATTERN_META });
3709
+ jsonResponse(res, 200, _blocklistView());
2748
3710
  } catch (e) { jsonResponse(res, 500, { error: e.message }); }
2749
3711
  }
2750
3712
  async function handleSetBlocklistEnabled(req, res) {
2751
3713
  try {
2752
- const body = await readBody(req);
2753
- const enabled = !!body.enabled;
2754
- db.setSetting('approval_blocklist_enabled', enabled);
2755
- console.log(`[approval-blocklist] enabled=${enabled}`);
2756
- jsonResponse(res, 200, { enabled });
3714
+ const { validateUserPattern, PATTERN_META } = require('./workers/approval-blocklist');
3715
+ const body = await readBody(req) || {};
3716
+
3717
+ if (Object.prototype.hasOwnProperty.call(body, 'enabled')) {
3718
+ db.setSetting('approval_blocklist_enabled', !!body.enabled);
3719
+ console.log(`[approval-blocklist] enabled=${!!body.enabled}`);
3720
+ }
3721
+
3722
+ const touchesConfig = Object.prototype.hasOwnProperty.call(body, 'disabledIds')
3723
+ || Object.prototype.hasOwnProperty.call(body, 'customPatterns');
3724
+ if (touchesConfig) {
3725
+ const cfg = db.getSetting('approval_blocklist_config', null) || {};
3726
+
3727
+ if (Object.prototype.hasOwnProperty.call(body, 'disabledIds')) {
3728
+ if (!Array.isArray(body.disabledIds)) return jsonResponse(res, 400, { error: 'disabledIds must be an array of pattern ids' });
3729
+ const ids = [];
3730
+ for (const raw of body.disabledIds) {
3731
+ const n = Number(raw);
3732
+ if (!Number.isInteger(n) || n < 0 || n >= PATTERN_META.length) return jsonResponse(res, 400, { error: `invalid pattern id: ${raw}` });
3733
+ if (!ids.includes(n)) ids.push(n);
3734
+ }
3735
+ cfg.disabledIds = ids;
3736
+ }
3737
+
3738
+ if (Object.prototype.hasOwnProperty.call(body, 'customPatterns')) {
3739
+ if (!Array.isArray(body.customPatterns)) return jsonResponse(res, 400, { error: 'customPatterns must be an array' });
3740
+ if (body.customPatterns.length > 200) return jsonResponse(res, 400, { error: 'too many custom patterns (max 200)' });
3741
+ const normalized = [];
3742
+ for (let i = 0; i < body.customPatterns.length; i++) {
3743
+ const v = validateUserPattern(body.customPatterns[i]);
3744
+ if (!v.ok) return jsonResponse(res, 400, { error: `pattern ${i + 1}: ${v.error}` });
3745
+ normalized.push(v.normalized); // { source, flags, reason, category }
3746
+ }
3747
+ cfg.customPatterns = normalized;
3748
+ }
3749
+
3750
+ db.setSetting('approval_blocklist_config', cfg);
3751
+ console.log(`[approval-blocklist] config updated: ${(cfg.disabledIds || []).length} disabled, ${(cfg.customPatterns || []).length} custom`);
3752
+ }
3753
+
3754
+ jsonResponse(res, 200, _blocklistView());
2757
3755
  } catch (e) { jsonResponse(res, 400, { error: e.message }); }
2758
3756
  }
2759
3757
 
@@ -3036,22 +4034,53 @@ function handlePromptQuality(req, res, url) {
3036
4034
  jsonResponse(res, 200, harvest.assessPromptQuality(text));
3037
4035
  }
3038
4036
 
3039
- function handleListExecutions(req, res, url) {
4037
+ // Injected by server.js: run the list query on the read-pool (off the main loop)
4038
+ // and resolve to the executions array, or null when the pool is unavailable so we
4039
+ // fall back to the main-thread query. SELECT * with a large LIMIT blocked the loop
4040
+ // ~3.9s on the request path per a CPU profile.
4041
+ let _promptExecutionsOffThread = null;
4042
+ function setPromptExecutionsOffThread(fn) { _promptExecutionsOffThread = typeof fn === 'function' ? fn : null; }
4043
+
4044
+ async function handleListExecutions(req, res, url) {
4045
+ const opts = {
4046
+ limit: url.searchParams.get('limit'),
4047
+ role: url.searchParams.get('role') || null,
4048
+ project: url.searchParams.get('project') || null,
4049
+ after: url.searchParams.get('after') || url.searchParams.get('since') || null,
4050
+ order: url.searchParams.get('order') || 'desc',
4051
+ };
3040
4052
  try {
3041
- const d = db.getDb();
3042
- const limit = parseInt(url.searchParams.get('limit')) || 50;
3043
- const role = url.searchParams.get('role') || null;
3044
- const project = url.searchParams.get('project') || null;
3045
- let sql = 'SELECT * FROM prompt_executions WHERE 1=1';
3046
- const params = [];
3047
- if (role) { sql += ' AND role = ?'; params.push(role); }
3048
- if (project) { sql += ' AND project_path LIKE ?'; params.push('%' + project + '%'); }
3049
- sql += ' ORDER BY executed_at DESC LIMIT ?';
3050
- params.push(limit);
3051
- jsonResponse(res, 200, { executions: d.prepare(sql).all(...params) });
4053
+ if (_promptExecutionsOffThread) {
4054
+ const executions = await _promptExecutionsOffThread(opts);
4055
+ if (executions) return jsonResponse(res, 200, { executions });
4056
+ // null pool unavailable; fall through to the main-thread query.
4057
+ }
4058
+ jsonResponse(res, 200, { executions: queryPromptExecutions(db.getDb(), opts) });
3052
4059
  } catch (e) { jsonResponse(res, 200, { executions: [] }); }
3053
4060
  }
3054
4061
 
4062
+ function handlePromptExecutionStats(req, res) {
4063
+ try {
4064
+ const d = db.getDb();
4065
+ const totalSessions = d.prepare('SELECT COUNT(DISTINCT session_id) AS count FROM prompt_executions').get().count;
4066
+ const totalPairs = d.prepare(`
4067
+ SELECT COUNT(*) AS count FROM prompt_executions u
4068
+ JOIN prompt_executions a ON a.session_id = u.session_id AND a.message_index = u.message_index + 1
4069
+ WHERE u.role = 'user' AND a.role = 'assistant'
4070
+ AND length(u.message_text) >= 20 AND length(a.message_text) >= 20
4071
+ `).get().count;
4072
+ const multiTurnSessions = d.prepare(`
4073
+ SELECT COUNT(*) AS count FROM (
4074
+ SELECT session_id FROM prompt_executions WHERE role = 'user'
4075
+ GROUP BY session_id HAVING COUNT(*) >= 2
4076
+ )
4077
+ `).get().count;
4078
+ jsonResponse(res, 200, { totalSessions, totalPairs, multiTurnSessions });
4079
+ } catch (e) {
4080
+ jsonResponse(res, 200, { totalSessions: 0, totalPairs: 0, multiTurnSessions: 0, error: e.message });
4081
+ }
4082
+ }
4083
+
3055
4084
  function handleSessionExecutions(req, res, sessionId) {
3056
4085
  try {
3057
4086
  const result = harvest.getPromptsForSession(sessionId);
@@ -3190,30 +4219,28 @@ async function handleImprovePrompt(req, res) {
3190
4219
 
3191
4220
  function handleSkillAutocomplete(req, res, url) {
3192
4221
  try {
3193
- const query = (url.searchParams.get('q') || '').toLowerCase();
4222
+ const query = url.searchParams.get('q') || '';
3194
4223
  const agent = normalizeSkillAutocompleteAgent(url.searchParams.get('agent') || url.searchParams.get('agentType'));
3195
4224
  const cwd = url.searchParams.get('cwd') || '';
3196
4225
  const includeSessionSeen = !agent || agent === 'all' || url.searchParams.get('includeSession') === '1';
3197
4226
  const skills = buildSkillAutocompleteItems({ cwd, agent, includeSessionSeen });
3198
4227
 
3199
- let filtered = skills;
3200
- if (query) {
3201
- filtered = skills.filter(s => fuzzySkillMatch(s.name, query));
3202
- }
3203
-
3204
- filtered.sort((a, b) => {
3205
- if (b.frequency !== a.frequency) return b.frequency - a.frequency;
3206
- if (agent) {
3207
- const sourceDelta = skillAutocomplete.skillSourcePriorityForAgent(a.source, agent) - skillAutocomplete.skillSourcePriorityForAgent(b.source, agent);
3208
- if (sourceDelta) return sourceDelta;
3209
- }
3210
- return a.name.localeCompare(b.name);
3211
- });
4228
+ const filtered = skillAutocomplete.filterAndSortSkillAutocompleteItems(skills, query, agent || 'all');
3212
4229
 
3213
4230
  jsonResponse(res, 200, filtered.slice(0, 20));
3214
4231
  } catch (e) { jsonResponse(res, 500, { error: e.message }); }
3215
4232
  }
3216
4233
 
4234
+ function handleSkillResolveIntent(req, res, url) {
4235
+ try {
4236
+ const intent = url.searchParams.get('intent') || '';
4237
+ const agent = normalizeSkillAutocompleteAgent(url.searchParams.get('agent') || url.searchParams.get('agentType'));
4238
+ const cwd = url.searchParams.get('cwd') || '';
4239
+ const result = skillIntentResolver.resolveSkillIntent({ intent, agent, cwd });
4240
+ jsonResponse(res, result.ok === false ? 400 : 200, result);
4241
+ } catch (e) { jsonResponse(res, 500, { ok: false, error: e.message }); }
4242
+ }
4243
+
3217
4244
  function normalizeSkillAutocompleteAgent(value) {
3218
4245
  return skillAutocomplete.normalizeSkillAgentType(value);
3219
4246
  }
@@ -3231,6 +4258,8 @@ function buildSkillAutocompleteItems({ cwd, agent, includeSessionSeen }) {
3231
4258
  description: skill.description || '',
3232
4259
  source: skill.source || 'skill',
3233
4260
  agents: Array.isArray(skill.agents) ? skill.agents : [],
4261
+ capabilities: Array.isArray(skill.capabilities) ? skill.capabilities : [],
4262
+ invocation: skill.invocation || '',
3234
4263
  execution: skill.execution || '',
3235
4264
  path: skill.filePath || skill.path || '',
3236
4265
  frequency: 0,
@@ -3268,14 +4297,6 @@ function sanitizeSkillAutocompleteName(value) {
3268
4297
  return /^[A-Za-z0-9_.:-]{1,80}$/.test(name) ? name : '';
3269
4298
  }
3270
4299
 
3271
- function fuzzySkillMatch(name, query) {
3272
- let qi = 0;
3273
- for (const ch of String(name || '').toLowerCase()) {
3274
- if (qi < query.length && ch === query[qi]) qi++;
3275
- }
3276
- return qi === query.length;
3277
- }
3278
-
3279
4300
  function handleHarvestStats(req, res) {
3280
4301
  try {
3281
4302
  const state = harvest.getHarvestState();
@@ -3326,4 +4347,4 @@ function safeParse(json, fallback) {
3326
4347
  try { return JSON.parse(json); } catch { return fallback; }
3327
4348
  }
3328
4349
 
3329
- module.exports = { handlePromptApi, queueEngine, importPermissionsToDb, runIncrementalConversationImport, importSessionFile, setUiPrefsBroadcaster, setDbMaintenanceRunner };
4350
+ module.exports = { handlePromptApi, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile };