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
@@ -1,4 +1,15 @@
1
1
  // --- WALL-E Brain: SQLite Database Layer (WAL mode, better-sqlite3) ---
2
+ const {
3
+ assertNoForeignSqliteOwner,
4
+ } = require('../shared/sqlite-owner-guard');
5
+
6
+ assertNoForeignSqliteOwner({
7
+ blockedEnv: 'CTM_PROCESS_ROLE',
8
+ overrideEnv: 'WALL_E_ALLOW_EMBEDDED_BRAIN_IN_CTM',
9
+ databaseLabel: 'Wall-E brain DB',
10
+ ownerLabel: 'Wall-E',
11
+ });
12
+
2
13
  const Database = require('better-sqlite3');
3
14
  const path = require('path');
4
15
  const fs = require('fs');
@@ -9,6 +20,16 @@ const { createWriteAuditLog } = require('./db/write-audit');
9
20
  const { inferProviderFromModel, normalizeEvalProvider } = require('./eval/provider-normalizer');
10
21
  const { capabilitiesForOllamaModel } = require('./llm/ollama');
11
22
  const { findSupportedModel, supportedModelIdsForProvider } = require('./llm/supported-models');
23
+ const { isPortkeyProviderConfig } = require('./llm/portkey');
24
+ const {
25
+ enforceSqliteStoragePolicy,
26
+ } = require('../shared/sqlite-storage-policy');
27
+ const {
28
+ installSqliteWriteLock,
29
+ } = require('../shared/sqlite-write-lock');
30
+ const {
31
+ createSqliteOwnerWriteQueue,
32
+ } = require('../shared/sqlite-owner-write-queue');
12
33
  const _brainEvents = new EventEmitter();
13
34
  _brainEvents.setMaxListeners(20);
14
35
  let _stripNoise;
@@ -28,16 +49,124 @@ configureDevboxGateway();
28
49
 
29
50
  const DATA_DIR = process.env.WALL_E_DATA_DIR || path.join(process.env.HOME, '.walle', 'data');
30
51
  const DEFAULT_DB_PATH = path.join(DATA_DIR, 'wall-e-brain.db');
31
- const BACKUP_DIR = path.join(DATA_DIR, 'backups');
52
+ const DEFAULT_BACKUP_DIR = _normalizeUserPath(process.env.WALL_E_BACKUP_DIR || path.join(DATA_DIR, 'backups'));
53
+ let BACKUP_DIR = DEFAULT_BACKUP_DIR;
54
+ let BACKUP_DIR_SOURCE = process.env.WALL_E_BACKUP_DIR ? 'env' : 'default';
32
55
  const writeAuditLog = createWriteAuditLog({ dataDir: DATA_DIR });
33
56
  const { logWrite } = writeAuditLog;
34
57
 
35
58
  let db = null;
36
59
  let currentDbPath = null;
60
+ let ownerWriteQueue = null;
37
61
  let _daemonOwned = false; // When true, closeDb() is a no-op (daemon manages lifecycle)
62
+ let storageRisk = null;
63
+
64
+ function _normalizeUserPath(value) {
65
+ const raw = String(value || '').trim().replace(/[\r\n\0]/g, '');
66
+ if (!raw) return '';
67
+ const expanded = raw === '~'
68
+ ? process.env.HOME
69
+ : raw.replace(/^~(?=\/|$)/, process.env.HOME || '~');
70
+ return path.resolve(expanded);
71
+ }
72
+
73
+ function _ensureBackupDir(dir) {
74
+ const target = _normalizeUserPath(dir || BACKUP_DIR || DEFAULT_BACKUP_DIR);
75
+ if (!target) throw new Error('Backup directory is empty');
76
+ fs.mkdirSync(target, { recursive: true });
77
+ const stat = fs.statSync(target);
78
+ if (!stat.isDirectory()) throw new Error('Backup path is not a directory');
79
+ return target;
80
+ }
81
+
82
+ function _moveFileAcrossDevices(src, dst) {
83
+ try {
84
+ fs.renameSync(src, dst);
85
+ return;
86
+ } catch (err) {
87
+ if (err && err.code !== 'EXDEV') throw err;
88
+ }
89
+ fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL);
90
+ fs.unlinkSync(src);
91
+ }
92
+
93
+ function _wallEBackupFile(name) {
94
+ return /^wall-e-brain-.+\.db$/i.test(String(name || ''));
95
+ }
96
+
97
+ function moveBackupsToDir(targetDir, opts = {}) {
98
+ const previous = _backupDirForCurrentDb();
99
+ const target = _ensureBackupDir(targetDir || DEFAULT_BACKUP_DIR);
100
+ const moved = [];
101
+ const skipped = [];
102
+ if (path.resolve(previous) !== path.resolve(target) && fs.existsSync(previous)) {
103
+ for (const name of fs.readdirSync(previous)) {
104
+ if (!_wallEBackupFile(name)) continue;
105
+ const src = path.join(previous, name);
106
+ const dst = path.join(target, name);
107
+ try {
108
+ if (fs.existsSync(dst)) {
109
+ skipped.push({ name, reason: 'exists' });
110
+ continue;
111
+ }
112
+ _moveFileAcrossDevices(src, dst);
113
+ moved.push(name);
114
+ } catch (e) {
115
+ skipped.push({ name, reason: e.message });
116
+ if (opts.failFast !== false) throw e;
117
+ }
118
+ }
119
+ }
120
+ return { ok: true, previous_backup_dir: previous, backup_dir: target, moved, skipped };
121
+ }
122
+
123
+ function setBackupDir(dir, opts = {}) {
124
+ const requested = String(dir || '').trim();
125
+ const target = _ensureBackupDir(requested || DEFAULT_BACKUP_DIR);
126
+ const previous = BACKUP_DIR;
127
+ if (opts.moveExisting) moveBackupsToDir(target, { failFast: true });
128
+ BACKUP_DIR = target;
129
+ BACKUP_DIR_SOURCE = requested ? 'settings' : (process.env.WALL_E_BACKUP_DIR ? 'env' : 'default');
130
+ if (opts.persist && db) {
131
+ setKv('backup_dir', requested ? target : '');
132
+ }
133
+ return {
134
+ ok: true,
135
+ backup_dir: BACKUP_DIR,
136
+ previous_backup_dir: previous,
137
+ default_backup_dir: DEFAULT_BACKUP_DIR,
138
+ source: BACKUP_DIR_SOURCE,
139
+ };
140
+ }
141
+
142
+ function getBackupDirInfo() {
143
+ let configured = '';
144
+ try {
145
+ if (db) configured = getKv('backup_dir') || '';
146
+ } catch {}
147
+ return {
148
+ backup_dir: _backupDirForCurrentDb(),
149
+ default_backup_dir: DEFAULT_BACKUP_DIR,
150
+ configured_backup_dir: configured,
151
+ source: configured ? 'settings' : BACKUP_DIR_SOURCE,
152
+ };
153
+ }
154
+
155
+ function _applyPersistedBackupDirSetting() {
156
+ try {
157
+ if (!db) return;
158
+ const configured = getKv('backup_dir') || '';
159
+ setBackupDir(configured, { persist: false });
160
+ } catch (e) {
161
+ console.error('[brain] Backup directory setting ignored:', e.message);
162
+ _ensureBackupDir(DEFAULT_BACKUP_DIR);
163
+ BACKUP_DIR = DEFAULT_BACKUP_DIR;
164
+ BACKUP_DIR_SOURCE = process.env.WALL_E_BACKUP_DIR ? 'env' : 'default';
165
+ }
166
+ }
38
167
 
39
168
  // --- Schema versioning via PRAGMA user_version ---
40
- const SCHEMA_VERSION = 14; // Bump on every migration addition
169
+ const SCHEMA_VERSION = 18; // Bump on every migration addition
41
170
 
42
171
  const MIGRATIONS = {
43
172
  1: (d) => {
@@ -79,6 +208,7 @@ const MIGRATIONS = {
79
208
  d.prepare("CREATE INDEX IF NOT EXISTS idx_memories_topic ON memories(topic)").run();
80
209
  // History column on chat_branches
81
210
  addCol('chat_branches', 'history', "TEXT NOT NULL DEFAULT '[]'");
211
+ addCol('chat_branches', 'updated_at_ms', 'INTEGER NOT NULL DEFAULT 0');
82
212
  // Temporal validity on knowledge
83
213
  addCol('knowledge', 'valid_from', 'TEXT');
84
214
  addCol('knowledge', 'valid_to', 'TEXT');
@@ -365,6 +495,82 @@ const MIGRATIONS = {
365
495
  ON channel_message_events(delivery_id);
366
496
  `);
367
497
  },
498
+ 15: (d) => {
499
+ const addCol = (table, col, type) => {
500
+ try { d.prepare(`SELECT ${col} FROM ${table} LIMIT 0`).run(); } catch (_) {
501
+ d.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run();
502
+ }
503
+ };
504
+ addCol('model_registry', 'source', "TEXT DEFAULT 'catalog'");
505
+ addCol('model_registry', 'verification_status', "TEXT DEFAULT 'verified'");
506
+ addCol('model_registry', 'last_seen_at', 'TEXT');
507
+ addCol('model_registry', 'gateway_type', 'TEXT');
508
+ d.prepare("UPDATE model_registry SET source = 'catalog' WHERE source IS NULL OR source = ''").run();
509
+ d.prepare("UPDATE model_registry SET verification_status = 'verified' WHERE verification_status IS NULL OR verification_status = ''").run();
510
+ },
511
+ 16: (d) => {
512
+ // Daily question digest: track when a question was delivered so it isn't re-asked every
513
+ // day (the digest is once/day and includes only not-yet-delivered pending questions).
514
+ const addCol = (table, col, type) => {
515
+ try { d.prepare(`SELECT ${col} FROM ${table} LIMIT 0`).run(); } catch (_) {
516
+ d.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run();
517
+ }
518
+ };
519
+ addCol('pending_questions', 'digest_delivered_at', 'TEXT');
520
+ d.prepare('CREATE INDEX IF NOT EXISTS idx_questions_digest ON pending_questions(status, digest_delivered_at)').run();
521
+ },
522
+ 17: (d) => {
523
+ d.exec(`
524
+ CREATE TABLE IF NOT EXISTS model_usage_ledger (
525
+ id TEXT PRIMARY KEY,
526
+ occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
527
+ source TEXT NOT NULL DEFAULT 'wall-e.chat',
528
+ feature TEXT,
529
+ session_id TEXT,
530
+ branch_id TEXT,
531
+ message_id TEXT,
532
+ request_id TEXT,
533
+ provider_type TEXT,
534
+ provider_id TEXT,
535
+ model_id TEXT,
536
+ model_registry_id TEXT,
537
+ gateway_type TEXT,
538
+ route_label TEXT,
539
+ input_tokens INTEGER DEFAULT 0,
540
+ output_tokens INTEGER DEFAULT 0,
541
+ total_tokens INTEGER DEFAULT 0,
542
+ cached_input_tokens INTEGER DEFAULT 0,
543
+ reasoning_output_tokens INTEGER DEFAULT 0,
544
+ latency_ms INTEGER,
545
+ stop_reason TEXT,
546
+ status TEXT DEFAULT 'success',
547
+ error_type TEXT,
548
+ cost_usd REAL,
549
+ cost_source TEXT,
550
+ metadata TEXT,
551
+ created_at TEXT DEFAULT (datetime('now'))
552
+ );
553
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_model_usage_ledger_request
554
+ ON model_usage_ledger(source, request_id)
555
+ WHERE request_id IS NOT NULL AND request_id != '';
556
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_occurred
557
+ ON model_usage_ledger(occurred_at);
558
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_session
559
+ ON model_usage_ledger(session_id, occurred_at);
560
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_provider_model
561
+ ON model_usage_ledger(provider_type, model_id, occurred_at);
562
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_registry
563
+ ON model_usage_ledger(model_registry_id, occurred_at);
564
+ `);
565
+ },
566
+ 18: (d) => {
567
+ const addCol = (table, col, type) => {
568
+ try { d.prepare(`SELECT ${col} FROM ${table} LIMIT 0`).run(); } catch (_) {
569
+ d.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run();
570
+ }
571
+ };
572
+ addCol('chat_branches', 'updated_at_ms', 'INTEGER NOT NULL DEFAULT 0');
573
+ },
368
574
  };
369
575
 
370
576
  // Schema invariants — columns/tables that MUST exist after the named migration.
@@ -382,6 +588,9 @@ const SCHEMA_INVARIANTS = [
382
588
  { migration: 9, table: 'agent_runner_evaluations', column: 'runner_id' },
383
589
  { migration: 10, table: 'eval_benchmark_runs', column: 'dataset_version' },
384
590
  { migration: 14, table: 'channel_message_events', column: 'channel' },
591
+ { migration: 15, table: 'model_registry', column: 'source' },
592
+ { migration: 17, table: 'model_usage_ledger', column: 'id' },
593
+ { migration: 18, table: 'chat_branches', column: 'updated_at_ms' },
385
594
  ];
386
595
 
387
596
  function _columnExists(d, table, column) {
@@ -540,6 +749,428 @@ function getDbPath() {
540
749
  return currentDbPath || DEFAULT_DB_PATH;
541
750
  }
542
751
 
752
+ function getStorageRisk() {
753
+ return storageRisk;
754
+ }
755
+
756
+ function _storagePolicyEnvForCurrentProcess() {
757
+ const isScriptSkillProcess = Boolean(process.env.WALLE_BUNDLED_SKILL_NAME || process.env.WALL_E_SKILL_DIR);
758
+ if (!isScriptSkillProcess) return process.env;
759
+ if (process.env.WALL_E_SQLITE_STORAGE_POLICY || process.env.SQLITE_STORAGE_POLICY) return process.env;
760
+ return {
761
+ ...process.env,
762
+ WALL_E_SQLITE_STORAGE_POLICY: 'error',
763
+ };
764
+ }
765
+
766
+ function _checkStoragePolicy(dbPath) {
767
+ storageRisk = enforceSqliteStoragePolicy(dbPath, {
768
+ env: _storagePolicyEnvForCurrentProcess(),
769
+ prefix: 'WALL_E',
770
+ label: 'Wall-E wall-e-brain.db',
771
+ });
772
+ return storageRisk;
773
+ }
774
+
775
+ function _loadSqliteVec(d) {
776
+ // Load sqlite-vec before schema/PRAGMA reads. Without it, DBs containing
777
+ // vec0 virtual tables can look corrupt even when the file is structurally OK.
778
+ try {
779
+ const sqliteVec = require('sqlite-vec');
780
+ sqliteVec.load(d);
781
+ } catch {
782
+ // sqlite-vec not installed — fine if DB has no vec0 tables.
783
+ }
784
+ }
785
+
786
+ function _quickCheckFile(filePath) {
787
+ let d = null;
788
+ try {
789
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) {
790
+ return { ok: true, skipped: true, reason: 'missing_or_empty' };
791
+ }
792
+ d = new Database(filePath, { readonly: true, fileMustExist: true });
793
+ _loadSqliteVec(d);
794
+ d.pragma('busy_timeout = 5000');
795
+ const rows = d.pragma('quick_check');
796
+ const messages = (Array.isArray(rows) ? rows : [rows])
797
+ .map(row => String(Object.values(row || {})[0] || '').trim())
798
+ .filter(Boolean);
799
+ if (messages.length === 1 && messages[0].toLowerCase() === 'ok') return { ok: true };
800
+ return { ok: false, error: messages.join('\n') || 'quick_check failed', classification: 'sqlite_structural_corruption' };
801
+ } catch (err) {
802
+ return _classifySqliteError(err);
803
+ } finally {
804
+ try { if (d) d.close(); } catch {}
805
+ }
806
+ }
807
+
808
+ // Map a thrown SQLite error to {ok:false, error, code, classification}.
809
+ function _classifySqliteError(err) {
810
+ const code = String(err?.code || '');
811
+ const message = String(err?.message || err || '');
812
+ const text = `${code} ${message}`.toLowerCase();
813
+ let classification = 'unknown';
814
+ if (/sqlite_(corrupt|notadb)|database disk image is malformed|malformed database|database schema is corrupt|file is not a database/.test(text)) {
815
+ classification = 'sqlite_structural_corruption';
816
+ } else if (/sqlite_(busy|locked)|database is locked|database table is locked/.test(text)) {
817
+ classification = 'sqlite_locked';
818
+ } else if (/sqlite_cantopen|unable to open database file/.test(text)) {
819
+ classification = 'sqlite_unavailable';
820
+ }
821
+ return { ok: false, error: message, code: code || undefined, classification };
822
+ }
823
+
824
+ // Cheap boot-time integrity probe: open readonly + read the header (user_version) and the
825
+ // schema. Catches GROSS corruption (truncated / not-a-db / malformed header or schema) in
826
+ // milliseconds, WITHOUT the full-file `quick_check` page scan — which costs 12-42s on a
827
+ // multi-GB brain on EVERY boot (measured via the boot profile). Deep page-level integrity
828
+ // is still verified off the boot path by the daily backup job (createBackup runs
829
+ // quick_check on the produced image and fails/alerts if it's bad).
830
+ function _cheapIntegrityCheck(filePath) {
831
+ let d = null;
832
+ try {
833
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) {
834
+ return { ok: true, skipped: true, reason: 'missing_or_empty' };
835
+ }
836
+ d = new Database(filePath, { readonly: true, fileMustExist: true });
837
+ d.pragma('busy_timeout = 5000');
838
+ d.pragma('user_version'); // reads the DB header
839
+ d.prepare('SELECT count(*) AS n FROM sqlite_master').get(); // reads the schema
840
+ return { ok: true };
841
+ } catch (err) {
842
+ return _classifySqliteError(err);
843
+ } finally {
844
+ try { if (d) d.close(); } catch {}
845
+ }
846
+ }
847
+
848
+ function _archiveDbFamily(dbPath, reason) {
849
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
850
+ const archived = [];
851
+ for (const ext of ['', '-wal', '-shm']) {
852
+ const src = dbPath + ext;
853
+ if (!fs.existsSync(src)) continue;
854
+ const dst = `${dbPath}.corrupt-${ts}${ext}`;
855
+ fs.renameSync(src, dst);
856
+ archived.push(dst);
857
+ }
858
+ console.error(`[brain] Archived corrupt DB family (${reason}): ${archived.map(f => path.basename(f)).join(', ')}`);
859
+ return archived;
860
+ }
861
+
862
+ function _backupDirsForRestore(dbPath) {
863
+ const dirs = [
864
+ path.join(path.dirname(dbPath), 'backups'),
865
+ BACKUP_DIR,
866
+ ];
867
+ return [...new Set(dirs)].filter(Boolean);
868
+ }
869
+
870
+ function _materializeBackupCandidate(backupPath, tmpPath) {
871
+ fs.rmSync(tmpPath, { force: true });
872
+ fs.copyFileSync(backupPath, tmpPath);
873
+ }
874
+
875
+ function _restoreLatestHealthyBackup(dbPath) {
876
+ const candidates = [];
877
+ for (const dir of _backupDirsForRestore(dbPath)) {
878
+ if (!fs.existsSync(dir)) continue;
879
+ for (const name of fs.readdirSync(dir)) {
880
+ if (!name.startsWith('wall-e-brain-') || !name.endsWith('.db')) continue;
881
+ const backupPath = path.join(dir, name);
882
+ try {
883
+ candidates.push({ path: backupPath, mtimeMs: fs.statSync(backupPath).mtimeMs });
884
+ } catch {}
885
+ }
886
+ }
887
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
888
+
889
+ const dir = path.dirname(dbPath);
890
+ for (const candidate of candidates) {
891
+ const tmp = path.join(dir, `wall-e-brain.db.restore-candidate-${process.pid}-${Date.now()}`);
892
+ try {
893
+ _materializeBackupCandidate(candidate.path, tmp);
894
+ const check = _quickCheckFile(tmp);
895
+ if (!check.ok) {
896
+ fs.rmSync(tmp, { force: true });
897
+ continue;
898
+ }
899
+ fs.renameSync(tmp, dbPath);
900
+ console.error(`[brain] Recovered from healthy backup ${path.basename(candidate.path)}`);
901
+ return candidate.path;
902
+ } catch (err) {
903
+ console.error(`[brain] Skipped backup candidate ${path.basename(candidate.path)}: ${err.message}`);
904
+ try { fs.rmSync(tmp, { force: true }); } catch {}
905
+ }
906
+ }
907
+ return null;
908
+ }
909
+
910
+ function _recoverCorruptDbIfNeeded(dbPath) {
911
+ if (!fs.existsSync(dbPath) || fs.statSync(dbPath).size === 0) return null;
912
+ // Cheap header/schema probe (ms) instead of a full quick_check (12-42s/boot). Gross
913
+ // corruption that actually blocks operation is still caught here → archive + restore.
914
+ // Subtle page corruption is caught off-boot by the daily backup's quick_check.
915
+ const check = _cheapIntegrityCheck(dbPath);
916
+ if (check.ok) return null;
917
+ const classification = check.classification || 'unknown';
918
+ if (classification !== 'sqlite_structural_corruption') {
919
+ throw new Error(`Wall-E brain health check failed with ${classification}: ${check.error}`);
920
+ }
921
+ _archiveDbFamily(dbPath, `quick_check failed: ${String(check.error || '').split('\n')[0] || 'sqlite structural corruption'}`);
922
+ const restoredFrom = _restoreLatestHealthyBackup(dbPath);
923
+ if (!restoredFrom) {
924
+ throw new Error(`Wall-E brain quick_check failed and no healthy backup was found: ${check.error}`);
925
+ }
926
+ return { restoredFrom };
927
+ }
928
+
929
+ function _sqliteTimeoutMs(name, fallback) {
930
+ const n = Number(process.env[name]);
931
+ return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : fallback;
932
+ }
933
+
934
+ function _sqlitePositiveInt(name, fallback) {
935
+ const n = Number(process.env[name]);
936
+ return Number.isFinite(n) ? Math.max(1, Math.trunc(n)) : fallback;
937
+ }
938
+
939
+ function _isWriteLockBusyError(err) {
940
+ if (!err) return false;
941
+ if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_WRITE_LOCK_BUSY') return true;
942
+ return /write lock busy|SQLITE_BUSY/i.test(String(err.message || ''));
943
+ }
944
+
945
+ // Boot-only bounded SYNC retry for cross-process write-lock contention while
946
+ // OPENING the brain (journal_mode=WAL, table create, migrations). Steady-state
947
+ // writes keep their 0ms main-thread fail-fast (set at installSqliteWriteLock);
948
+ // this runs only during initDb — before the daemon serves — so a transient busy
949
+ // lock at boot no longer throws FATAL and crash-loops with "MCP never became
950
+ // ready". The wrapped init writes are all idempotent, so re-running is safe.
951
+ function _retryBusySync(fn, deadlineMs, pollMs = 50) {
952
+ const start = Date.now();
953
+ for (;;) {
954
+ try {
955
+ return fn();
956
+ } catch (err) {
957
+ if (!_isWriteLockBusyError(err) || (Date.now() - start) >= deadlineMs) throw err;
958
+ try {
959
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, pollMs));
960
+ } catch {
961
+ /* SharedArrayBuffer unavailable — fall through and retry immediately */
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ // ---------------------------------------------------------------------------
968
+ // Write-lock probes (mirrors CTM's db.js instrumentation). The brain DB is
969
+ // contended CROSS-PROCESS (the daemon + each spawned skill subprocess opens its
970
+ // own connection to the same file lock), so unlike CTM the relevant identity is
971
+ // the OS pid, not a worker threadId. We aggregate per-label acquire/busy/hold/wait
972
+ // stats with hold/wait histograms (p50/p95) and, on slow/busy events, log the
973
+ // originating caller plus the lock HOLDER's pid — so log analysis can map
974
+ // "caller X in pid A blocked by holder pid B". Exposed via
975
+ // GET /api/wall-e/diagnostics/write-lock.
976
+ const WRITE_LOCK_SLOW_HOLD_MS = _sqlitePositiveInt('WALL_E_WRITE_LOCK_SLOW_HOLD_MS', 250);
977
+ const WRITE_LOCK_SLOW_WAIT_MS = _sqlitePositiveInt('WALL_E_WRITE_LOCK_SLOW_WAIT_MS', 250);
978
+ const WRITE_LOCK_HIST_BUCKETS_MS = [1, 5, 25, 100, 500, 2000];
979
+ function _newWriteLockHist() {
980
+ return new Array(WRITE_LOCK_HIST_BUCKETS_MS.length + 1).fill(0);
981
+ }
982
+ function _writeLockHistAdd(hist, ms) {
983
+ const value = Number(ms) || 0;
984
+ for (let i = 0; i < WRITE_LOCK_HIST_BUCKETS_MS.length; i += 1) {
985
+ if (value < WRITE_LOCK_HIST_BUCKETS_MS[i]) { hist[i] += 1; return; }
986
+ }
987
+ hist[WRITE_LOCK_HIST_BUCKETS_MS.length] += 1;
988
+ }
989
+ function _writeLockBucketLabel(i) {
990
+ if (i < WRITE_LOCK_HIST_BUCKETS_MS.length) return `<${WRITE_LOCK_HIST_BUCKETS_MS[i]}ms`;
991
+ return `>=${WRITE_LOCK_HIST_BUCKETS_MS[WRITE_LOCK_HIST_BUCKETS_MS.length - 1]}ms`;
992
+ }
993
+ function _writeLockPercentile(hist, pct) {
994
+ const total = hist.reduce((a, b) => a + b, 0);
995
+ if (!total) return null;
996
+ const target = Math.ceil(total * pct);
997
+ let cumulative = 0;
998
+ for (let i = 0; i < hist.length; i += 1) {
999
+ cumulative += hist[i];
1000
+ if (cumulative >= target) return _writeLockBucketLabel(i);
1001
+ }
1002
+ return _writeLockBucketLabel(hist.length - 1);
1003
+ }
1004
+ function _newWriteLockStats() {
1005
+ return {
1006
+ startedAtMs: Date.now(),
1007
+ acquires: 0, busy: 0, contended: 0,
1008
+ holdSum: 0, holdMax: 0, holdHist: _newWriteLockHist(),
1009
+ waitSum: 0, waitMax: 0, waitHist: _newWriteLockHist(),
1010
+ byLabel: new Map(),
1011
+ };
1012
+ }
1013
+ let _writeLockStats = _newWriteLockStats();
1014
+ function _writeLockLabelStat(label) {
1015
+ let stat = _writeLockStats.byLabel.get(label);
1016
+ if (!stat) {
1017
+ stat = { acquires: 0, busy: 0, holdSum: 0, holdMax: 0, waitMax: 0 };
1018
+ _writeLockStats.byLabel.set(label, stat);
1019
+ }
1020
+ return stat;
1021
+ }
1022
+ // Identify the originating call site for slow/busy log lines. onEvent runs
1023
+ // synchronously inside withSqliteWriteLock (invoked synchronously via
1024
+ // stmt.run/exec/pragma/transaction), so the caller frame is still on the stack.
1025
+ // Only computed on a slow/busy event (rare), so the Error().stack cost is moot.
1026
+ function _writeLockCallerHint() {
1027
+ try {
1028
+ const lines = String(new Error().stack || '').split('\n').slice(2);
1029
+ for (const line of lines) {
1030
+ if (/sqlite-write-lock\.js|_onWriteLockEvent|_writeLockCallerHint|node:|node_modules/.test(line)) continue;
1031
+ const m = line.match(/at\s+(?:(\S+)\s+\()?(?:.*\/)?([^/\s):]+):(\d+):\d+\)?/);
1032
+ if (m) return `${m[1] ? m[1] + '@' : ''}${m[2]}:${m[3]}`;
1033
+ }
1034
+ } catch {}
1035
+ return '?';
1036
+ }
1037
+ // Pull the lock HOLDER's pid out of the busy error (the shared lock module formats
1038
+ // it as "... pid=<n> label=<l>: <path>.write-lock"). Cross-process attribution.
1039
+ function _writeLockHolderPid(err) {
1040
+ const m = /pid=(\d+)/.exec(err && err.message ? err.message : '');
1041
+ return m ? m[1] : '?';
1042
+ }
1043
+ function _onWriteLockEvent(ev) {
1044
+ try {
1045
+ const label = ev.label || '';
1046
+ const labelStat = _writeLockLabelStat(label);
1047
+ const waited = Number(ev.waitedMs) || 0;
1048
+ _writeLockStats.waitSum += waited;
1049
+ if (waited > _writeLockStats.waitMax) _writeLockStats.waitMax = waited;
1050
+ if (waited > labelStat.waitMax) labelStat.waitMax = waited;
1051
+ _writeLockHistAdd(_writeLockStats.waitHist, waited);
1052
+
1053
+ if (ev.acquired === false) {
1054
+ _writeLockStats.busy += 1;
1055
+ labelStat.busy += 1;
1056
+ const holder = _writeLockHolderPid(ev.error);
1057
+ console.warn(`[sqlite-write-lock] busy fail-fast label=${label} waitedMs=${waited} pid=${process.pid} holder=${holder} caller=${_writeLockCallerHint()}`);
1058
+ return;
1059
+ }
1060
+
1061
+ const held = Number(ev.heldMs) || 0;
1062
+ _writeLockStats.acquires += 1;
1063
+ labelStat.acquires += 1;
1064
+ if (ev.contended) _writeLockStats.contended += 1;
1065
+ _writeLockStats.holdSum += held;
1066
+ if (held > _writeLockStats.holdMax) _writeLockStats.holdMax = held;
1067
+ labelStat.holdSum += held;
1068
+ if (held > labelStat.holdMax) labelStat.holdMax = held;
1069
+ _writeLockHistAdd(_writeLockStats.holdHist, held);
1070
+
1071
+ if (held >= WRITE_LOCK_SLOW_HOLD_MS || waited >= WRITE_LOCK_SLOW_WAIT_MS) {
1072
+ console.warn(`[sqlite-write-lock] slow label=${label} heldMs=${held} waitedMs=${waited} contended=${ev.contended} pid=${process.pid} caller=${_writeLockCallerHint()}`);
1073
+ }
1074
+ } catch {}
1075
+ }
1076
+ // Snapshot accumulated write-lock stats for this process. `byLabel` sorted by
1077
+ // total hold time (the most likely contention culprit first).
1078
+ function getWriteLockStats() {
1079
+ const s = _writeLockStats;
1080
+ const topLabelsByHold = [...s.byLabel.entries()]
1081
+ .map(([label, v]) => ({
1082
+ label,
1083
+ acquires: v.acquires,
1084
+ busy: v.busy,
1085
+ holdSumMs: v.holdSum,
1086
+ holdMaxMs: v.holdMax,
1087
+ holdAvgMs: v.acquires ? Math.round(v.holdSum / v.acquires) : 0,
1088
+ waitMaxMs: v.waitMax,
1089
+ }))
1090
+ .sort((a, b) => b.holdSumMs - a.holdSumMs)
1091
+ .slice(0, 12);
1092
+ return {
1093
+ pid: process.pid,
1094
+ uptimeMs: Date.now() - s.startedAtMs,
1095
+ acquires: s.acquires,
1096
+ busy: s.busy,
1097
+ contended: s.contended,
1098
+ hold: {
1099
+ sumMs: s.holdSum,
1100
+ maxMs: s.holdMax,
1101
+ avgMs: s.acquires ? Math.round(s.holdSum / s.acquires) : 0,
1102
+ p50: _writeLockPercentile(s.holdHist, 0.5),
1103
+ p95: _writeLockPercentile(s.holdHist, 0.95),
1104
+ },
1105
+ wait: { sumMs: s.waitSum, maxMs: s.waitMax, p95: _writeLockPercentile(s.waitHist, 0.95) },
1106
+ topLabelsByHold,
1107
+ };
1108
+ }
1109
+ function resetWriteLockStats() {
1110
+ const previous = getWriteLockStats();
1111
+ _writeLockStats = _newWriteLockStats();
1112
+ return previous;
1113
+ }
1114
+
1115
+ function _closeOwnerWriteQueueNoDrain() {
1116
+ const queue = ownerWriteQueue;
1117
+ ownerWriteQueue = null;
1118
+ if (!queue) return;
1119
+ try { queue.close({ drain: false }).catch(() => {}); } catch {}
1120
+ }
1121
+
1122
+ function _resetOwnerWriteQueue(dbPath) {
1123
+ _closeOwnerWriteQueueNoDrain();
1124
+ ownerWriteQueue = createSqliteOwnerWriteQueue({
1125
+ name: `wall-e-brain:${path.basename(dbPath || DEFAULT_DB_PATH)}`,
1126
+ maxDepth: _sqlitePositiveInt('WALL_E_SQLITE_OWNER_WRITE_QUEUE_MAX_DEPTH', 1000),
1127
+ });
1128
+ return ownerWriteQueue;
1129
+ }
1130
+
1131
+ function _getOwnerWriteQueue() {
1132
+ if (!ownerWriteQueue) {
1133
+ ownerWriteQueue = createSqliteOwnerWriteQueue({
1134
+ name: `wall-e-brain:${path.basename(currentDbPath || DEFAULT_DB_PATH)}`,
1135
+ maxDepth: _sqlitePositiveInt('WALL_E_SQLITE_OWNER_WRITE_QUEUE_MAX_DEPTH', 1000),
1136
+ });
1137
+ }
1138
+ return ownerWriteQueue;
1139
+ }
1140
+
1141
+ function enqueueOwnerWrite(labelOrFn, fnOrOptions, maybeOptions) {
1142
+ const label = typeof labelOrFn === 'string' ? labelOrFn : 'write';
1143
+ const fn = typeof labelOrFn === 'function' ? labelOrFn : fnOrOptions;
1144
+ const options = typeof labelOrFn === 'function' ? (fnOrOptions || {}) : (maybeOptions || {});
1145
+ if (typeof fn !== 'function') throw new TypeError('enqueueOwnerWrite requires a function');
1146
+ return _getOwnerWriteQueue().enqueue(() => fn(getDb()), { ...options, label });
1147
+ }
1148
+
1149
+ function getOwnerWriteQueueStatus() {
1150
+ return ownerWriteQueue
1151
+ ? ownerWriteQueue.getStatus()
1152
+ : {
1153
+ name: `wall-e-brain:${path.basename(currentDbPath || DEFAULT_DB_PATH)}`,
1154
+ active: null,
1155
+ running: false,
1156
+ pending: 0,
1157
+ accepting: false,
1158
+ closed: true,
1159
+ maxDepth: _sqlitePositiveInt('WALL_E_SQLITE_OWNER_WRITE_QUEUE_MAX_DEPTH', 1000),
1160
+ enqueued: 0,
1161
+ completed: 0,
1162
+ failed: 0,
1163
+ idle: true,
1164
+ };
1165
+ }
1166
+
1167
+ function drainOwnerWrites(options = {}) {
1168
+ if (!ownerWriteQueue) {
1169
+ return Promise.resolve({ ok: true, timedOut: false, ...getOwnerWriteQueueStatus() });
1170
+ }
1171
+ return ownerWriteQueue.drain(options);
1172
+ }
1173
+
543
1174
  const _VALID_CHECKPOINT_MODES = new Set(['PASSIVE', 'FULL', 'RESTART', 'TRUNCATE']);
544
1175
  function checkpointWalOrThrow(mode) {
545
1176
  const d = getDb();
@@ -563,40 +1194,52 @@ function runImmediateTransaction(d, fn, ...args) {
563
1194
 
564
1195
  function initDb(dbPath) {
565
1196
  dbPath = dbPath || DEFAULT_DB_PATH;
566
- currentDbPath = dbPath;
1197
+ if (db && currentDbPath === dbPath) return db;
1198
+ if (db && currentDbPath !== dbPath) closeDb(true);
567
1199
  const dir = path.dirname(dbPath);
568
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
569
- if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
570
-
571
- const newDb = new Database(dbPath);
572
- // Load sqlite-vec extension BEFORE any schema reads (including PRAGMAs).
573
- // If the DB has vec0 virtual tables and the extension isn't loaded,
574
- // SQLite returns SQLITE_CORRUPT: "malformed database schema".
575
- try {
576
- const sqliteVec = require('sqlite-vec');
577
- sqliteVec.load(newDb);
578
- } catch {
579
- // sqlite-vec not installed — fine if DB has no vec0 tables
580
- }
1200
+ let newDb = null;
1201
+ const boot = require('./lib/boot-profile'); // per-phase timing (surfaced at /api/wall-e/boot-profile)
581
1202
  try {
582
- newDb.pragma('journal_mode = WAL');
583
- newDb.pragma('busy_timeout = 5000');
1203
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1204
+ _ensureBackupDir(BACKUP_DIR);
1205
+ boot.measure('initDb.storagePolicy', () => _checkStoragePolicy(dbPath));
1206
+ boot.measure('initDb.recoverCorruptCheck', () => _recoverCorruptDbIfNeeded(dbPath));
1207
+
1208
+ const busyTimeoutMs = _sqliteTimeoutMs('WALL_E_SQLITE_BUSY_TIMEOUT_MS', 1000);
1209
+ newDb = boot.measure('initDb.openDatabase', () => new Database(dbPath, { timeout: busyTimeoutMs }));
1210
+ installSqliteWriteLock(newDb, dbPath, {
1211
+ label: 'wall-e-brain',
1212
+ timeoutMs: _sqliteTimeoutMs('WALL_E_SQLITE_WRITE_LOCK_TIMEOUT_MS', 0),
1213
+ staleMs: Number(process.env.WALL_E_SQLITE_WRITE_LOCK_STALE_MS || 10 * 60 * 1000),
1214
+ onEvent: _onWriteLockEvent,
1215
+ });
1216
+ boot.measure('initDb.loadSqliteVec', () => _loadSqliteVec(newDb));
1217
+ // Boot writes tolerate transient cross-process lock contention (a few seconds)
1218
+ // instead of FATAL-ing the daemon before MCP is ready. journal_mode=WAL can also
1219
+ // trigger WAL recovery/checkpoint work on open — measured separately.
1220
+ const bootLockWaitMs = _sqliteTimeoutMs('WALL_E_BRAIN_BOOT_LOCK_WAIT_MS', 7000);
1221
+ boot.measure('initDb.pragma.walMode', () => _retryBusySync(() => newDb.pragma('journal_mode = WAL'), bootLockWaitMs));
1222
+ newDb.pragma(`busy_timeout = ${busyTimeoutMs}`);
584
1223
  newDb.pragma('foreign_keys = ON');
585
1224
  db = newDb;
586
- createTables();
1225
+ currentDbPath = dbPath;
1226
+ _resetOwnerWriteQueue(dbPath);
1227
+ boot.measure('initDb.createTables', () => _retryBusySync(() => createTables(), bootLockWaitMs));
1228
+ boot.measure('initDb.backupDirSetting', () => _retryBusySync(() => _applyPersistedBackupDirSetting(), bootLockWaitMs));
587
1229
 
588
1230
  // --- Schema migrations via PRAGMA user_version ---
589
1231
  // ensureSchema runs forward migrations and then verifies invariants;
590
1232
  // it returns the pre-migration pragma value so telemetry can still
591
1233
  // report what version we upgraded from.
592
- const previousSchemaVersion = ensureSchema(newDb);
1234
+ const previousSchemaVersion = boot.measure('initDb.ensureSchema', () => _retryBusySync(() => ensureSchema(newDb), bootLockWaitMs));
593
1235
 
594
1236
  // --- Upgrade detection (Phase 2) ---
595
- _detectUpgrade(previousSchemaVersion);
1237
+ boot.measure('initDb.detectUpgrade', () => _detectUpgrade(previousSchemaVersion));
596
1238
  } catch (err) {
597
- newDb.close();
1239
+ if (newDb) newDb.close();
598
1240
  db = null;
599
1241
  currentDbPath = null;
1242
+ _closeOwnerWriteQueueNoDrain();
600
1243
  throw err;
601
1244
  }
602
1245
  return db;
@@ -848,7 +1491,8 @@ function createTables() {
848
1491
  session_id TEXT PRIMARY KEY,
849
1492
  branches TEXT NOT NULL DEFAULT '{}',
850
1493
  active TEXT NOT NULL DEFAULT '{}',
851
- history TEXT NOT NULL DEFAULT '[]'
1494
+ history TEXT NOT NULL DEFAULT '[]',
1495
+ updated_at_ms INTEGER NOT NULL DEFAULT 0
852
1496
  );
853
1497
 
854
1498
  CREATE TABLE IF NOT EXISTS skills (
@@ -1071,6 +1715,10 @@ function createTables() {
1071
1715
  speed_tier INTEGER DEFAULT 3,
1072
1716
  enabled INTEGER DEFAULT 1,
1073
1717
  is_fine_tuned INTEGER DEFAULT 0,
1718
+ source TEXT DEFAULT 'catalog',
1719
+ verification_status TEXT DEFAULT 'verified',
1720
+ last_seen_at TEXT,
1721
+ gateway_type TEXT,
1074
1722
  UNIQUE(provider_id, model_id)
1075
1723
  );
1076
1724
 
@@ -1088,6 +1736,36 @@ function createTables() {
1088
1736
  created_at TEXT DEFAULT (datetime('now'))
1089
1737
  );
1090
1738
 
1739
+ CREATE TABLE IF NOT EXISTS model_usage_ledger (
1740
+ id TEXT PRIMARY KEY,
1741
+ occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
1742
+ source TEXT NOT NULL DEFAULT 'wall-e.chat',
1743
+ feature TEXT,
1744
+ session_id TEXT,
1745
+ branch_id TEXT,
1746
+ message_id TEXT,
1747
+ request_id TEXT,
1748
+ provider_type TEXT,
1749
+ provider_id TEXT,
1750
+ model_id TEXT,
1751
+ model_registry_id TEXT,
1752
+ gateway_type TEXT,
1753
+ route_label TEXT,
1754
+ input_tokens INTEGER DEFAULT 0,
1755
+ output_tokens INTEGER DEFAULT 0,
1756
+ total_tokens INTEGER DEFAULT 0,
1757
+ cached_input_tokens INTEGER DEFAULT 0,
1758
+ reasoning_output_tokens INTEGER DEFAULT 0,
1759
+ latency_ms INTEGER,
1760
+ stop_reason TEXT,
1761
+ status TEXT DEFAULT 'success',
1762
+ error_type TEXT,
1763
+ cost_usd REAL,
1764
+ cost_source TEXT,
1765
+ metadata TEXT,
1766
+ created_at TEXT DEFAULT (datetime('now'))
1767
+ );
1768
+
1091
1769
  CREATE TABLE IF NOT EXISTS agent_runner_evaluations (
1092
1770
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1093
1771
  runner_id TEXT NOT NULL,
@@ -1117,6 +1795,13 @@ function createTables() {
1117
1795
  CREATE INDEX IF NOT EXISTS idx_model_registry_provider ON model_registry(provider_id);
1118
1796
  CREATE INDEX IF NOT EXISTS idx_model_evaluations_model ON model_evaluations(model_registry_id);
1119
1797
  CREATE INDEX IF NOT EXISTS idx_model_evaluations_created ON model_evaluations(created_at);
1798
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_model_usage_ledger_request
1799
+ ON model_usage_ledger(source, request_id)
1800
+ WHERE request_id IS NOT NULL AND request_id != '';
1801
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_occurred ON model_usage_ledger(occurred_at);
1802
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_session ON model_usage_ledger(session_id, occurred_at);
1803
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_provider_model ON model_usage_ledger(provider_type, model_id, occurred_at);
1804
+ CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_registry ON model_usage_ledger(model_registry_id, occurred_at);
1120
1805
  CREATE INDEX IF NOT EXISTS idx_agent_runner_eval_runner ON agent_runner_evaluations(runner_id, task_type);
1121
1806
  CREATE INDEX IF NOT EXISTS idx_agent_runner_eval_created ON agent_runner_evaluations(created_at);
1122
1807
 
@@ -1406,13 +2091,13 @@ function createTables() {
1406
2091
  */
1407
2092
  function closeDb(force = false) {
1408
2093
  if (_daemonOwned && !force) return; // Skill called closeDb() in-process — ignore
1409
- if (backupIntervalId) {
1410
- clearInterval(backupIntervalId);
1411
- backupIntervalId = null;
1412
- }
2094
+ _closeOwnerWriteQueueNoDrain();
1413
2095
  writeAuditLog.close();
1414
2096
  if (db) {
1415
- try { db.pragma('wal_checkpoint(TRUNCATE)'); } catch {} // no-op if not in WAL mode
2097
+ // Standalone bundled skills run in separate processes while the daemon and
2098
+ // other skills may still have this DB open. TRUNCATE can invalidate shared
2099
+ // WAL state for those peers; PASSIVE is the safe close-time checkpoint.
2100
+ try { db.pragma('wal_checkpoint(PASSIVE)'); } catch (e) { /* no-op if not in WAL mode */ }
1416
2101
  db.close();
1417
2102
  db = null;
1418
2103
  currentDbPath = null;
@@ -1455,6 +2140,11 @@ function insertMemory(mem) {
1455
2140
 
1456
2141
  // Strip noise from content at ingestion time (preserves raw in content_raw)
1457
2142
  const cleanContent = _stripNoise ? _stripNoise(mem.content) : mem.content;
2143
+ // Only persist content_raw when it actually differs from the stored content; otherwise
2144
+ // NULL and readers fall back via `content_raw ?? content`. Avoids duplicating the text
2145
+ // for every memory whose raw == content (the common no-noise-stripped case).
2146
+ const _effectiveRaw = mem.content_raw || mem.content;
2147
+ const _rawToStore = _effectiveRaw === cleanContent ? null : _effectiveRaw;
1458
2148
 
1459
2149
  const id = uuidv4();
1460
2150
  getDb().prepare(`
@@ -1463,7 +2153,7 @@ function insertMemory(mem) {
1463
2153
  `).run(
1464
2154
  id, mem.source, mem.source_id || null, mem.source_channel || null,
1465
2155
  mem.memory_type, mem.direction || null, mem.participants || null,
1466
- mem.subject || null, cleanContent, mem.content_raw || mem.content,
2156
+ mem.subject || null, cleanContent, _rawToStore,
1467
2157
  mem.metadata || null, mem.importance ?? 0.5, mem.timestamp
1468
2158
  );
1469
2159
  logWrite('insert', 'memories', { id, source: mem.source, subject: mem.subject });
@@ -1472,7 +2162,10 @@ function insertMemory(mem) {
1472
2162
  }
1473
2163
 
1474
2164
  function getMemory(id) {
1475
- return getDb().prepare('SELECT * FROM memories WHERE id = ?').get(id) || null;
2165
+ const row = getDb().prepare('SELECT * FROM memories WHERE id = ?').get(id) || null;
2166
+ // content_raw is NULL when identical to content (dedup); present it as the effective raw.
2167
+ if (row && row.content_raw == null) row.content_raw = row.content;
2168
+ return row;
1476
2169
  }
1477
2170
 
1478
2171
  function listMemories({ source, since, extractionStatus, limit } = {}) {
@@ -1505,34 +2198,129 @@ function listMemories({ source, since, extractionStatus, limit } = {}) {
1505
2198
  return getDb().prepare(sql).all(...params);
1506
2199
  }
1507
2200
 
1508
- function searchMemories({ query, limit, source, memory_type, since, until } = {}) {
1509
- if (!query || !query.trim()) return [];
1510
- const terms = query.trim().split(/\s+/).filter(Boolean);
1511
- const conditions = terms.map(() => "content LIKE ? ESCAPE '\\'");
1512
- const params = terms.map(t => '%' + t.replace(/[%_\\]/g, '\\$&') + '%');
1513
- if (source) {
1514
- conditions.push('source = ?');
1515
- params.push(source);
1516
- }
1517
- if (memory_type) {
1518
- conditions.push('memory_type = ?');
1519
- params.push(memory_type);
1520
- }
1521
- if (since) {
1522
- conditions.push('timestamp >= ?');
1523
- params.push(since);
1524
- }
1525
- if (until) {
1526
- conditions.push('timestamp <= ?');
1527
- params.push(until);
2201
+ // COUNT variant of listMemories. Callers that only need `.length` were pulling every
2202
+ // matching row (with content blobs) just to count them — a full materialization on a
2203
+ // 350k-row brain. This counts in the DB instead.
2204
+ function countMemories({ source, since, extractionStatus } = {}) {
2205
+ const conditions = ['archived_at IS NULL'];
2206
+ const params = [];
2207
+ if (source) { conditions.push('source = ?'); params.push(source); }
2208
+ if (since) { conditions.push('timestamp >= ?'); params.push(since); }
2209
+ if (extractionStatus) { conditions.push('extraction_status = ?'); params.push(extractionStatus); }
2210
+ const sql = 'SELECT count(*) AS c FROM memories WHERE ' + conditions.join(' AND ');
2211
+ return getDb().prepare(sql).get(...params).c;
2212
+ }
2213
+
2214
+ // Low-signal words dropped from free-text memory queries. The old search ANDed a
2215
+ // LIKE for EVERY whitespace token, so a natural-language request ("search history
2216
+ // about Eric Gu and write a note that reads like me") required one memory to
2217
+ // contain all ~14 words and matched nothing. Proper nouns/keywords carry the
2218
+ // signal; stopwords only ever weaken recall.
2219
+ const _MEMORY_STOPWORDS = new Set([
2220
+ 'a','an','the','and','or','but','to','of','in','on','at','for','with','from','by','as',
2221
+ 'is','are','was','were','be','been','being','it','this','that','these','those',
2222
+ 'my','me','i','you','your','our','we','us','he','she','they','them','his','her','their',
2223
+ 'can','could','would','should','will','shall','may','might','do','does','did','done','have','has','had',
2224
+ 'please','help','write','writing','make','made','read','reads','reading','like','about',
2225
+ 'search','find','history','note','notes','some','any','so','if','then','just','also',
2226
+ 'get','got','want','wants','need','needs','tell','give','show','what','who','whom','when','where','why','how',
2227
+ 'up','out','over','into','than','too','very','more','most','here','there','now','today',
2228
+ ]);
2229
+
2230
+ // Tokenize a free-text query into the high-signal terms used for matching. Caller
2231
+ // may pass `extraTerms` (e.g. a person's aliases/emails/handles) to broaden recall.
2232
+ function _memoryQueryTerms(query, extraTerms) {
2233
+ const raw = String(query || '').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}.@_+-]*/gu) || [];
2234
+ if (Array.isArray(extraTerms)) {
2235
+ for (const t of extraTerms) {
2236
+ const v = String(t || '').toLowerCase().trim();
2237
+ if (v) raw.push(v);
2238
+ }
1528
2239
  }
2240
+ const seen = new Set();
2241
+ const all = [];
2242
+ for (const t of raw) { if (!seen.has(t)) { seen.add(t); all.push(t); } }
2243
+ let signal = all.filter(t => t.length >= 2 && !_MEMORY_STOPWORDS.has(t));
2244
+ if (!signal.length) signal = all; // query was all stopwords — fall back to using them
2245
+ return signal.slice(0, 12);
2246
+ }
2247
+
2248
+ // Lexical memory search. Matches a memory if it contains ANY of the high-signal
2249
+ // terms (OR), then ranks by how many distinct terms it matched (so all-terms hits
2250
+ // rank above any-term hits) and recency. This keeps recall high for multi-word /
2251
+ // natural-language queries while preserving precision at the top of the list.
2252
+ function searchMemories({ query, limit, source, memory_type, since, until, terms } = {}) {
2253
+ const searchTerms = _memoryQueryTerms(query, terms);
2254
+ if (!searchTerms.length) return [];
2255
+ const likePatterns = searchTerms.map(t => '%' + t.replace(/[%_\\]/g, '\\$&') + '%');
2256
+ const scoreExpr = likePatterns.map(() => "(content LIKE ? ESCAPE '\\')").join(' + ');
2257
+ const orExpr = '(' + likePatterns.map(() => "content LIKE ? ESCAPE '\\'").join(' OR ') + ')';
2258
+ const params = [...likePatterns, ...likePatterns]; // score CASEs first, then WHERE OR
2259
+ const conditions = [orExpr];
2260
+ if (source) { conditions.push('source = ?'); params.push(source); }
2261
+ if (memory_type) { conditions.push('memory_type = ?'); params.push(memory_type); }
2262
+ if (since) { conditions.push('timestamp >= ?'); params.push(since); }
2263
+ if (until) { conditions.push('timestamp <= ?'); params.push(until); }
1529
2264
  const lim = Math.min(Math.max(limit || 50, 1), 200);
1530
2265
  params.push(lim);
1531
2266
  return getDb().prepare(
1532
- `SELECT * FROM memories WHERE archived_at IS NULL AND ${conditions.join(' AND ')} ORDER BY timestamp DESC LIMIT ?`
2267
+ `SELECT *, (${scoreExpr}) AS match_score FROM memories
2268
+ WHERE archived_at IS NULL AND ${conditions.join(' AND ')}
2269
+ ORDER BY match_score DESC, timestamp DESC LIMIT ?`
1533
2270
  ).all(...params);
1534
2271
  }
1535
2272
 
2273
+ // Resolve a person's name to all of their searchable identities — canonical name,
2274
+ // aliases, emails, @handles — by consulting the people table and the entity graph.
2275
+ // Lets a memory search match messages that reference someone by email/handle even
2276
+ // when the brain has no memory containing their full display name.
2277
+ // Returns { found, canonical, terms: [...] }.
2278
+ function resolvePersonIdentities(name) {
2279
+ const clean = String(name || '').trim();
2280
+ const out = { found: false, canonical: clean, terms: [] };
2281
+ if (!clean) return out;
2282
+ const push = (v) => {
2283
+ const s = String(v == null ? '' : v).trim();
2284
+ if (s && !out.terms.some(t => t.toLowerCase() === s.toLowerCase())) out.terms.push(s);
2285
+ };
2286
+ push(clean);
2287
+ const lower = clean.toLowerCase();
2288
+ const db = getDb();
2289
+ let person = null;
2290
+ try {
2291
+ person = db.prepare('SELECT * FROM people WHERE LOWER(name) = ?').get(lower)
2292
+ || db.prepare('SELECT * FROM people WHERE LOWER(name) LIKE ? LIMIT 1').get('%' + lower.replace(/[%_\\]/g, '\\$&') + '%');
2293
+ if (!person) {
2294
+ const withAliases = db.prepare('SELECT * FROM people WHERE aliases IS NOT NULL').all();
2295
+ person = withAliases.find(p => {
2296
+ try { return (JSON.parse(p.aliases) || []).some(a => String(a).toLowerCase().trim() === lower); }
2297
+ catch { return false; }
2298
+ }) || null;
2299
+ }
2300
+ } catch {}
2301
+ if (person) {
2302
+ out.found = true;
2303
+ out.canonical = person.name || clean;
2304
+ push(person.name);
2305
+ try { (JSON.parse(person.aliases || '[]') || []).forEach(push); } catch {}
2306
+ try {
2307
+ const ids = JSON.parse(person.identities || '[]');
2308
+ if (Array.isArray(ids)) ids.forEach(id => push(typeof id === 'string' ? id : (id && (id.value || id.email || id.handle || id.id))));
2309
+ else if (ids && typeof ids === 'object') Object.values(ids).forEach(push);
2310
+ } catch {}
2311
+ }
2312
+ try {
2313
+ const ent = findEntityFuzzy(clean);
2314
+ if (ent) {
2315
+ out.found = true;
2316
+ if (!person) out.canonical = ent.canonical_name || out.canonical;
2317
+ push(ent.canonical_name);
2318
+ try { (JSON.parse(ent.aliases || '[]') || []).forEach(push); } catch {}
2319
+ }
2320
+ } catch {}
2321
+ return out;
2322
+ }
2323
+
1536
2324
  function updateMemoryExtraction(id, status) {
1537
2325
  getDb().prepare('UPDATE memories SET extraction_status = ? WHERE id = ?').run(status, id);
1538
2326
  logWrite('update_extraction', 'memories', { id, source: `status:${status}` });
@@ -1551,18 +2339,44 @@ function touchMemory(id) {
1551
2339
  ).run(id);
1552
2340
  }
1553
2341
 
1554
- function decayImportance() {
2342
+ // Batched touch: one UPDATE (one write-lock acquisition) instead of N. Hot paths like
2343
+ // search_memories touch every result row; the per-row loop was an N+1 of write-locked
2344
+ // statements. Ids are parameterized.
2345
+ function touchMemories(ids) {
2346
+ const list = Array.isArray(ids) ? ids.filter(Boolean) : [];
2347
+ if (!list.length) return 0;
2348
+ const placeholders = list.map(() => '?').join(',');
2349
+ const res = getDb().prepare(
2350
+ `UPDATE memories SET last_accessed = datetime('now'), access_count = COALESCE(access_count, 0) + 1 WHERE id IN (${placeholders})`
2351
+ ).run(...list);
2352
+ return res.changes;
2353
+ }
2354
+
2355
+ function decayImportance({ windowSize = 5000 } = {}) {
1555
2356
  const db = getDb();
1556
2357
  const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
1557
- const decayed = db.prepare(`
2358
+ // Window by rowid instead of two single UPDATEs over the whole table. On a 350k-row
2359
+ // brain a global UPDATE holds the cross-process write lock for its entire duration,
2360
+ // which makes the OTHER process (CTM) busy-wait on its main thread (the
2361
+ // session-integrity starvation class). Disjoint rowid windows process each row exactly
2362
+ // once (same result) but release the write lock between statements. The decay and boost
2363
+ // predicates are mutually exclusive on last_accessed, so no row is touched twice.
2364
+ const decayStmt = db.prepare(`
1558
2365
  UPDATE memories SET importance = MAX(0.1, importance * 0.95)
1559
- WHERE (last_accessed IS NULL OR last_accessed < ?) AND importance > 0.1
1560
- `).run(thirtyDaysAgo);
1561
- const boosted = db.prepare(`
2366
+ WHERE rowid >= ? AND rowid < ? AND (last_accessed IS NULL OR last_accessed < ?) AND importance > 0.1
2367
+ `);
2368
+ const boostStmt = db.prepare(`
1562
2369
  UPDATE memories SET importance = MIN(1.0, importance * 1.02)
1563
- WHERE last_accessed IS NOT NULL AND last_accessed >= ? AND importance < 1.0
1564
- `).run(thirtyDaysAgo);
1565
- return { decayed: decayed.changes, boosted: boosted.changes };
2370
+ WHERE rowid >= ? AND rowid < ? AND last_accessed IS NOT NULL AND last_accessed >= ? AND importance < 1.0
2371
+ `);
2372
+ const maxRow = db.prepare('SELECT max(rowid) AS m FROM memories').get().m || 0;
2373
+ const step = Math.max(500, Number(windowSize) || 5000);
2374
+ let decayed = 0, boosted = 0;
2375
+ for (let lo = 0; lo <= maxRow; lo += step) {
2376
+ decayed += decayStmt.run(lo, lo + step, thirtyDaysAgo).changes;
2377
+ boosted += boostStmt.run(lo, lo + step, thirtyDaysAgo).changes;
2378
+ }
2379
+ return { decayed, boosted };
1566
2380
  }
1567
2381
 
1568
2382
  // -- Knowledge CRUD --
@@ -1805,54 +2619,80 @@ function deleteSchedulerJobState(job_name) {
1805
2619
 
1806
2620
  // -- Backup --
1807
2621
 
1808
- let backupIntervalId = null;
2622
+ function _backupDirForCurrentDb() {
2623
+ const dbDir = path.dirname(currentDbPath || DEFAULT_DB_PATH);
2624
+ if (BACKUP_DIR_SOURCE === 'default' && dbDir !== path.dirname(DEFAULT_DB_PATH)) {
2625
+ return path.join(dbDir, 'backups');
2626
+ }
2627
+ return BACKUP_DIR;
2628
+ }
1809
2629
 
1810
- function createBackup(label) {
1811
- getDb(); // throws if not initialized
1812
- checkpointWalOrThrow('TRUNCATE');
2630
+ function _quickCheckBackupFile(filePath) {
2631
+ return _quickCheckFile(filePath);
2632
+ }
1813
2633
 
1814
- // Use backup dir relative to the current DB, not the module-level constant.
1815
- // This prevents test runs from writing backups to the production backup dir.
1816
- const dbDir = path.dirname(currentDbPath);
1817
- const backupDir = dbDir === path.dirname(DEFAULT_DB_PATH) ? BACKUP_DIR : path.join(dbDir, 'backups');
2634
+ async function createBackup(label) {
2635
+ getDb(); // throws if not initialized
2636
+ const backupDir = _backupDirForCurrentDb();
1818
2637
 
1819
2638
  const timestamp = new Date().toISOString().replace(/:/g, '-');
1820
- const backupName = `wall-e-brain-${timestamp}-${label}.db`;
2639
+ const safeLabel = String(label || 'manual').replace(/[^a-z0-9_.-]+/gi, '-').replace(/^-+|-+$/g, '') || 'manual';
2640
+ const backupName = `wall-e-brain-${timestamp}-${safeLabel}.db`;
1821
2641
  const backupPath = path.join(backupDir, backupName);
2642
+ const tmpPath = path.join(backupDir, `.${backupName}.${process.pid}.tmp`);
1822
2643
 
1823
2644
  if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
1824
- fs.copyFileSync(currentDbPath, backupPath);
2645
+ fs.rmSync(tmpPath, { force: true });
2646
+ try {
2647
+ // Online incremental backup. better-sqlite3's db.backup() copies the database in
2648
+ // page batches and YIELDS to the event loop between them (default 100 pages/batch),
2649
+ // so a multi-GB brain never freezes the loop — unlike the old synchronous
2650
+ // `VACUUM INTO`, which blocked for 30-120s. Like VACUUM INTO it reads a consistent
2651
+ // snapshot via a read transaction and does not force a WAL checkpoint that would
2652
+ // perturb peer processes; SQLite transparently re-copies any pages a peer writes
2653
+ // mid-backup. The quick_check below validates the produced image before we keep it.
2654
+ await getDb().backup(tmpPath);
2655
+ const check = _quickCheckBackupFile(tmpPath);
2656
+ if (!check.ok) {
2657
+ throw new Error(`backup quick_check failed: ${check.error}`);
2658
+ }
2659
+ fs.renameSync(tmpPath, backupPath);
2660
+ } catch (err) {
2661
+ try { fs.rmSync(tmpPath, { force: true }); } catch {}
2662
+ throw err;
2663
+ }
1825
2664
 
1826
- cleanOldBackups();
2665
+ cleanOldBackups(backupDir);
1827
2666
 
1828
2667
  return { backupName, backupPath, timestamp };
1829
2668
  }
1830
2669
 
1831
2670
  function listBackups() {
1832
- if (!fs.existsSync(BACKUP_DIR)) return [];
1833
- return fs.readdirSync(BACKUP_DIR)
2671
+ const backupDir = _backupDirForCurrentDb();
2672
+ if (!fs.existsSync(backupDir)) return [];
2673
+ return fs.readdirSync(backupDir)
1834
2674
  .filter(f => f.startsWith('wall-e-brain-') && f.endsWith('.db'))
1835
2675
  .sort().reverse()
1836
2676
  .map(f => {
1837
- const stat = fs.statSync(path.join(BACKUP_DIR, f));
1838
- return { name: f, path: path.join(BACKUP_DIR, f), size: stat.size, createdAt: stat.mtime.toISOString() };
2677
+ const stat = fs.statSync(path.join(backupDir, f));
2678
+ return { name: f, path: path.join(backupDir, f), size: stat.size, createdAt: stat.mtime.toISOString() };
1839
2679
  });
1840
2680
  }
1841
2681
 
1842
2682
  function deleteBackup(backupName) {
1843
- const p = path.join(BACKUP_DIR, path.basename(backupName));
2683
+ const p = path.join(_backupDirForCurrentDb(), path.basename(backupName));
1844
2684
  if (fs.existsSync(p)) fs.unlinkSync(p);
1845
2685
  }
1846
2686
 
1847
- function cleanOldBackups() {
1848
- if (!fs.existsSync(BACKUP_DIR)) return;
1849
- const files = fs.readdirSync(BACKUP_DIR);
2687
+ function cleanOldBackups(backupDir = BACKUP_DIR) {
2688
+ if (!fs.existsSync(backupDir)) return;
2689
+ const files = fs.readdirSync(backupDir);
1850
2690
  const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
1851
2691
 
1852
2692
  // Always keep at least 3
1853
2693
  const brainFiles = files.filter(f => f.startsWith('wall-e-brain-') && f.endsWith('.db')).sort().reverse();
1854
2694
  for (const file of brainFiles.slice(3)) {
1855
- const filePath = path.join(BACKUP_DIR, file);
2695
+ const filePath = path.join(backupDir, file);
1856
2696
  const stat = fs.statSync(filePath);
1857
2697
  if (stat.mtimeMs < cutoff) {
1858
2698
  fs.unlinkSync(filePath);
@@ -1860,37 +2700,24 @@ function cleanOldBackups() {
1860
2700
  }
1861
2701
  }
1862
2702
 
1863
- function startDailyBackup() {
1864
- // Check if backup exists for today
2703
+ // Ensure a daily backup exists for today. Idempotent — skips if today's daily backup
2704
+ // is already present. This is registered as a SCHEDULER job (see agent.js), NOT coupled
2705
+ // to process boot or a hand-rolled hourly poll: the scheduler owns "run once a day +
2706
+ // replay if a day was missed during downtime" (persistJobState), and runs it well after
2707
+ // startup so the backup never blocks port bind / MCP readiness. Returns
2708
+ // { created: boolean, backupName?, backupPath?, timestamp? }.
2709
+ async function ensureDailyBackup() {
1865
2710
  const today = new Date().toISOString().slice(0, 10);
1866
- let needsBackup = true;
1867
-
1868
- if (fs.existsSync(BACKUP_DIR)) {
1869
- const files = fs.readdirSync(BACKUP_DIR);
2711
+ const backupDir = _backupDirForCurrentDb();
2712
+ if (fs.existsSync(backupDir)) {
2713
+ const files = fs.readdirSync(backupDir);
1870
2714
  const todayPrefix = `wall-e-brain-${today}`;
1871
- needsBackup = !files.some(f => f.startsWith(todayPrefix) && f.endsWith('-daily.db'));
1872
- }
1873
-
1874
- if (needsBackup) {
1875
- try { createBackup('daily'); } catch (_) {}
1876
- }
1877
-
1878
- // Check hourly
1879
- backupIntervalId = setInterval(() => {
1880
- const todayNow = new Date().toISOString().slice(0, 10);
1881
- let exists = false;
1882
- if (fs.existsSync(BACKUP_DIR)) {
1883
- const files = fs.readdirSync(BACKUP_DIR);
1884
- const prefix = `wall-e-brain-${todayNow}`;
1885
- exists = files.some(f => f.startsWith(prefix) && f.endsWith('-daily.db'));
1886
- }
1887
- if (!exists) {
1888
- try { createBackup('daily'); } catch (_) {}
2715
+ if (files.some(f => f.startsWith(todayPrefix) && f.endsWith('-daily.db'))) {
2716
+ return { created: false };
1889
2717
  }
1890
- }, 60 * 60 * 1000);
1891
-
1892
- // Don't keep process alive just for backups
1893
- if (backupIntervalId.unref) backupIntervalId.unref();
2718
+ }
2719
+ const result = await createBackup('daily');
2720
+ return { created: true, ...result };
1894
2721
  }
1895
2722
 
1896
2723
  // -- People CRUD --
@@ -1973,29 +2800,37 @@ function findEntityFuzzy(name, maxDistance = 2) {
1973
2800
  const lower = name.toLowerCase().trim();
1974
2801
  const exact = findEntity(name);
1975
2802
  if (exact) return exact;
1976
- // Check aliases (exact match)
1977
- const all = getDb().prepare('SELECT * FROM entities WHERE aliases IS NOT NULL').all();
1978
- for (const e of all) {
2803
+ // Check aliases (exact match). Only the alias column is needed for the scan; pull the
2804
+ // full row lazily once we've found the match.
2805
+ const aliasRows = getDb().prepare('SELECT id, aliases FROM entities WHERE aliases IS NOT NULL').all();
2806
+ for (const e of aliasRows) {
1979
2807
  try {
1980
2808
  const aliases = JSON.parse(e.aliases);
1981
- if (Array.isArray(aliases) && aliases.some(a => a.toLowerCase().trim() === lower)) return e;
2809
+ if (Array.isArray(aliases) && aliases.some(a => a.toLowerCase().trim() === lower)) {
2810
+ return getDb().prepare('SELECT * FROM entities WHERE id = ?').get(e.id);
2811
+ }
1982
2812
  } catch {}
1983
2813
  }
1984
2814
  // LIKE prefix match
1985
2815
  const likeMatch = getDb().prepare('SELECT * FROM entities WHERE LOWER(canonical_name) LIKE ? LIMIT 1').get(`%${lower}%`);
1986
2816
  if (likeMatch) return likeMatch;
1987
- // Levenshtein fuzzy match (catches typos like "Jhon" → "John")
2817
+ // Levenshtein fuzzy match (catches typos like "Jhon" → "John"). Select only id +
2818
+ // canonical_name (not SELECT *), and skip any name whose length differs by more than
2819
+ // maxDistance — it can't possibly be within edit distance, so we avoid the O(n*m) DP
2820
+ // for the vast majority of rows.
1988
2821
  if (lower.length >= 3) {
1989
- const allEntities = getDb().prepare('SELECT * FROM entities').all();
1990
- let bestMatch = null, bestDist = maxDistance + 1;
1991
- for (const e of allEntities) {
1992
- const dist = _editDistance(lower, e.canonical_name.toLowerCase().trim());
2822
+ const candidates = getDb().prepare('SELECT id, canonical_name FROM entities').all();
2823
+ let bestId = null, bestDist = maxDistance + 1;
2824
+ for (const e of candidates) {
2825
+ const cn = (e.canonical_name || '').toLowerCase().trim();
2826
+ if (Math.abs(cn.length - lower.length) > maxDistance) continue;
2827
+ const dist = _editDistance(lower, cn);
1993
2828
  if (dist > 0 && dist <= maxDistance && dist < bestDist) {
1994
- bestMatch = e;
2829
+ bestId = e.id;
1995
2830
  bestDist = dist;
1996
2831
  }
1997
2832
  }
1998
- if (bestMatch) return bestMatch;
2833
+ if (bestId) return getDb().prepare('SELECT * FROM entities WHERE id = ?').get(bestId);
1999
2834
  }
2000
2835
  return null;
2001
2836
  }
@@ -2146,6 +2981,17 @@ function listQuestions({ status, question_type, limit } = {}) {
2146
2981
  return getDb().prepare(sql).all(...params);
2147
2982
  }
2148
2983
 
2984
+ // COUNT variant of listQuestions — avoids materializing every pending row just to count.
2985
+ function countQuestions({ status, question_type } = {}) {
2986
+ const conditions = [];
2987
+ const params = [];
2988
+ if (status) { conditions.push('status = ?'); params.push(status); }
2989
+ if (question_type) { conditions.push('question_type = ?'); params.push(question_type); }
2990
+ let sql = 'SELECT count(*) AS c FROM pending_questions';
2991
+ if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
2992
+ return getDb().prepare(sql).get(...params).c;
2993
+ }
2994
+
2149
2995
  function answerQuestion(id, { answer, resolution_type, resolution_evidence }) {
2150
2996
  if (!answer) throw new Error('Answer is required');
2151
2997
  if (!resolution_type) throw new Error('Resolution type is required');
@@ -2167,6 +3013,30 @@ function answerQuestion(id, { answer, resolution_type, resolution_evidence }) {
2167
3013
  if (result.changes === 0) throw new Error(`Question not found: ${id}`);
2168
3014
  }
2169
3015
 
3016
+ // Questions worth surfacing in the daily digest: still pending and not yet delivered,
3017
+ // highest priority first (high → normal → low), then oldest-first so nothing waits forever.
3018
+ function listUndeliveredDigestQuestions({ max = 10 } = {}) {
3019
+ const cap = Math.max(1, Math.min(50, Number(max) || 10));
3020
+ return getDb().prepare(`
3021
+ SELECT * FROM pending_questions
3022
+ WHERE status = 'pending' AND (digest_delivered_at IS NULL OR digest_delivered_at = '')
3023
+ ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at ASC
3024
+ LIMIT ?
3025
+ `).all(cap);
3026
+ }
3027
+
3028
+ // Mark questions as delivered in a digest (so they aren't re-asked daily). Call only after
3029
+ // the digest was successfully sent.
3030
+ function markQuestionsDelivered(ids) {
3031
+ if (!Array.isArray(ids) || ids.length === 0) return 0;
3032
+ const now = new Date().toISOString();
3033
+ const stmt = getDb().prepare('UPDATE pending_questions SET digest_delivered_at = ? WHERE id = ?');
3034
+ let n = 0;
3035
+ const txn = getDb().transaction(() => { for (const id of ids) n += stmt.run(now, id).changes; });
3036
+ txn();
3037
+ return n;
3038
+ }
3039
+
2170
3040
  // -- Knowledge updates --
2171
3041
 
2172
3042
  function supersedeKnowledge(oldId, newId) {
@@ -2398,15 +3268,21 @@ function listExchanges({ limit } = {}) {
2398
3268
 
2399
3269
  // ── Chat Messages ──
2400
3270
 
2401
- function insertChatMessage({ role, content, channel, session_id, id, attachments }) {
3271
+ function insertChatMessage({ role, content, channel, session_id, id, attachments, model_id, model_provider }) {
2402
3272
  const messageId = id || uuidv4();
2403
3273
  const attachmentsJson = attachments
2404
3274
  ? (typeof attachments === 'string' ? attachments : JSON.stringify(attachments))
2405
3275
  : null;
3276
+ // model_id / model_provider record which model produced an assistant turn so the
3277
+ // conversation log / token badge can attribute it. Columns exist (migrations 84-85)
3278
+ // but were never written; null-coalesced so user/system rows stay NULL.
2406
3279
  getDb().prepare(`
2407
- INSERT INTO chat_messages (id, role, content, channel, session_id, attachments, created_at)
2408
- VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
2409
- `).run(messageId, role, content, channel || 'ctm', session_id || null, attachmentsJson);
3280
+ INSERT INTO chat_messages (id, role, content, channel, session_id, attachments, model_id, model_provider, created_at)
3281
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
3282
+ `).run(
3283
+ messageId, role, content, channel || 'ctm', session_id || null, attachmentsJson,
3284
+ model_id || null, model_provider || null,
3285
+ );
2410
3286
  return { id: messageId };
2411
3287
  }
2412
3288
 
@@ -2458,24 +3334,29 @@ function clearChatSession(session_id) {
2458
3334
 
2459
3335
  // ── Chat Branches (ChatGPT-style edit/resend) ──
2460
3336
 
2461
- function saveChatBranches(session_id, branches, active, history) {
3337
+ function saveChatBranches(session_id, branches, active, history, updated_at_ms) {
3338
+ const updatedAtMs = Number.isFinite(Number(updated_at_ms)) && Number(updated_at_ms) > 0
3339
+ ? Math.floor(Number(updated_at_ms))
3340
+ : Date.now();
2462
3341
  getDb().prepare(`
2463
- INSERT INTO chat_branches (session_id, branches, active, history)
2464
- VALUES (?, ?, ?, ?)
2465
- ON CONFLICT(session_id) DO UPDATE SET branches = excluded.branches, active = excluded.active, history = excluded.history
2466
- `).run(session_id, JSON.stringify(branches), JSON.stringify(active), JSON.stringify(history || []));
3342
+ INSERT INTO chat_branches (session_id, branches, active, history, updated_at_ms)
3343
+ VALUES (?, ?, ?, ?, ?)
3344
+ ON CONFLICT(session_id) DO UPDATE SET branches = excluded.branches, active = excluded.active, history = excluded.history, updated_at_ms = excluded.updated_at_ms
3345
+ `).run(session_id, JSON.stringify(branches), JSON.stringify(active), JSON.stringify(history || []), updatedAtMs);
2467
3346
  }
2468
3347
 
2469
3348
  function loadChatBranches(session_id) {
2470
- const row = getDb().prepare('SELECT branches, active, history FROM chat_branches WHERE session_id = ?').get(session_id);
2471
- if (!row) return { branches: {}, active: {}, history: [] };
3349
+ const row = getDb().prepare('SELECT branches, active, history, updated_at_ms FROM chat_branches WHERE session_id = ?').get(session_id);
3350
+ if (!row) return { branches: {}, active: {}, history: [], exists: false };
2472
3351
  try {
2473
3352
  return {
2474
3353
  branches: JSON.parse(row.branches),
2475
3354
  active: JSON.parse(row.active),
2476
3355
  history: JSON.parse(row.history || '[]'),
3356
+ updated_at_ms: Number(row.updated_at_ms || 0) || 0,
3357
+ exists: true,
2477
3358
  };
2478
- } catch (e) { return { branches: {}, active: {}, history: [] }; }
3359
+ } catch (e) { return { branches: {}, active: {}, history: [], exists: true }; }
2479
3360
  }
2480
3361
 
2481
3362
  // -- Skills CRUD --
@@ -3055,6 +3936,86 @@ function pruneChannelMessageEvents(ttlMs = 7 * 24 * 60 * 60 * 1000) {
3055
3936
  return getDb().prepare('DELETE FROM channel_message_events WHERE updated_at < ?').run(cutoff).changes;
3056
3937
  }
3057
3938
 
3939
+ // -- Brain retention (write-volume audit, 2026-05-31) --
3940
+ //
3941
+ // Age-based pruning for tables that grew unbounded (see
3942
+ // docs/superpowers/specs/2026-05-31-question-digest-and-brain-retention-design.md).
3943
+ // DELETEs are BATCHED via a bounded `rowid IN (SELECT ... LIMIT ?)` sub-select so each
3944
+ // statement holds the cross-process write lock only briefly (a single unbounded DELETE
3945
+ // over a huge table can hold it for seconds — see the CTM approval_observations fix). The
3946
+ // table/column/where fragments are internal constants, never user input.
3947
+ const _RETENTION_BATCH = Math.max(50, Math.min(5000, Number(process.env.WALL_E_RETENTION_BATCH) || 500));
3948
+ function _pruneRowsByAge(table, cutoffIso, { batchSize = _RETENTION_BATCH, column = 'created_at', where = '' } = {}) {
3949
+ if (!db) return 0;
3950
+ const batch = Math.max(50, Math.min(5000, Number(batchSize) || _RETENTION_BATCH));
3951
+ const extra = where ? ` AND ${where}` : '';
3952
+ const stmt = getDb().prepare(
3953
+ `DELETE FROM ${table} WHERE rowid IN (
3954
+ SELECT rowid FROM ${table} WHERE ${column} < ?${extra} ORDER BY rowid LIMIT ?
3955
+ )`
3956
+ );
3957
+ let deleted = 0;
3958
+ for (let i = 0; i < 5000; i += 1) { // backstop against a runaway loop
3959
+ const res = stmt.run(cutoffIso, batch);
3960
+ deleted += res.changes || 0;
3961
+ if (!res.changes || res.changes < batch) break;
3962
+ }
3963
+ return deleted;
3964
+ }
3965
+
3966
+ function _ageCutoffIso(days, fallbackDays) {
3967
+ const d = Math.max(1, Number(days) || fallbackDays);
3968
+ return new Date(Date.now() - d * 24 * 60 * 60 * 1000).toISOString();
3969
+ }
3970
+
3971
+ // Stale unanswered questions. With the generator fixed (contradictions auto-supersede),
3972
+ // only genuine questions persist; a still-`pending` question older than the window has
3973
+ // gone stale (the daily digest gave it ~30 chances). Resolved rows (answered/dismissed/
3974
+ // inferred) are kept for audit — they're few. Deleting reclaims space from the historical
3975
+ // 125k-row contradiction backlog.
3976
+ function pruneStalePendingQuestions({ retentionDays = 30, batchSize } = {}) {
3977
+ return { deleted: _pruneRowsByAge('pending_questions', _ageCutoffIso(retentionDays, 30), { batchSize, where: "status = 'pending'" }) };
3978
+ }
3979
+
3980
+ function pruneActivityLog({ retentionDays = 30, batchSize } = {}) {
3981
+ return { deleted: _pruneRowsByAge('activity_log', _ageCutoffIso(retentionDays, 30), { batchSize }) };
3982
+ }
3983
+
3984
+ function pruneSkillExecutions({ retentionDays = 30, batchSize } = {}) {
3985
+ return { deleted: _pruneRowsByAge('skill_executions', _ageCutoffIso(retentionDays, 30), { batchSize }) };
3986
+ }
3987
+
3988
+ function pruneInitiativeLog({ retentionDays = 90, batchSize } = {}) {
3989
+ return { deleted: _pruneRowsByAge('initiative_log', _ageCutoffIso(retentionDays, 90), { batchSize }) };
3990
+ }
3991
+
3992
+ // Hard-delete a memory and everything keyed to it: its memory_index row(s) and its
3993
+ // embeddings (embedding_map + vec entries). Skills that re-sync external data
3994
+ // (gws-workspace, claude-code-reader, google-calendar) used to `DELETE FROM memories
3995
+ // WHERE id = ?` directly, which orphaned the embedding (the audited 47,649) and the
3996
+ // memory_index row. Route those deletes here so nothing leaks. memory_index has no FK to
3997
+ // memories so it must be cleaned explicitly.
3998
+ function deleteMemory(id) {
3999
+ if (!id) return 0;
4000
+ const d = getDb();
4001
+ const changes = d.prepare('DELETE FROM memories WHERE id = ?').run(id).changes;
4002
+ try { d.prepare('DELETE FROM memory_index WHERE memory_id = ?').run(id); } catch {}
4003
+ try { require('./embeddings').deleteEmbeddingsForEntity(id); } catch {}
4004
+ return changes;
4005
+ }
4006
+
4007
+ // One pass over all age-based retention. Returns per-table deleted counts. Env-tunable TTLs.
4008
+ function runBrainRetention(opts = {}) {
4009
+ const ttl = (name, dflt) => _sqlitePositiveInt(name, dflt);
4010
+ const out = {};
4011
+ try { out.pending_questions = pruneStalePendingQuestions({ retentionDays: ttl('WALL_E_RETAIN_QUESTIONS_DAYS', 30), ...opts }).deleted; } catch (e) { out.pending_questions = `err:${e.message}`; }
4012
+ try { out.activity_log = pruneActivityLog({ retentionDays: ttl('WALL_E_RETAIN_ACTIVITY_DAYS', 30), ...opts }).deleted; } catch (e) { out.activity_log = `err:${e.message}`; }
4013
+ try { out.skill_executions = pruneSkillExecutions({ retentionDays: ttl('WALL_E_RETAIN_SKILL_EXEC_DAYS', 30), ...opts }).deleted; } catch (e) { out.skill_executions = `err:${e.message}`; }
4014
+ try { out.initiative_log = pruneInitiativeLog({ retentionDays: ttl('WALL_E_RETAIN_INITIATIVE_DAYS', 90), ...opts }).deleted; } catch (e) { out.initiative_log = `err:${e.message}`; }
4015
+ try { out.orphan_embeddings = require('./embeddings').pruneOrphanEmbeddings({ batchSize: opts.batchSize }).deleted; } catch (e) { out.orphan_embeddings = `err:${e.message}`; }
4016
+ return out;
4017
+ }
4018
+
3058
4019
  // -- Slack Inbound Event Ledger --
3059
4020
 
3060
4021
  function slackInboundEventId(channelId, messageTs) {
@@ -3104,6 +4065,90 @@ function pruneSlackInboundEvents(ttlMs = 48 * 60 * 60 * 1000) {
3104
4065
 
3105
4066
  // -- Model Provider CRUD --
3106
4067
 
4068
+ const VALID_PROVIDER_ROUTE_POLICIES = new Set(['auto', 'direct', 'portkey']);
4069
+
4070
+ function _modelProviderRoutePolicyKey(type) {
4071
+ return `model_provider_route_policy:${String(type || '').trim().toLowerCase()}`;
4072
+ }
4073
+
4074
+ function _modelProviderConnectionKind(row = {}) {
4075
+ if (row.auth_method && row.auth_method !== 'api_key') return row.auth_method;
4076
+ return isPortkeyProviderConfig({
4077
+ baseUrl: row.base_url || row.baseUrl,
4078
+ customHeaders: row.custom_headers || row.customHeaders,
4079
+ }) ? 'portkey' : 'direct';
4080
+ }
4081
+
4082
+ function _modelProviderHasCredential(row = {}) {
4083
+ const type = row.type || row.provider_type;
4084
+ if (!row) return false;
4085
+ if (type === 'ollama' || type === 'mlx') return true;
4086
+ if (row.auth_method && row.auth_method !== 'api_key') return true;
4087
+ if (row.api_key_encrypted) return true;
4088
+ if (type === 'anthropic' && process.env.ANTHROPIC_API_KEY) return true;
4089
+ if (type === 'openai' && process.env.OPENAI_API_KEY) return true;
4090
+ if (type === 'google' && (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY)) return true;
4091
+ if (type === 'deepseek' && process.env.DEEPSEEK_API_KEY) return true;
4092
+ if (type === 'moonshot' && process.env.MOONSHOT_API_KEY) return true;
4093
+ return false;
4094
+ }
4095
+
4096
+ function _modelProviderRouteRank(row = {}, policy = 'auto') {
4097
+ const kind = _modelProviderConnectionKind(row);
4098
+ const isPortkey = kind === 'portkey';
4099
+ const hasCredential = _modelProviderHasCredential(row);
4100
+ let policyRank = 50;
4101
+ if (policy === 'portkey') policyRank = isPortkey ? 0 : 10;
4102
+ else if (policy === 'direct') policyRank = isPortkey ? 10 : 0;
4103
+ else policyRank = isPortkey ? 10 : 0;
4104
+ const credentialRank = hasCredential ? 0 : 20;
4105
+ const defaultRank = /-(default|auto)$/.test(String(row.id || '')) ? 0 : 2;
4106
+ return policyRank + credentialRank + defaultRank;
4107
+ }
4108
+
4109
+ function sortModelProvidersByRoutePolicy(rows = [], policy = 'auto') {
4110
+ const normalizedPolicy = VALID_PROVIDER_ROUTE_POLICIES.has(policy) ? policy : 'auto';
4111
+ return [...(rows || [])].sort((a, b) => {
4112
+ const ar = _modelProviderRouteRank(a, normalizedPolicy);
4113
+ const br = _modelProviderRouteRank(b, normalizedPolicy);
4114
+ if (ar !== br) return ar - br;
4115
+ const au = Date.parse(a.updated_at || '') || 0;
4116
+ const bu = Date.parse(b.updated_at || '') || 0;
4117
+ if (bu !== au) return bu - au;
4118
+ return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
4119
+ });
4120
+ }
4121
+
4122
+ function setProviderRoutePolicy({ type, policy }) {
4123
+ const providerType = String(type || '').trim().toLowerCase();
4124
+ const routePolicy = String(policy || 'auto').trim().toLowerCase();
4125
+ if (!providerType) throw new Error('Provider type required');
4126
+ if (!VALID_PROVIDER_ROUTE_POLICIES.has(routePolicy)) {
4127
+ throw new Error(`Invalid provider route policy: ${routePolicy}`);
4128
+ }
4129
+ setKv(_modelProviderRoutePolicyKey(providerType), routePolicy);
4130
+ return { type: providerType, policy: routePolicy };
4131
+ }
4132
+
4133
+ function getProviderRoutePolicy(type) {
4134
+ const providerType = String(type || '').trim().toLowerCase();
4135
+ if (!providerType) return 'auto';
4136
+ const policy = getKv(_modelProviderRoutePolicyKey(providerType));
4137
+ return VALID_PROVIDER_ROUTE_POLICIES.has(policy) ? policy : 'auto';
4138
+ }
4139
+
4140
+ function getPreferredModelProviderForType(type) {
4141
+ const providerType = String(type || '').trim().toLowerCase();
4142
+ if (!providerType) return null;
4143
+ const rows = getDb().prepare(`
4144
+ SELECT *
4145
+ FROM model_providers
4146
+ WHERE type = ? AND enabled = 1
4147
+ `).all(providerType);
4148
+ if (!rows.length) return null;
4149
+ return sortModelProvidersByRoutePolicy(rows, getProviderRoutePolicy(providerType))[0] || null;
4150
+ }
4151
+
3107
4152
  function upsertModelProvider({ id, name, type, baseUrl, apiKeyEncrypted, customHeaders, enabled }) {
3108
4153
  if (!id || !name || !type) throw new Error('Provider requires id, name, type');
3109
4154
  getDb().prepare(`
@@ -3187,6 +4232,8 @@ function saveSetupProvider({
3187
4232
  setDefault = false,
3188
4233
  authMethod,
3189
4234
  preserveExistingKey = true,
4235
+ preserveExistingBaseUrl = true,
4236
+ preserveExistingCustomHeaders = true,
3190
4237
  registerModel = true,
3191
4238
  }) {
3192
4239
  if (!type) throw new Error('Provider type required');
@@ -3199,9 +4246,9 @@ function saveSetupProvider({
3199
4246
  return runImmediateTransaction(db, () => {
3200
4247
  const currentDefaultProvider = getKv('walle_provider') || process.env.WALLE_PROVIDER || 'anthropic';
3201
4248
  const syncDefaultModel = !!(model && currentDefaultProvider === type && !setDefault);
4249
+ const existingProvider = getModelProviderWithKey(providerId);
3202
4250
  let storedKey = apiKeyEncrypted || null;
3203
4251
  if (!storedKey && preserveExistingKey) {
3204
- const existingProvider = getModelProviderWithKey(providerId);
3205
4252
  storedKey = existingProvider?.api_key_encrypted || null;
3206
4253
  if (!storedKey) {
3207
4254
  const row = db.prepare(
@@ -3210,14 +4257,34 @@ function saveSetupProvider({
3210
4257
  storedKey = row?.api_key_encrypted || null;
3211
4258
  }
3212
4259
  }
4260
+ let storedBaseUrl = baseUrl;
4261
+ if (storedBaseUrl === undefined && preserveExistingBaseUrl) {
4262
+ storedBaseUrl = existingProvider?.base_url;
4263
+ if (!storedBaseUrl) {
4264
+ const row = db.prepare(
4265
+ 'SELECT base_url FROM model_providers WHERE type = ? AND enabled = 1 AND base_url IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
4266
+ ).get(type);
4267
+ storedBaseUrl = row?.base_url;
4268
+ }
4269
+ }
4270
+ let storedCustomHeaders = customHeaders;
4271
+ if (storedCustomHeaders === undefined && preserveExistingCustomHeaders) {
4272
+ storedCustomHeaders = existingProvider?.custom_headers;
4273
+ if (!storedCustomHeaders) {
4274
+ const row = db.prepare(
4275
+ 'SELECT custom_headers FROM model_providers WHERE type = ? AND enabled = 1 AND custom_headers IS NOT NULL ORDER BY updated_at DESC LIMIT 1'
4276
+ ).get(type);
4277
+ storedCustomHeaders = row?.custom_headers;
4278
+ }
4279
+ }
3213
4280
 
3214
4281
  upsertModelProvider({
3215
4282
  id: providerId,
3216
4283
  name: displayName,
3217
4284
  type,
3218
- baseUrl: baseUrl || null,
4285
+ baseUrl: storedBaseUrl || null,
3219
4286
  apiKeyEncrypted: storedKey,
3220
- customHeaders: customHeaders || null,
4287
+ customHeaders: storedCustomHeaders || null,
3221
4288
  enabled,
3222
4289
  });
3223
4290
  if (authMethod) setProviderAuthMethod(type, authMethod);
@@ -3294,14 +4361,31 @@ function disableModelProviderByType(type) {
3294
4361
 
3295
4362
  // -- Model Registry CRUD --
3296
4363
 
3297
- function upsertModelRegistryEntry({ id, providerId, modelId, displayName, capabilities, costPer1mInput, costPer1mOutput, maxContextTokens, maxOutputTokens, speedTier, enabled, isFineTuned }) {
4364
+ function upsertModelRegistryEntry({
4365
+ id,
4366
+ providerId,
4367
+ modelId,
4368
+ displayName,
4369
+ capabilities,
4370
+ costPer1mInput,
4371
+ costPer1mOutput,
4372
+ maxContextTokens,
4373
+ maxOutputTokens,
4374
+ speedTier,
4375
+ enabled,
4376
+ isFineTuned,
4377
+ source,
4378
+ verificationStatus,
4379
+ lastSeenAt,
4380
+ gatewayType,
4381
+ }) {
3298
4382
  if (!id || !providerId || !modelId || !displayName) throw new Error('Registry entry requires id, providerId, modelId, displayName');
3299
4383
  const caps = (Array.isArray(capabilities) || (capabilities && typeof capabilities === 'object'))
3300
4384
  ? JSON.stringify(capabilities)
3301
4385
  : (capabilities || '[]');
3302
4386
  getDb().prepare(`
3303
- INSERT INTO model_registry (id, provider_id, model_id, display_name, capabilities, cost_per_1m_input, cost_per_1m_output, max_context_tokens, max_output_tokens, speed_tier, enabled, is_fine_tuned)
3304
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4387
+ INSERT INTO model_registry (id, provider_id, model_id, display_name, capabilities, cost_per_1m_input, cost_per_1m_output, max_context_tokens, max_output_tokens, speed_tier, enabled, is_fine_tuned, source, verification_status, last_seen_at, gateway_type)
4388
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3305
4389
  ON CONFLICT(id) DO UPDATE SET
3306
4390
  provider_id = excluded.provider_id,
3307
4391
  model_id = excluded.model_id,
@@ -3313,8 +4397,29 @@ function upsertModelRegistryEntry({ id, providerId, modelId, displayName, capabi
3313
4397
  max_output_tokens = excluded.max_output_tokens,
3314
4398
  speed_tier = excluded.speed_tier,
3315
4399
  enabled = excluded.enabled,
3316
- is_fine_tuned = excluded.is_fine_tuned
3317
- `).run(id, providerId, modelId, displayName, caps, costPer1mInput || null, costPer1mOutput || null, maxContextTokens || null, maxOutputTokens || null, speedTier || 3, enabled !== undefined ? (enabled ? 1 : 0) : 1, isFineTuned ? 1 : 0);
4400
+ is_fine_tuned = excluded.is_fine_tuned,
4401
+ source = COALESCE(excluded.source, model_registry.source, 'catalog'),
4402
+ verification_status = COALESCE(excluded.verification_status, model_registry.verification_status, 'verified'),
4403
+ last_seen_at = COALESCE(excluded.last_seen_at, model_registry.last_seen_at),
4404
+ gateway_type = COALESCE(excluded.gateway_type, model_registry.gateway_type)
4405
+ `).run(
4406
+ id,
4407
+ providerId,
4408
+ modelId,
4409
+ displayName,
4410
+ caps,
4411
+ costPer1mInput || null,
4412
+ costPer1mOutput || null,
4413
+ maxContextTokens || null,
4414
+ maxOutputTokens || null,
4415
+ speedTier || 3,
4416
+ enabled !== undefined ? (enabled ? 1 : 0) : 1,
4417
+ isFineTuned ? 1 : 0,
4418
+ source || null,
4419
+ verificationStatus || null,
4420
+ lastSeenAt || null,
4421
+ gatewayType || null,
4422
+ );
3318
4423
  }
3319
4424
 
3320
4425
  function getModelRegistryEntry(id) {
@@ -3335,6 +4440,15 @@ function listModelsByProvider(providerId) {
3335
4440
  return rows;
3336
4441
  }
3337
4442
 
4443
+ function listModelCountsByProvider() {
4444
+ return getDb().prepare(`
4445
+ SELECT provider_id, COUNT(*) AS model_count
4446
+ FROM model_registry
4447
+ WHERE enabled = 1
4448
+ GROUP BY provider_id
4449
+ `).all();
4450
+ }
4451
+
3338
4452
  function listAllModels() {
3339
4453
  const rows = getDb().prepare(`
3340
4454
  SELECT mr.*, mp.name AS provider_name, mp.type AS provider_type
@@ -3350,6 +4464,328 @@ function listAllModels() {
3350
4464
  return rows;
3351
4465
  }
3352
4466
 
4467
+ // -- Model Usage Ledger --
4468
+
4469
+ function _finiteNumberOrNull(value) {
4470
+ if (value == null || value === '') return null;
4471
+ const n = Number(value);
4472
+ return Number.isFinite(n) ? n : null;
4473
+ }
4474
+
4475
+ function _nonNegativeInteger(value) {
4476
+ const n = Number(value);
4477
+ if (!Number.isFinite(n) || n <= 0) return 0;
4478
+ return Math.max(0, Math.round(n));
4479
+ }
4480
+
4481
+ function _ledgerMetadata(value) {
4482
+ if (value == null) return null;
4483
+ if (typeof value === 'string') return value;
4484
+ try { return JSON.stringify(value); } catch { return String(value); }
4485
+ }
4486
+
4487
+ function _parseLedgerMetadata(row) {
4488
+ if (!row || !row.metadata) return row;
4489
+ try { row.metadata = JSON.parse(row.metadata); } catch {}
4490
+ return row;
4491
+ }
4492
+
4493
+ function _normalizeUsageTokens({ usage, inputTokens, outputTokens, cachedInputTokens, reasoningOutputTokens }) {
4494
+ const raw = usage && typeof usage === 'object' ? usage : {};
4495
+ const input = _nonNegativeInteger(inputTokens ?? raw.input ?? raw.input_tokens ?? raw.prompt_tokens);
4496
+ const output = _nonNegativeInteger(outputTokens ?? raw.output ?? raw.output_tokens ?? raw.completion_tokens);
4497
+ const cached = _nonNegativeInteger(
4498
+ cachedInputTokens
4499
+ ?? raw.cached_input
4500
+ ?? raw.cachedInput
4501
+ ?? raw.cached_input_tokens
4502
+ ?? raw.prompt_tokens_details?.cached_tokens
4503
+ );
4504
+ const reasoning = _nonNegativeInteger(
4505
+ reasoningOutputTokens
4506
+ ?? raw.reasoning_output
4507
+ ?? raw.reasoningOutput
4508
+ ?? raw.reasoning_output_tokens
4509
+ ?? raw.completion_tokens_details?.reasoning_tokens
4510
+ );
4511
+ return {
4512
+ inputTokens: input,
4513
+ outputTokens: output,
4514
+ totalTokens: _nonNegativeInteger(raw.total ?? raw.total_tokens) || input + output,
4515
+ cachedInputTokens: cached,
4516
+ reasoningOutputTokens: reasoning,
4517
+ };
4518
+ }
4519
+
4520
+ function findModelRegistryForUsage({ providerType, providerId, modelId, modelRegistryId } = {}) {
4521
+ const db = getDb();
4522
+ const registryKey = String(modelRegistryId || '').trim();
4523
+ if (registryKey) {
4524
+ const row = db.prepare(`
4525
+ SELECT mr.*, mp.name AS provider_name, mp.type AS provider_type
4526
+ FROM model_registry mr
4527
+ LEFT JOIN model_providers mp ON mr.provider_id = mp.id
4528
+ WHERE mr.id = ?
4529
+ LIMIT 1
4530
+ `).get(registryKey);
4531
+ if (row) return row;
4532
+ }
4533
+
4534
+ const model = String(modelId || '').trim();
4535
+ if (!model) return null;
4536
+ const modelLower = model.toLowerCase();
4537
+ const typeLower = String(providerType || '').trim().toLowerCase();
4538
+ const providerKey = String(providerId || '').trim();
4539
+ const where = ['(lower(mr.model_id) = ? OR lower(mr.id) = ?)'];
4540
+ const params = [modelLower, modelLower];
4541
+ if (providerKey) {
4542
+ where.push('mr.provider_id = ?');
4543
+ params.push(providerKey);
4544
+ } else if (typeLower) {
4545
+ where.push('lower(mp.type) = ?');
4546
+ params.push(typeLower);
4547
+ }
4548
+ const row = db.prepare(`
4549
+ SELECT mr.*, mp.name AS provider_name, mp.type AS provider_type
4550
+ FROM model_registry mr
4551
+ LEFT JOIN model_providers mp ON mr.provider_id = mp.id
4552
+ WHERE ${where.join(' AND ')}
4553
+ ORDER BY
4554
+ mr.enabled DESC,
4555
+ CASE WHEN mr.cost_per_1m_input IS NOT NULL AND mr.cost_per_1m_output IS NOT NULL THEN 0 ELSE 1 END,
4556
+ datetime(COALESCE(mr.last_seen_at, '1970-01-01')) DESC,
4557
+ mr.id ASC
4558
+ LIMIT 1
4559
+ `).get(...params);
4560
+ if (row) return row;
4561
+
4562
+ // Last resort for legacy rows that recorded only the executable model id.
4563
+ return db.prepare(`
4564
+ SELECT mr.*, mp.name AS provider_name, mp.type AS provider_type
4565
+ FROM model_registry mr
4566
+ LEFT JOIN model_providers mp ON mr.provider_id = mp.id
4567
+ WHERE lower(mr.model_id) = ? OR lower(mr.id) = ?
4568
+ ORDER BY
4569
+ CASE WHEN ? != '' AND lower(mp.type) = ? THEN 0 ELSE 1 END,
4570
+ mr.enabled DESC,
4571
+ CASE WHEN mr.cost_per_1m_input IS NOT NULL AND mr.cost_per_1m_output IS NOT NULL THEN 0 ELSE 1 END,
4572
+ datetime(COALESCE(mr.last_seen_at, '1970-01-01')) DESC,
4573
+ mr.id ASC
4574
+ LIMIT 1
4575
+ `).get(modelLower, modelLower, typeLower, typeLower) || null;
4576
+ }
4577
+
4578
+ function estimateModelUsageCost({ providerType, providerId, modelId, modelRegistryId, inputTokens, outputTokens } = {}) {
4579
+ const registry = findModelRegistryForUsage({ providerType, providerId, modelId, modelRegistryId });
4580
+ if (!registry) {
4581
+ return {
4582
+ costUsd: null,
4583
+ costSource: modelId || modelRegistryId ? 'missing_model_registry' : 'missing_model',
4584
+ modelRegistryId: modelRegistryId || null,
4585
+ providerId: providerId || null,
4586
+ providerType: providerType || null,
4587
+ gatewayType: null,
4588
+ };
4589
+ }
4590
+ const inputPrice = _finiteNumberOrNull(registry.cost_per_1m_input);
4591
+ const outputPrice = _finiteNumberOrNull(registry.cost_per_1m_output);
4592
+ const result = {
4593
+ costUsd: null,
4594
+ costSource: 'missing_price',
4595
+ modelRegistryId: registry.id,
4596
+ providerId: registry.provider_id || providerId || null,
4597
+ providerType: registry.provider_type || providerType || null,
4598
+ gatewayType: registry.gateway_type || null,
4599
+ };
4600
+ if (inputPrice == null || outputPrice == null) return result;
4601
+ result.costUsd = ((_nonNegativeInteger(inputTokens) * inputPrice) + (_nonNegativeInteger(outputTokens) * outputPrice)) / 1_000_000;
4602
+ result.costSource = 'model_registry';
4603
+ return result;
4604
+ }
4605
+
4606
+ function recordModelUsage({
4607
+ id,
4608
+ occurredAt,
4609
+ source = 'wall-e.chat',
4610
+ feature,
4611
+ sessionId,
4612
+ branchId,
4613
+ messageId,
4614
+ requestId,
4615
+ providerType,
4616
+ providerId,
4617
+ modelId,
4618
+ modelRegistryId,
4619
+ gatewayType,
4620
+ routeLabel,
4621
+ usage,
4622
+ inputTokens,
4623
+ outputTokens,
4624
+ cachedInputTokens,
4625
+ reasoningOutputTokens,
4626
+ latencyMs,
4627
+ stopReason,
4628
+ status,
4629
+ errorType,
4630
+ costUsd,
4631
+ costSource,
4632
+ metadata,
4633
+ } = {}) {
4634
+ const db = getDb();
4635
+ const normalizedSource = String(source || 'wall-e.chat').trim() || 'wall-e.chat';
4636
+ const normalizedRequestId = requestId == null ? null : String(requestId).trim();
4637
+ if (normalizedRequestId) {
4638
+ const existing = db.prepare('SELECT * FROM model_usage_ledger WHERE source = ? AND request_id = ?').get(normalizedSource, normalizedRequestId);
4639
+ if (existing) return _parseLedgerMetadata({ ...existing, duplicate: true });
4640
+ }
4641
+
4642
+ const tokens = _normalizeUsageTokens({ usage, inputTokens, outputTokens, cachedInputTokens, reasoningOutputTokens });
4643
+ const explicitCost = _finiteNumberOrNull(costUsd);
4644
+ const estimate = explicitCost == null
4645
+ ? estimateModelUsageCost({
4646
+ providerType,
4647
+ providerId,
4648
+ modelId,
4649
+ modelRegistryId,
4650
+ inputTokens: tokens.inputTokens,
4651
+ outputTokens: tokens.outputTokens,
4652
+ })
4653
+ : {
4654
+ costUsd: explicitCost,
4655
+ costSource: costSource || 'explicit',
4656
+ modelRegistryId: modelRegistryId || null,
4657
+ providerId: providerId || null,
4658
+ providerType: providerType || null,
4659
+ gatewayType: gatewayType || null,
4660
+ };
4661
+
4662
+ const rowId = id || uuidv4();
4663
+ const insert = db.prepare(`
4664
+ INSERT INTO model_usage_ledger (
4665
+ id, occurred_at, source, feature, session_id, branch_id, message_id, request_id,
4666
+ provider_type, provider_id, model_id, model_registry_id, gateway_type, route_label,
4667
+ input_tokens, output_tokens, total_tokens, cached_input_tokens, reasoning_output_tokens,
4668
+ latency_ms, stop_reason, status, error_type, cost_usd, cost_source, metadata
4669
+ )
4670
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4671
+ `);
4672
+ try {
4673
+ insert.run(
4674
+ rowId,
4675
+ occurredAt || new Date().toISOString(),
4676
+ normalizedSource,
4677
+ feature || null,
4678
+ sessionId || null,
4679
+ branchId || null,
4680
+ messageId || null,
4681
+ normalizedRequestId || null,
4682
+ providerType || estimate.providerType || null,
4683
+ providerId || estimate.providerId || null,
4684
+ modelId || null,
4685
+ modelRegistryId || estimate.modelRegistryId || null,
4686
+ gatewayType || estimate.gatewayType || null,
4687
+ routeLabel || null,
4688
+ tokens.inputTokens,
4689
+ tokens.outputTokens,
4690
+ tokens.totalTokens,
4691
+ tokens.cachedInputTokens,
4692
+ tokens.reasoningOutputTokens,
4693
+ latencyMs == null ? null : _nonNegativeInteger(latencyMs),
4694
+ stopReason || null,
4695
+ status || 'success',
4696
+ errorType || null,
4697
+ estimate.costUsd,
4698
+ costSource || estimate.costSource || null,
4699
+ _ledgerMetadata(metadata),
4700
+ );
4701
+ } catch (err) {
4702
+ if (normalizedRequestId && /UNIQUE constraint failed/.test(String(err.message || ''))) {
4703
+ const existing = db.prepare('SELECT * FROM model_usage_ledger WHERE source = ? AND request_id = ?').get(normalizedSource, normalizedRequestId);
4704
+ if (existing) return _parseLedgerMetadata({ ...existing, duplicate: true });
4705
+ }
4706
+ throw err;
4707
+ }
4708
+ return _parseLedgerMetadata(db.prepare('SELECT * FROM model_usage_ledger WHERE id = ?').get(rowId));
4709
+ }
4710
+
4711
+ function getModelUsageLedgerEntry(id) {
4712
+ const row = getDb().prepare('SELECT * FROM model_usage_ledger WHERE id = ?').get(id);
4713
+ return row ? _parseLedgerMetadata(row) : null;
4714
+ }
4715
+
4716
+ function _modelUsageWhere(filters = {}) {
4717
+ const clauses = [];
4718
+ const params = [];
4719
+ const addEq = (column, value, lower = false) => {
4720
+ const text = String(value || '').trim();
4721
+ if (!text) return;
4722
+ if (lower) {
4723
+ clauses.push(`lower(${column}) = ?`);
4724
+ params.push(text.toLowerCase());
4725
+ } else {
4726
+ clauses.push(`${column} = ?`);
4727
+ params.push(text);
4728
+ }
4729
+ };
4730
+ addEq('session_id', filters.sessionId || filters.session_id);
4731
+ addEq('provider_type', filters.providerType || filters.provider_type || filters.provider, true);
4732
+ addEq('provider_id', filters.providerId || filters.provider_id);
4733
+ addEq('model_id', filters.modelId || filters.model_id || filters.model, true);
4734
+ addEq('model_registry_id', filters.modelRegistryId || filters.model_registry_id);
4735
+ addEq('source', filters.source);
4736
+ if (filters.since) {
4737
+ clauses.push('occurred_at >= ?');
4738
+ params.push(String(filters.since));
4739
+ }
4740
+ if (filters.until) {
4741
+ clauses.push('occurred_at <= ?');
4742
+ params.push(String(filters.until));
4743
+ }
4744
+ return {
4745
+ where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
4746
+ params,
4747
+ };
4748
+ }
4749
+
4750
+ function listModelUsageLedger(filters = {}) {
4751
+ const { where, params } = _modelUsageWhere(filters);
4752
+ const limit = Math.max(1, Math.min(1000, _nonNegativeInteger(filters.limit) || 100));
4753
+ const rows = getDb().prepare(`
4754
+ SELECT *
4755
+ FROM model_usage_ledger
4756
+ ${where}
4757
+ ORDER BY datetime(occurred_at) DESC, id DESC
4758
+ LIMIT ?
4759
+ `).all(...params, limit);
4760
+ return rows.map((row) => _parseLedgerMetadata(row));
4761
+ }
4762
+
4763
+ function summarizeModelUsageLedger(filters = {}) {
4764
+ const { where, params } = _modelUsageWhere(filters);
4765
+ const rows = getDb().prepare(`
4766
+ SELECT
4767
+ date(occurred_at) AS day,
4768
+ provider_type,
4769
+ model_id,
4770
+ model_registry_id,
4771
+ COUNT(*) AS calls,
4772
+ SUM(input_tokens) AS input_tokens,
4773
+ SUM(output_tokens) AS output_tokens,
4774
+ SUM(total_tokens) AS total_tokens,
4775
+ SUM(cached_input_tokens) AS cached_input_tokens,
4776
+ SUM(reasoning_output_tokens) AS reasoning_output_tokens,
4777
+ SUM(cost_usd) AS cost_usd,
4778
+ SUM(CASE WHEN cost_usd IS NULL THEN 1 ELSE 0 END) AS missing_cost_rows,
4779
+ MIN(occurred_at) AS first_seen_at,
4780
+ MAX(occurred_at) AS last_seen_at
4781
+ FROM model_usage_ledger
4782
+ ${where}
4783
+ GROUP BY date(occurred_at), provider_type, model_id, model_registry_id
4784
+ ORDER BY datetime(last_seen_at) DESC, provider_type, model_id
4785
+ `).all(...params);
4786
+ return rows;
4787
+ }
4788
+
3353
4789
  // -- Model Evaluations --
3354
4790
 
3355
4791
  function getEvaluationDaysCutoff(days) {
@@ -3684,6 +5120,7 @@ function pruneUnsupportedCloudModelRegistryRowsForDb(d, { dryRun = false } = {})
3684
5120
  JOIN model_providers mp ON mr.provider_id = mp.id
3685
5121
  WHERE mp.type IN ('anthropic', 'openai', 'google', 'deepseek', 'moonshot')
3686
5122
  AND COALESCE(mr.is_fine_tuned, 0) = 0
5123
+ AND COALESCE(mr.source, 'catalog') IN ('catalog', 'builtin')
3687
5124
  `).all();
3688
5125
  const stale = rows.filter((row) => {
3689
5126
  const allowed = allowedByType.get(row.provider_type);
@@ -4480,10 +5917,18 @@ module.exports = {
4480
5917
  getDb,
4481
5918
  closeDb,
4482
5919
  getDbPath,
5920
+ enqueueOwnerWrite,
5921
+ drainOwnerWrites,
5922
+ getOwnerWriteQueueStatus,
5923
+ getStorageRisk,
4483
5924
  /** Mark DB as daemon-owned — closeDb() becomes no-op unless force=true */
4484
5925
  setDaemonOwned() { _daemonOwned = true; },
4485
5926
  DATA_DIR,
4486
- BACKUP_DIR,
5927
+ get BACKUP_DIR() { return _backupDirForCurrentDb(); },
5928
+ DEFAULT_BACKUP_DIR,
5929
+ getBackupDirInfo,
5930
+ setBackupDir,
5931
+ moveBackupsToDir,
4487
5932
  // Owner
4488
5933
  setOwner,
4489
5934
  getOwner,
@@ -4559,7 +6004,7 @@ module.exports = {
4559
6004
  createBackup,
4560
6005
  listBackups,
4561
6006
  deleteBackup,
4562
- startDailyBackup,
6007
+ ensureDailyBackup,
4563
6008
  // Skills
4564
6009
  insertSkill,
4565
6010
  getSkill,
@@ -4619,6 +6064,19 @@ module.exports = {
4619
6064
  completeSlackInboundEvent,
4620
6065
  releaseSlackInboundEvent,
4621
6066
  pruneSlackInboundEvents,
6067
+ // Write-lock probes
6068
+ getWriteLockStats,
6069
+ resetWriteLockStats,
6070
+ // Question digest
6071
+ listUndeliveredDigestQuestions,
6072
+ markQuestionsDelivered,
6073
+ // Brain retention
6074
+ deleteMemory,
6075
+ pruneStalePendingQuestions,
6076
+ pruneActivityLog,
6077
+ pruneSkillExecutions,
6078
+ pruneInitiativeLog,
6079
+ runBrainRetention,
4622
6080
  // Model Providers
4623
6081
  upsertModelProvider,
4624
6082
  getModelProvider,
@@ -4626,6 +6084,10 @@ module.exports = {
4626
6084
  listModelProviders,
4627
6085
  listEnabledProviders,
4628
6086
  getProviderByType,
6087
+ setProviderRoutePolicy,
6088
+ getProviderRoutePolicy,
6089
+ getPreferredModelProviderForType,
6090
+ sortModelProvidersByRoutePolicy,
4629
6091
  setProviderAuthMethod,
4630
6092
  getProviderAuthMethod,
4631
6093
  saveSetupProvider,
@@ -4640,7 +6102,15 @@ module.exports = {
4640
6102
  upsertModelRegistryEntry,
4641
6103
  getModelRegistryEntry,
4642
6104
  listModelsByProvider,
6105
+ listModelCountsByProvider,
4643
6106
  listAllModels,
6107
+ // Model Usage Ledger
6108
+ recordModelUsage,
6109
+ getModelUsageLedgerEntry,
6110
+ listModelUsageLedger,
6111
+ summarizeModelUsageLedger,
6112
+ estimateModelUsageCost,
6113
+ findModelRegistryForUsage,
4644
6114
  // Model Evaluations
4645
6115
  insertModelEvaluation,
4646
6116
  getModelScorecard,
@@ -4695,11 +6165,14 @@ module.exports = {
4695
6165
  getBestWorstExamples,
4696
6166
  // MemPalace features
4697
6167
  touchMemory,
6168
+ touchMemories,
6169
+ countMemories,
6170
+ countQuestions,
4698
6171
  decayImportance,
4699
6172
  backfillTemporalValidity,
4700
6173
  updateKnowledgeEntityLinks,
4701
6174
  // Entities
4702
- insertEntity, getEntity, findEntity, findEntityFuzzy, mergeEntities, listEntities, getEntityGraph,
6175
+ insertEntity, getEntity, findEntity, findEntityFuzzy, resolvePersonIdentities, mergeEntities, listEntities, getEntityGraph,
4703
6176
  // Memory Index
4704
6177
  insertMemoryIndex, searchMemoryIndex, getMemoryIndex,
4705
6178
  // Memory Lifecycle Hooks