@runcore-sh/runcore 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (523) hide show
  1. package/README.md +19 -19
  2. package/brain-template/agents/README.md +20 -0
  3. package/brain-template/identity/README.md +20 -0
  4. package/brain-template/identity/brand.md +25 -0
  5. package/brain-template/identity/principles.md +21 -0
  6. package/brain-template/identity/tone-of-voice.md +23 -0
  7. package/brain-template/knowledge/notes/tier-gated-ui-spec.md +1 -1
  8. package/brain-template/metrics/README.md +13 -0
  9. package/brain-template/operations/goals.yaml +12 -0
  10. package/brain-template/operations/todos.md +17 -0
  11. package/brain-template/ops/README.md +13 -0
  12. package/brain-template/scheduling/README.md +2 -2
  13. package/brain-template/skills/README.md +23 -0
  14. package/brain-template/templates/README.md +9 -0
  15. package/brain-template/training/README.md +20 -0
  16. package/dictionary.json +2 -2
  17. package/dist/activity/log.d.ts +3 -0
  18. package/dist/activity/log.d.ts.map +1 -1
  19. package/dist/activity/log.js +12 -0
  20. package/dist/activity/log.js.map +1 -1
  21. package/dist/agents/autonomous.js +1 -0
  22. package/dist/agents/autonomous.js.map +1 -1
  23. package/dist/agents/commit.d.ts.map +1 -1
  24. package/dist/agents/commit.js +3 -10
  25. package/dist/agents/commit.js.map +1 -1
  26. package/dist/agents/dedup-guard.d.ts.map +1 -1
  27. package/dist/agents/dedup-guard.js +26 -23
  28. package/dist/agents/dedup-guard.js.map +1 -1
  29. package/dist/agents/feed.d.ts +69 -0
  30. package/dist/agents/feed.d.ts.map +1 -0
  31. package/dist/agents/feed.js +176 -0
  32. package/dist/agents/feed.js.map +1 -0
  33. package/dist/agents/governance.d.ts +14 -0
  34. package/dist/agents/governance.d.ts.map +1 -1
  35. package/dist/agents/governance.js +73 -1
  36. package/dist/agents/governance.js.map +1 -1
  37. package/dist/agents/governed-spawn.d.ts +12 -0
  38. package/dist/agents/governed-spawn.d.ts.map +1 -1
  39. package/dist/agents/governed-spawn.js +8 -2
  40. package/dist/agents/governed-spawn.js.map +1 -1
  41. package/dist/agents/index.d.ts +2 -0
  42. package/dist/agents/index.d.ts.map +1 -1
  43. package/dist/agents/index.js +1 -0
  44. package/dist/agents/index.js.map +1 -1
  45. package/dist/agents/issue-reporter.d.ts +15 -0
  46. package/dist/agents/issue-reporter.d.ts.map +1 -0
  47. package/dist/agents/issue-reporter.js +123 -0
  48. package/dist/agents/issue-reporter.js.map +1 -0
  49. package/dist/agents/issues.d.ts +33 -0
  50. package/dist/agents/issues.d.ts.map +1 -0
  51. package/dist/agents/issues.js +141 -0
  52. package/dist/agents/issues.js.map +1 -0
  53. package/dist/agents/reflection.js +3 -3
  54. package/dist/agents/reflection.js.map +1 -1
  55. package/dist/agents/runtime/driver.d.ts.map +1 -1
  56. package/dist/agents/runtime/driver.js +1 -2
  57. package/dist/agents/runtime/driver.js.map +1 -1
  58. package/dist/agents/runtime/manager.d.ts.map +1 -1
  59. package/dist/agents/runtime/manager.js +1 -0
  60. package/dist/agents/runtime/manager.js.map +1 -1
  61. package/dist/agents/runtime/types.d.ts +2 -0
  62. package/dist/agents/runtime/types.d.ts.map +1 -1
  63. package/dist/agents/spawn.d.ts.map +1 -1
  64. package/dist/agents/spawn.js +20 -26
  65. package/dist/agents/spawn.js.map +1 -1
  66. package/dist/agents/store.d.ts.map +1 -1
  67. package/dist/agents/store.js +34 -1
  68. package/dist/agents/store.js.map +1 -1
  69. package/dist/agents/types.d.ts +27 -0
  70. package/dist/agents/types.d.ts.map +1 -1
  71. package/dist/auth/identity.d.ts +3 -0
  72. package/dist/auth/identity.d.ts.map +1 -1
  73. package/dist/auth/identity.js +9 -1
  74. package/dist/auth/identity.js.map +1 -1
  75. package/dist/auth/middleware.d.ts.map +1 -1
  76. package/dist/auth/middleware.js +4 -0
  77. package/dist/auth/middleware.js.map +1 -1
  78. package/dist/calibration/conversation.d.ts +46 -0
  79. package/dist/calibration/conversation.d.ts.map +1 -0
  80. package/dist/calibration/conversation.js +295 -0
  81. package/dist/calibration/conversation.js.map +1 -0
  82. package/dist/calibration/index.d.ts +5 -0
  83. package/dist/calibration/index.d.ts.map +1 -0
  84. package/dist/calibration/index.js +5 -0
  85. package/dist/calibration/index.js.map +1 -0
  86. package/dist/calibration/runner.d.ts +127 -0
  87. package/dist/calibration/runner.d.ts.map +1 -0
  88. package/dist/calibration/runner.js +307 -0
  89. package/dist/calibration/runner.js.map +1 -0
  90. package/dist/calibration/store.d.ts +49 -0
  91. package/dist/calibration/store.d.ts.map +1 -0
  92. package/dist/calibration/store.js +140 -0
  93. package/dist/calibration/store.js.map +1 -0
  94. package/dist/calibration/types.d.ts +93 -0
  95. package/dist/calibration/types.d.ts.map +1 -0
  96. package/dist/calibration/types.js +53 -0
  97. package/dist/calibration/types.js.map +1 -0
  98. package/dist/cli.d.ts +1 -1
  99. package/dist/cli.js +17 -2
  100. package/dist/cli.js.map +1 -1
  101. package/dist/config/index.d.ts +5 -0
  102. package/dist/config/index.d.ts.map +1 -0
  103. package/dist/config/index.js +3 -0
  104. package/dist/config/index.js.map +1 -0
  105. package/dist/config/loader.d.ts +28 -0
  106. package/dist/config/loader.d.ts.map +1 -0
  107. package/dist/config/loader.js +181 -0
  108. package/dist/config/loader.js.map +1 -0
  109. package/dist/config/schema.d.ts +76 -0
  110. package/dist/config/schema.d.ts.map +1 -0
  111. package/dist/config/schema.js +93 -0
  112. package/dist/config/schema.js.map +1 -0
  113. package/dist/dictionary/challenge.d.ts +66 -0
  114. package/dist/dictionary/challenge.d.ts.map +1 -0
  115. package/dist/dictionary/challenge.js +145 -0
  116. package/dist/dictionary/challenge.js.map +1 -0
  117. package/dist/dictionary/client.d.ts +32 -0
  118. package/dist/dictionary/client.d.ts.map +1 -0
  119. package/dist/dictionary/client.js +139 -0
  120. package/dist/dictionary/client.js.map +1 -0
  121. package/dist/dictionary/compatibility.d.ts +8 -0
  122. package/dist/dictionary/compatibility.d.ts.map +1 -0
  123. package/dist/dictionary/compatibility.js +56 -0
  124. package/dist/dictionary/compatibility.js.map +1 -0
  125. package/dist/dictionary/index.d.ts +22 -0
  126. package/dist/dictionary/index.d.ts.map +1 -0
  127. package/dist/dictionary/index.js +15 -0
  128. package/dist/dictionary/index.js.map +1 -0
  129. package/dist/dictionary/matcher.d.ts +18 -0
  130. package/dist/dictionary/matcher.d.ts.map +1 -0
  131. package/dist/dictionary/matcher.js +98 -0
  132. package/dist/dictionary/matcher.js.map +1 -0
  133. package/dist/dictionary/publisher.d.ts +17 -0
  134. package/dist/dictionary/publisher.d.ts.map +1 -0
  135. package/dist/dictionary/publisher.js +156 -0
  136. package/dist/dictionary/publisher.js.map +1 -0
  137. package/dist/dictionary/sync.d.ts +28 -0
  138. package/dist/dictionary/sync.d.ts.map +1 -0
  139. package/dist/dictionary/sync.js +268 -0
  140. package/dist/dictionary/sync.js.map +1 -0
  141. package/dist/dictionary/types.d.ts +75 -0
  142. package/dist/dictionary/types.d.ts.map +1 -0
  143. package/dist/dictionary/types.js +8 -0
  144. package/dist/dictionary/types.js.map +1 -0
  145. package/dist/dictionary/updater.d.ts +23 -0
  146. package/dist/dictionary/updater.d.ts.map +1 -0
  147. package/dist/dictionary/updater.js +84 -0
  148. package/dist/dictionary/updater.js.map +1 -0
  149. package/dist/dictionary/versioning.d.ts +15 -0
  150. package/dist/dictionary/versioning.d.ts.map +1 -0
  151. package/dist/dictionary/versioning.js +52 -0
  152. package/dist/dictionary/versioning.js.map +1 -0
  153. package/dist/errors.d.ts +36 -0
  154. package/dist/errors.d.ts.map +1 -0
  155. package/dist/errors.js +66 -0
  156. package/dist/errors.js.map +1 -0
  157. package/dist/events/event-bus.d.ts +37 -0
  158. package/dist/events/event-bus.d.ts.map +1 -0
  159. package/dist/events/event-bus.js +261 -0
  160. package/dist/events/event-bus.js.map +1 -0
  161. package/dist/events/index.d.ts +3 -0
  162. package/dist/events/index.d.ts.map +1 -0
  163. package/dist/events/index.js +2 -0
  164. package/dist/events/index.js.map +1 -0
  165. package/dist/events/types.d.ts +71 -0
  166. package/dist/events/types.d.ts.map +1 -0
  167. package/dist/events/types.js +7 -0
  168. package/dist/events/types.js.map +1 -0
  169. package/dist/feed/client.d.ts +34 -0
  170. package/dist/feed/client.d.ts.map +1 -0
  171. package/dist/feed/client.js +206 -0
  172. package/dist/feed/client.js.map +1 -0
  173. package/dist/feed/index.d.ts +61 -0
  174. package/dist/feed/index.d.ts.map +1 -0
  175. package/dist/feed/index.js +115 -0
  176. package/dist/feed/index.js.map +1 -0
  177. package/dist/feed/metrics.d.ts +51 -0
  178. package/dist/feed/metrics.d.ts.map +1 -0
  179. package/dist/feed/metrics.js +84 -0
  180. package/dist/feed/metrics.js.map +1 -0
  181. package/dist/feed/mixer.d.ts +89 -0
  182. package/dist/feed/mixer.d.ts.map +1 -0
  183. package/dist/feed/mixer.js +230 -0
  184. package/dist/feed/mixer.js.map +1 -0
  185. package/dist/feed/tiers.d.ts +15 -0
  186. package/dist/feed/tiers.d.ts.map +1 -0
  187. package/dist/feed/tiers.js +75 -0
  188. package/dist/feed/tiers.js.map +1 -0
  189. package/dist/feed/types.d.ts +76 -0
  190. package/dist/feed/types.d.ts.map +1 -0
  191. package/dist/feed/types.js +6 -0
  192. package/dist/feed/types.js.map +1 -0
  193. package/dist/files/registry.d.ts +77 -0
  194. package/dist/files/registry.d.ts.map +1 -0
  195. package/dist/files/registry.js +222 -0
  196. package/dist/files/registry.js.map +1 -0
  197. package/dist/google/plugin.d.ts +17 -0
  198. package/dist/google/plugin.d.ts.map +1 -0
  199. package/dist/google/plugin.js +169 -0
  200. package/dist/google/plugin.js.map +1 -0
  201. package/dist/health/checker.d.ts.map +1 -1
  202. package/dist/health/checker.js +14 -4
  203. package/dist/health/checker.js.map +1 -1
  204. package/dist/health/checks/openrouter.js +1 -1
  205. package/dist/health/checks/openrouter.js.map +1 -1
  206. package/dist/health/checks.d.ts +1 -1
  207. package/dist/health/checks.d.ts.map +1 -1
  208. package/dist/health/checks.js +9 -3
  209. package/dist/health/checks.js.map +1 -1
  210. package/dist/ledger/distance.d.ts +19 -0
  211. package/dist/ledger/distance.d.ts.map +1 -0
  212. package/dist/ledger/distance.js +70 -0
  213. package/dist/ledger/distance.js.map +1 -0
  214. package/dist/ledger/index.d.ts +8 -0
  215. package/dist/ledger/index.d.ts.map +1 -0
  216. package/dist/ledger/index.js +7 -0
  217. package/dist/ledger/index.js.map +1 -0
  218. package/dist/ledger/store.d.ts +27 -0
  219. package/dist/ledger/store.d.ts.map +1 -0
  220. package/dist/ledger/store.js +123 -0
  221. package/dist/ledger/store.js.map +1 -0
  222. package/dist/ledger/types.d.ts +109 -0
  223. package/dist/ledger/types.d.ts.map +1 -0
  224. package/dist/ledger/types.js +57 -0
  225. package/dist/ledger/types.js.map +1 -0
  226. package/dist/literacy.d.ts +50 -0
  227. package/dist/literacy.d.ts.map +1 -0
  228. package/dist/literacy.js +141 -0
  229. package/dist/literacy.js.map +1 -0
  230. package/dist/llm/complete.d.ts.map +1 -1
  231. package/dist/llm/complete.js +36 -3
  232. package/dist/llm/complete.js.map +1 -1
  233. package/dist/llm/fetch-guard.d.ts.map +1 -1
  234. package/dist/llm/fetch-guard.js +2 -0
  235. package/dist/llm/fetch-guard.js.map +1 -1
  236. package/dist/llm/membrane.d.ts +5 -0
  237. package/dist/llm/membrane.d.ts.map +1 -1
  238. package/dist/llm/membrane.js +48 -8
  239. package/dist/llm/membrane.js.map +1 -1
  240. package/dist/llm/nlp-detect.d.ts +17 -0
  241. package/dist/llm/nlp-detect.d.ts.map +1 -0
  242. package/dist/llm/nlp-detect.js +45 -0
  243. package/dist/llm/nlp-detect.js.map +1 -0
  244. package/dist/llm/ollama.d.ts +5 -0
  245. package/dist/llm/ollama.d.ts.map +1 -1
  246. package/dist/llm/ollama.js +39 -1
  247. package/dist/llm/ollama.js.map +1 -1
  248. package/dist/llm/openrouter.d.ts.map +1 -1
  249. package/dist/llm/openrouter.js +17 -1
  250. package/dist/llm/openrouter.js.map +1 -1
  251. package/dist/llm/providers/ollama.d.ts +7 -2
  252. package/dist/llm/providers/ollama.d.ts.map +1 -1
  253. package/dist/llm/providers/ollama.js +109 -17
  254. package/dist/llm/providers/ollama.js.map +1 -1
  255. package/dist/llm/providers/openrouter.js +1 -1
  256. package/dist/llm/providers/openrouter.js.map +1 -1
  257. package/dist/llm/sensitive-registry.d.ts +6 -0
  258. package/dist/llm/sensitive-registry.d.ts.map +1 -1
  259. package/dist/llm/sensitive-registry.js +60 -1
  260. package/dist/llm/sensitive-registry.js.map +1 -1
  261. package/dist/mcp-server.js +25 -18
  262. package/dist/mcp-server.js.map +1 -1
  263. package/dist/metrics/collector.d.ts +6 -0
  264. package/dist/metrics/collector.d.ts.map +1 -1
  265. package/dist/metrics/collector.js +150 -0
  266. package/dist/metrics/collector.js.map +1 -1
  267. package/dist/metrics/types.d.ts +1 -1
  268. package/dist/metrics/types.d.ts.map +1 -1
  269. package/dist/middleware/error-handler.d.ts +13 -0
  270. package/dist/middleware/error-handler.d.ts.map +1 -0
  271. package/dist/middleware/error-handler.js +71 -0
  272. package/dist/middleware/error-handler.js.map +1 -0
  273. package/dist/notifications/channels/adapter.d.ts +28 -0
  274. package/dist/notifications/channels/adapter.d.ts.map +1 -0
  275. package/dist/notifications/channels/adapter.js +55 -0
  276. package/dist/notifications/channels/adapter.js.map +1 -0
  277. package/dist/notifications/channels/index.d.ts +6 -0
  278. package/dist/notifications/channels/index.d.ts.map +1 -0
  279. package/dist/notifications/channels/index.js +6 -0
  280. package/dist/notifications/channels/index.js.map +1 -0
  281. package/dist/notifications/channels/log.d.ts +15 -0
  282. package/dist/notifications/channels/log.d.ts.map +1 -0
  283. package/dist/notifications/channels/log.js +29 -0
  284. package/dist/notifications/channels/log.js.map +1 -0
  285. package/dist/notifications/engine.d.ts +37 -0
  286. package/dist/notifications/engine.d.ts.map +1 -0
  287. package/dist/notifications/engine.js +198 -0
  288. package/dist/notifications/engine.js.map +1 -0
  289. package/dist/notifications/index.d.ts +12 -3
  290. package/dist/notifications/index.d.ts.map +1 -1
  291. package/dist/notifications/index.js +15 -3
  292. package/dist/notifications/index.js.map +1 -1
  293. package/dist/notifications/types.d.ts +97 -0
  294. package/dist/notifications/types.d.ts.map +1 -0
  295. package/dist/notifications/types.js +14 -0
  296. package/dist/notifications/types.js.map +1 -0
  297. package/dist/onboarding/bootstrap.d.ts +51 -0
  298. package/dist/onboarding/bootstrap.d.ts.map +1 -0
  299. package/dist/onboarding/bootstrap.js +92 -0
  300. package/dist/onboarding/bootstrap.js.map +1 -0
  301. package/dist/onboarding/conversation.d.ts +131 -0
  302. package/dist/onboarding/conversation.d.ts.map +1 -0
  303. package/dist/onboarding/conversation.js +259 -0
  304. package/dist/onboarding/conversation.js.map +1 -0
  305. package/dist/onboarding/flow.d.ts +63 -0
  306. package/dist/onboarding/flow.d.ts.map +1 -0
  307. package/dist/onboarding/flow.js +287 -0
  308. package/dist/onboarding/flow.js.map +1 -0
  309. package/dist/onboarding/index.d.ts +17 -0
  310. package/dist/onboarding/index.d.ts.map +1 -0
  311. package/dist/onboarding/index.js +23 -0
  312. package/dist/onboarding/index.js.map +1 -0
  313. package/dist/onboarding/name-extraction.d.ts +42 -0
  314. package/dist/onboarding/name-extraction.d.ts.map +1 -0
  315. package/dist/onboarding/name-extraction.js +164 -0
  316. package/dist/onboarding/name-extraction.js.map +1 -0
  317. package/dist/onboarding/nerve-link.d.ts +23 -0
  318. package/dist/onboarding/nerve-link.d.ts.map +1 -0
  319. package/dist/onboarding/nerve-link.js +24 -0
  320. package/dist/onboarding/nerve-link.js.map +1 -0
  321. package/dist/onboarding/phases.d.ts +66 -0
  322. package/dist/onboarding/phases.d.ts.map +1 -0
  323. package/dist/onboarding/phases.js +167 -0
  324. package/dist/onboarding/phases.js.map +1 -0
  325. package/dist/onboarding/safe-word.d.ts +39 -0
  326. package/dist/onboarding/safe-word.d.ts.map +1 -0
  327. package/dist/onboarding/safe-word.js +90 -0
  328. package/dist/onboarding/safe-word.js.map +1 -0
  329. package/dist/onboarding/types.d.ts +124 -0
  330. package/dist/onboarding/types.d.ts.map +1 -0
  331. package/dist/onboarding/types.js +46 -0
  332. package/dist/onboarding/types.js.map +1 -0
  333. package/dist/openloop/resolution-scanner.d.ts +1 -1
  334. package/dist/openloop/resolution-scanner.d.ts.map +1 -1
  335. package/dist/openloop/resolution-scanner.js +4 -14
  336. package/dist/openloop/resolution-scanner.js.map +1 -1
  337. package/dist/plugins/index.d.ts +24 -0
  338. package/dist/plugins/index.d.ts.map +1 -0
  339. package/dist/plugins/index.js +91 -0
  340. package/dist/plugins/index.js.map +1 -0
  341. package/dist/plugins/status.d.ts +10 -0
  342. package/dist/plugins/status.d.ts.map +1 -0
  343. package/dist/plugins/status.js +12 -0
  344. package/dist/plugins/status.js.map +1 -0
  345. package/dist/posture/index.d.ts +2 -2
  346. package/dist/posture/index.d.ts.map +1 -1
  347. package/dist/posture/index.js +1 -1
  348. package/dist/posture/index.js.map +1 -1
  349. package/dist/posture/types.d.ts +34 -0
  350. package/dist/posture/types.d.ts.map +1 -1
  351. package/dist/posture/types.js +28 -0
  352. package/dist/posture/types.js.map +1 -1
  353. package/dist/pulse/activation-event.d.ts +5 -4
  354. package/dist/pulse/activation-event.d.ts.map +1 -1
  355. package/dist/pulse/activation-event.js +31 -8
  356. package/dist/pulse/activation-event.js.map +1 -1
  357. package/dist/pulse/activation-log.d.ts.map +1 -1
  358. package/dist/pulse/activation-log.js.map +1 -1
  359. package/dist/pulse/index.d.ts +3 -0
  360. package/dist/pulse/index.d.ts.map +1 -1
  361. package/dist/pulse/index.js +4 -0
  362. package/dist/pulse/index.js.map +1 -1
  363. package/dist/pulse/tier.d.ts +67 -0
  364. package/dist/pulse/tier.d.ts.map +1 -0
  365. package/dist/pulse/tier.js +104 -0
  366. package/dist/pulse/tier.js.map +1 -0
  367. package/dist/pulse/work.d.ts +66 -0
  368. package/dist/pulse/work.d.ts.map +1 -0
  369. package/dist/pulse/work.js +117 -0
  370. package/dist/pulse/work.js.map +1 -0
  371. package/dist/runtime-lock.d.ts +51 -0
  372. package/dist/runtime-lock.d.ts.map +1 -0
  373. package/dist/runtime-lock.js +147 -0
  374. package/dist/runtime-lock.js.map +1 -0
  375. package/dist/server.d.ts.map +1 -1
  376. package/dist/server.js +1244 -188
  377. package/dist/server.js.map +1 -1
  378. package/dist/services/whatsapp.js +1 -1
  379. package/dist/services/whatsapp.js.map +1 -1
  380. package/dist/settings.d.ts +15 -0
  381. package/dist/settings.d.ts.map +1 -1
  382. package/dist/settings.js +48 -1
  383. package/dist/settings.js.map +1 -1
  384. package/dist/skills/index.d.ts +2 -2
  385. package/dist/skills/index.d.ts.map +1 -1
  386. package/dist/skills/index.js +2 -2
  387. package/dist/skills/index.js.map +1 -1
  388. package/dist/skills/registry.d.ts +53 -142
  389. package/dist/skills/registry.d.ts.map +1 -1
  390. package/dist/skills/registry.js +249 -611
  391. package/dist/skills/registry.js.map +1 -1
  392. package/dist/stream/emitter.d.ts +36 -0
  393. package/dist/stream/emitter.d.ts.map +1 -0
  394. package/dist/stream/emitter.js +177 -0
  395. package/dist/stream/emitter.js.map +1 -0
  396. package/dist/stream/index.d.ts +4 -0
  397. package/dist/stream/index.d.ts.map +1 -0
  398. package/dist/stream/index.js +3 -0
  399. package/dist/stream/index.js.map +1 -0
  400. package/dist/stream/types.d.ts +100 -0
  401. package/dist/stream/types.d.ts.map +1 -0
  402. package/dist/stream/types.js +19 -0
  403. package/dist/stream/types.js.map +1 -0
  404. package/dist/threads/index.d.ts +3 -0
  405. package/dist/threads/index.d.ts.map +1 -0
  406. package/dist/threads/index.js +2 -0
  407. package/dist/threads/index.js.map +1 -0
  408. package/dist/threads/store.d.ts +36 -0
  409. package/dist/threads/store.d.ts.map +1 -0
  410. package/dist/threads/store.js +171 -0
  411. package/dist/threads/store.js.map +1 -0
  412. package/dist/threads/types.d.ts +16 -0
  413. package/dist/threads/types.d.ts.map +1 -0
  414. package/dist/threads/types.js +6 -0
  415. package/dist/threads/types.js.map +1 -0
  416. package/dist/tick/index.d.ts +10 -0
  417. package/dist/tick/index.d.ts.map +1 -0
  418. package/dist/tick/index.js +8 -0
  419. package/dist/tick/index.js.map +1 -0
  420. package/dist/tick/runner.d.ts +56 -0
  421. package/dist/tick/runner.d.ts.map +1 -0
  422. package/dist/tick/runner.js +235 -0
  423. package/dist/tick/runner.js.map +1 -0
  424. package/dist/tick/types.d.ts +73 -0
  425. package/dist/tick/types.d.ts.map +1 -0
  426. package/dist/tick/types.js +8 -0
  427. package/dist/tick/types.js.map +1 -0
  428. package/dist/tier/types.js +3 -3
  429. package/dist/tier/types.js.map +1 -1
  430. package/dist/ui-sync.d.ts +34 -0
  431. package/dist/ui-sync.d.ts.map +1 -0
  432. package/dist/ui-sync.js +108 -0
  433. package/dist/ui-sync.js.map +1 -0
  434. package/dist/utils/git.d.ts +12 -0
  435. package/dist/utils/git.d.ts.map +1 -0
  436. package/dist/utils/git.js +44 -0
  437. package/dist/utils/git.js.map +1 -0
  438. package/dist/volumes/index.d.ts +3 -0
  439. package/dist/volumes/index.d.ts.map +1 -0
  440. package/dist/volumes/index.js +2 -0
  441. package/dist/volumes/index.js.map +1 -0
  442. package/dist/volumes/manager.d.ts +83 -0
  443. package/dist/volumes/manager.d.ts.map +1 -0
  444. package/dist/volumes/manager.js +462 -0
  445. package/dist/volumes/manager.js.map +1 -0
  446. package/dist/volumes/types.d.ts +66 -0
  447. package/dist/volumes/types.d.ts.map +1 -0
  448. package/dist/volumes/types.js +8 -0
  449. package/dist/volumes/types.js.map +1 -0
  450. package/package.json +8 -5
  451. package/public/avatar/Hey-Dash_en_windows_v4_0_0.zip +0 -0
  452. package/public/avatar/README.md +43 -0
  453. package/public/avatar/cache/06fa55aececcc478.mp4 +0 -0
  454. package/public/avatar/cache/07a65738ba170827.mp4 +0 -0
  455. package/public/avatar/cache/08b6f4880f59a385.mp4 +0 -0
  456. package/public/avatar/cache/0ef9e0e78d715af4.mp4 +0 -0
  457. package/public/avatar/cache/0fa85e9e8f444a8b.mp4 +0 -0
  458. package/public/avatar/cache/1185fd491f413406.mp4 +0 -0
  459. package/public/avatar/cache/1b374d5390258fea.mp4 +0 -0
  460. package/public/avatar/cache/1e2367029b92f8aa.mp4 +0 -0
  461. package/public/avatar/cache/272c004a41087de5.mp4 +0 -0
  462. package/public/avatar/cache/2a0f3ff34d92521a.mp4 +0 -0
  463. package/public/avatar/cache/307a6f70859aeab8.mp4 +0 -0
  464. package/public/avatar/cache/332384e088ca214b.mp4 +0 -0
  465. package/public/avatar/cache/39fc4e81574d14ed.mp4 +0 -0
  466. package/public/avatar/cache/4a5c6051c1ef6a71.mp4 +0 -0
  467. package/public/avatar/cache/51f4aa76398c8c29.mp4 +0 -0
  468. package/public/avatar/cache/5d9a960bbf71732c.mp4 +0 -0
  469. package/public/avatar/cache/5e0954401e15af89.mp4 +0 -0
  470. package/public/avatar/cache/884ae6717fcacdd5.mp4 +0 -0
  471. package/public/avatar/cache/8ea0b7220d139615.mp4 +0 -0
  472. package/public/avatar/cache/9b9c4f7b8508eecc.mp4 +0 -0
  473. package/public/avatar/cache/9be1030ec2aa2b01.mp4 +0 -0
  474. package/public/avatar/cache/b35f7a3d558f22cb.mp4 +0 -0
  475. package/public/avatar/cache/be89f49970672374.mp4 +0 -0
  476. package/public/avatar/cache/c11fdc99479492b6.mp4 +0 -0
  477. package/public/avatar/cache/c900811e3382ac6d.mp4 +0 -0
  478. package/public/avatar/cache/d42a73667acf5716.mp4 +0 -0
  479. package/public/avatar/cache/e539f247a8908603.mp4 +0 -0
  480. package/public/avatar/cache/e78fceae2373b7c1.mp4 +0 -0
  481. package/public/avatar/cache/ec95af57d33b3f07.mp4 +0 -0
  482. package/public/avatar/cache/eeb8d775f40dbe2c.mp4 +0 -0
  483. package/public/avatar/dash_headhshot_v1.png +0 -0
  484. package/public/avatar/idle.mp4 +0 -0
  485. package/public/avatar/photo.png +0 -0
  486. package/public/board.html +6 -0
  487. package/public/browser.html +6 -2
  488. package/public/demo-data/Family Photos/2024/christmas-dinner.txt +13 -0
  489. package/public/demo-data/Family Photos/2025/summer-cookout.txt +13 -0
  490. package/public/demo-data/Financial/Insurance/auto-policy.txt +26 -0
  491. package/public/demo-data/Financial/Insurance/homeowners-policy.txt +20 -0
  492. package/public/demo-data/Financial/Taxes/property-tax-2025.txt +18 -0
  493. package/public/demo-data/Financial/Taxes/w2-2025.txt +18 -0
  494. package/public/demo-data/Health Records/lab-results-2026.txt +26 -0
  495. package/public/demo-data/Health Records/prescription-list.txt +24 -0
  496. package/public/demo-data/Health Records/vaccination-record.csv +8 -0
  497. package/public/demo-data/Legal/Estate/beneficiary-contacts.csv +4 -0
  498. package/public/demo-data/Legal/Estate/will-summary.txt +22 -0
  499. package/public/demo-data/Recipes/christmas-cookies.md +25 -0
  500. package/public/demo-data/Recipes/grandmas-chili.md +30 -0
  501. package/public/demo-data/Work/Contracts/lawn-service-2026.txt +23 -0
  502. package/public/demo-data/Work/Projects/project-status.md +19 -0
  503. package/public/demo-data/passwords.txt +13 -0
  504. package/public/demo-ingest.html +388 -0
  505. package/public/help.html +4 -1
  506. package/public/icon-192.png +0 -0
  507. package/public/icon-512.png +0 -0
  508. package/public/index.html +2641 -574
  509. package/public/library.html +51 -29
  510. package/public/manifest.json +21 -0
  511. package/public/nerve/icon-192.svg +6 -0
  512. package/public/nerve/icon-512.svg +6 -0
  513. package/public/nerve/index.html +698 -0
  514. package/public/nerve/manifest.json +24 -0
  515. package/public/nerve/sw.js +84 -0
  516. package/public/observatory.html +5 -1
  517. package/public/ops.html +33 -3
  518. package/public/pulse.html +3 -0
  519. package/public/registry.html +6 -2
  520. package/public/roadmap.html +7 -2
  521. package/public/sw.js +65 -0
  522. package/brain-template/registry.md +0 -566
  523. package/brain-template/rest_api-integration.md +0 -522
package/dist/server.js CHANGED
@@ -9,12 +9,17 @@ import { serveStatic } from "@hono/node-server/serve-static";
9
9
  import { join, dirname } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { readFile, writeFile, mkdir } from "node:fs/promises";
12
- // Package root (where public/ lives) — works whether run from CWD or npx
12
+ import { acquireLock, releaseLock } from "./runtime-lock.js";
13
+ // Package root — works whether run from CWD or npx
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const __dirname = dirname(__filename);
15
- const PKG_ROOT = join(__dirname, "..");
16
+ const PKG_ROOT = __dirname.endsWith("dist") ? join(__dirname, "..") : __dirname;
17
+ // UI directory — resolved at startup. Prefers CDN-synced, falls back to bundled.
18
+ let UI_DIR = getUiPublicDir(PKG_ROOT);
16
19
  import { writeFileSync } from "node:fs";
20
+ import { appendFile } from "node:fs/promises";
17
21
  import { initInstanceName, getInstanceName, setInstanceName, getInstanceNameLower, resolveEnv, getAlertEmailFrom } from "./instance.js";
22
+ import { syncUi, getUiPublicDir } from "./ui-sync.js";
18
23
  import { readBrainFile, writeBrainFile, appendBrainLine } from "./lib/brain-io.js";
19
24
  import { runWithAuditContext } from "./lib/audit.js";
20
25
  import { Brain } from "./brain.js";
@@ -22,7 +27,7 @@ import { FileSystemLongTermMemory } from "./memory/file-backed.js";
22
27
  import { createLogger } from "./utils/logger.js";
23
28
  const log = createLogger("server");
24
29
  const agentLog = createLogger("agent");
25
- import { ensurePairingCode, getStatus, pair, authenticate, getRecoveryQuestion, recover, validateSession, readHuman, restoreSession, cacheSessionKey, } from "./auth/identity.js";
30
+ import { ensurePairingCode, getStatus, pair, authenticate, getRecoveryQuestion, recover, validateSession, readHuman, restoreSession, cacheSessionKey, createSession, } from "./auth/identity.js";
26
31
  import { requireSession } from "./auth/middleware.js";
27
32
  import { getProvider } from "./llm/providers/index.js";
28
33
  import { withStreamRetry } from "./llm/retry.js";
@@ -31,7 +36,8 @@ import { PrivateModeError, isPrivateMode, checkOllamaHealth } from "./llm/guard.
31
36
  import { installFetchGuard } from "./llm/fetch-guard.js";
32
37
  import { SensitiveRegistry } from "./llm/sensitive-registry.js";
33
38
  import { PrivacyMembrane } from "./llm/membrane.js";
34
- import { setActiveMembrane, rehydrateResponse } from "./llm/redact.js";
39
+ import { setActiveMembrane, getActiveMembrane, rehydrateResponse } from "./llm/redact.js";
40
+ import { VolumeManager } from "./volumes/index.js";
35
41
  import { loadSettings, getSettings, updateSettings, resolveProvider, resolveChatModel, resolveUtilityModel, getPulseSettings, getMeshConfig, } from "./settings.js";
36
42
  import { startMdns, stopMdns } from "./mdns.js";
37
43
  import { ingestDirectory } from "./files/ingest.js";
@@ -93,7 +99,7 @@ import { tracingMiddleware } from "./tracing/middleware.js";
93
99
  import { attachOTelToBus } from "./tracing/bridge.js";
94
100
  import { HealthChecker, memoryCheck, eventLoopCheck, availabilityCheck, cpuCheck, diskUsageCheck, diskCheck, queueStoreCheck, agentCapacityCheck, agentHealthCheck, boardCheck, RecoveryManager, sidecarRecovery, AlertManager, defaultAlertConfig, } from "./health/index.js";
95
101
  import { NotificationDispatcher, EmailChannel, PhoneChannel } from "./notifications/index.js";
96
- import { createSkillRegistry, getSkillRegistry } from "./skills/index.js";
102
+ import { skillRegistry as _skillRegistry } from "./skills/index.js";
97
103
  import { createModuleRegistry, getModuleRegistry } from "./modules/index.js";
98
104
  import { createCapabilityRegistry, getCapabilityRegistry, calendarCapability, emailCapability, docsCapability, boardCapability, browserCapability, closeBrowser, taskDoneCapability, calendarContextProvider, emailContextProvider, createWebSearchContextProvider, vaultContextProvider } from "./capabilities/index.js";
99
105
  import { MetricsStore, startCollector, stopCollector, registerDefaultThresholds, evaluateAlerts, buildDashboard, metricsMiddleware, collectPrometheus, generatePeriodStats, generateComparisonReport, } from "./metrics/index.js";
@@ -133,7 +139,10 @@ function pickStreamFn() {
133
139
  return withStreamRetry((options) => provider.streamChat(options), { maxRetries: 3, baseDelayMs: 1_000, maxDelayMs: 30_000 });
134
140
  }
135
141
  // --- Config ---
136
- const PORT = parseInt(process.env.CORE_PORT ?? resolveEnv("PORT") ?? "0", 10);
142
+ import { getLastPort } from "./runtime-lock.js";
143
+ const _envPort = parseInt(process.env.CORE_PORT ?? resolveEnv("PORT") ?? "0", 10);
144
+ // Sticky port: if no explicit port set, reuse the last known port from runtime lock
145
+ const PORT = _envPort === 0 ? getLastPort() : _envPort;
137
146
  let actualPort = PORT;
138
147
  const SIDECAR_PORT = resolveEnv("SEARCH_PORT") ?? "3578";
139
148
  import { BRAIN_DIR } from "./lib/paths.js";
@@ -156,9 +165,25 @@ const alertManager = new AlertManager(health, defaultAlertConfig(), alertDispatc
156
165
  // --- Metrics ---
157
166
  const metricsStore = new MetricsStore(BRAIN_DIR);
158
167
  registerDefaultThresholds();
168
+ // --- Volume manager ---
169
+ const volumeManager = new VolumeManager(BRAIN_DIR);
170
+ volumeManager.init().catch((err) => log.warn("Volume manager init failed — single-volume mode", { error: String(err) }));
159
171
  const chatSessions = new Map();
160
172
  const sessionKeys = new Map();
161
173
  let goalTimerStarted = false;
174
+ /** Threads per session. Key = sessionId, value = Map<threadId, ChatThread>. */
175
+ const sessionThreads = new Map();
176
+ function getThreadsForSession(sessionId) {
177
+ let threads = sessionThreads.get(sessionId);
178
+ if (!threads) {
179
+ threads = new Map();
180
+ sessionThreads.set(sessionId, threads);
181
+ }
182
+ return threads;
183
+ }
184
+ function generateThreadId() {
185
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
186
+ }
162
187
  /** One-time startup token for zero-friction local auth. */
163
188
  let startupToken = null;
164
189
  export function getStartupToken() {
@@ -170,6 +195,7 @@ const tracer = new Tracer();
170
195
  let instanceManager = null;
171
196
  let agentPool = null;
172
197
  let workflowEngine = null;
198
+ let activeSensitiveRegistry = null;
173
199
  /** Get the current instance manager (or null if not initialized). */
174
200
  function getInstanceManager() {
175
201
  return instanceManager;
@@ -186,6 +212,13 @@ async function getOrCreateChatSession(sessionId, name) {
186
212
  let cs = chatSessions.get(sessionId);
187
213
  if (cs)
188
214
  return cs;
215
+ // Single-user system: reuse existing chat session from any prior session ID.
216
+ // This ensures all tabs/devices see the same conversation history.
217
+ if (chatSessions.size > 0) {
218
+ const [existingId, existingCs] = chatSessions.entries().next().value;
219
+ chatSessions.set(sessionId, existingCs);
220
+ return existingCs;
221
+ }
189
222
  // Read custom personality instructions (empty string if file doesn't exist)
190
223
  let personality = "";
191
224
  try {
@@ -207,18 +240,74 @@ async function getOrCreateChatSession(sessionId, name) {
207
240
  await ltm.init();
208
241
  const brain = new Brain({
209
242
  systemPrompt: [
210
- `You are ${getInstanceName()}, a personal AI agent. You just came into existence and paired with your human partner, ${name}.`,
243
+ `IDENTITY:`,
244
+ `- Your name is ${getInstanceName()}.`,
245
+ `- The human you are talking to is named ${name}. When they say "my name" they mean "${name}".`,
246
+ `- You are ${name}'s personal AI agent, running locally on their machine. This conversation is private.`,
247
+ `- Today is ${new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}. Current tier: ${activeTier}.`,
211
248
  ``,
212
- `CRITICAL RULES:`,
213
- `- NEVER invent information. You have no knowledge of reports, accounts, schedules, or tasks unless they appear in the context below.`,
214
- `- If context is provided below, reference ONLY that. If no context is provided, you know nothing yet — and that's okay.`,
215
- `- This is a new relationship. You and ${name} are just getting to know each other. Be curious. Ask real questions.`,
216
- `- Be warm, honest, and direct. Have personality. Don't be a corporate assistant.`,
217
- `- If you don't know something, say so plainly. Never fabricate details to seem helpful.`,
218
- `- NEVER reference board items, tasks, backlog items, or project work unless they appear verbatim in injected context below. If no "board issues" section is present, you know NOTHING about the board — do not guess, summarize from memory, or invent items.`,
219
- `- NEVER claim you searched the web unless "Web search results" appear in your context. If no search results are present, you did NOT search.`,
249
+ // Tier-aware capability boundaries
250
+ ...(() => {
251
+ const caps = TIER_CAPS[activeTier];
252
+ const can = [];
253
+ const cannot = [];
254
+ // Always available
255
+ can.push("chat and answer questions");
256
+ can.push("remember things and learn from conversations");
257
+ if (caps.ollama)
258
+ can.push("use local AI models via Ollama");
259
+ // Gated capabilities — be explicit about what's off
260
+ if (caps.integrations) {
261
+ can.push("connect to external services (Google, Slack, etc.)");
262
+ }
263
+ else {
264
+ cannot.push("connect to external services (Google, Slack, email)");
265
+ }
266
+ if (caps.vault) {
267
+ can.push("manage API keys and credentials");
268
+ }
269
+ else {
270
+ cannot.push("manage API keys or a credential vault");
271
+ }
272
+ if (caps.spawning) {
273
+ can.push("spawn sub-agents to edit code and run tasks");
274
+ }
275
+ else {
276
+ cannot.push("spawn agents, edit code, or run shell commands");
277
+ }
278
+ if (caps.voice) {
279
+ can.push("speak and listen (voice I/O)");
280
+ }
281
+ else {
282
+ cannot.push("use voice input or output");
283
+ }
284
+ if (caps.mesh) {
285
+ can.push("communicate with other instances on the network");
286
+ }
287
+ else {
288
+ cannot.push("reach other instances or the network");
289
+ }
290
+ if (caps.alerting) {
291
+ can.push("send alerts via SMS, email, or webhooks");
292
+ }
293
+ else {
294
+ cannot.push("send SMS, email, or webhook alerts");
295
+ }
296
+ const lines = [`CAPABILITIES (tier: ${activeTier}):`];
297
+ lines.push(`You CAN: ${can.join("; ")}.`);
298
+ if (cannot.length > 0) {
299
+ lines.push(`You CANNOT: ${cannot.join("; ")}.`);
300
+ lines.push(`Do NOT offer, suggest, or pretend to do things you cannot. If ${name} asks for something outside your capabilities, explain what tier unlocks it and how to upgrade (Settings → API Keys, or run \`runcore register\`).`);
301
+ }
302
+ return lines;
303
+ })(),
220
304
  ``,
221
- `You are running locally on ${name}'s machine. This conversation is private.`,
305
+ `RULES:`,
306
+ `- Be warm, honest, and direct. Have personality. Don't be a corporate assistant.`,
307
+ `- If you don't know something, say so. Never invent information.`,
308
+ `- Only reference data that appears in the context below. If nothing is provided, you know nothing yet.`,
309
+ `- NEVER reference board items, tasks, or project work unless they appear verbatim below.`,
310
+ `- NEVER claim you searched the web unless search results appear in your context.`,
222
311
  ...(personality ? [``, `--- Custom personality ---`, personality, `--- End custom personality ---`] : []),
223
312
  isSearchAvailable()
224
313
  ? `You have web search capability. When search results appear in your context, use them to answer. You don't control when searches happen — the system handles that automatically.`
@@ -257,32 +346,35 @@ async function getOrCreateChatSession(sessionId, name) {
257
346
  }
258
347
  return fragments;
259
348
  })(),
260
- `## Agent spawning (CRITICAL — follow exactly)`,
261
- `When a task requires code editing, file operations, or shell commands, you MUST spawn a Claude Code agent.`,
262
- `Do NOT describe what you would do actually spawn the agent by including the block below.`,
263
- `The block content MUST be valid JSON with "label" and "prompt" keys. No markdown, no backticks, no explanation inside the block.`,
264
- ``,
265
- `### Agent prompt quality rules (MANDATORY)`,
266
- `Agents are Claude Code sessions that edit files. They need CONCRETE instructions or they will fail.`,
267
- `NEVER spawn an agent for a task that lacks a clear spec, requirement, or file to work on. If a board item is vague (e.g. "Rules Engine", "Skills Library"), do NOT spawn an agent — instead tell ${name} the item needs a spec first.`,
268
- `Every agent prompt MUST include:`,
269
- `- Real file paths from this project (e.g. "Edit src/queue/store.ts to add...")`,
270
- `- What specifically to build, change, or fix`,
271
- `- How it connects to existing code`,
272
- `WRONG prompt: "Build a comprehensive rules engine with prioritization, conflict resolution, versioning..."`,
273
- `RIGHT prompt: "In src/agents/spawn.ts, add a timeout retry: when an agent exits with code 1, re-spawn it once with the same prompt. Update the exit handler at line 77."`,
274
- `If you cannot write a prompt with real file paths and concrete changes, the task is not ready to spawn. Tell ${name} what's missing and either propose a spec or ask what they want. Never go silent — if you can't act, communicate.`,
275
- ``,
276
- `Format (place at the END of your response, OUTSIDE any code blocks):`,
277
- `[AGENT_REQUEST]`,
278
- `{"label": "short task name", "prompt": "Detailed instructions for the agent including file paths and what to do", "taskId": "internal-id-from-board"}`,
279
- `[/AGENT_REQUEST]`,
280
- `Include "taskId" when spawning for a specific board item (use the internal id, not the DASH-N identifier). This locks the task so other agents don't pick it up concurrently.`,
281
- ``,
282
- `You can include multiple [AGENT_REQUEST] blocks to run tasks in parallel.`,
283
- `WRONG: Describing the agent request in prose. WRONG: Wrapping the block in \`\`\`markdown. WRONG: Putting non-JSON text inside the block.`,
284
- `RIGHT: Plain [AGENT_REQUEST] tag, one line of JSON, [/AGENT_REQUEST] tag. Nothing else inside.`,
285
- `Agent failures are normal (auth issues, timeouts, environment mismatches). Never stop spawning agents because of past failures.`,
349
+ ...(TIER_CAPS[activeTier].spawning ? [
350
+ `## Agent spawning (CRITICAL follow exactly)`,
351
+ `When a task requires code editing, file operations, or shell commands, you MUST spawn a Claude Code agent.`,
352
+ `Do NOT describe what you would do actually spawn the agent by including the block below.`,
353
+ `The block content MUST be valid JSON with "label" and "prompt" keys. No markdown, no backticks, no explanation inside the block.`,
354
+ ``,
355
+ `### Agent prompt quality rules (MANDATORY)`,
356
+ `Agents are Claude Code sessions that edit files. They need CONCRETE instructions or they will fail.`,
357
+ `NEVER spawn an agent for a task that lacks a clear spec, requirement, or file to work on. If a board item is vague (e.g. "Rules Engine", "Skills Library"), do NOT spawn an agent — instead tell ${name} the item needs a spec first.`,
358
+ `Every agent prompt MUST include:`,
359
+ `- Real file paths from this project (e.g. "Edit src/queue/store.ts to add...")`,
360
+ `- What specifically to build, change, or fix`,
361
+ `- How it connects to existing code`,
362
+ `WRONG prompt: "Build a comprehensive rules engine with prioritization, conflict resolution, versioning..."`,
363
+ `RIGHT prompt: "In src/agents/spawn.ts, add a timeout retry: when an agent exits with code 1, re-spawn it once with the same prompt. Update the exit handler at line 77."`,
364
+ `If you cannot write a prompt with real file paths and concrete changes, the task is not ready to spawn. Tell ${name} what's missing and either propose a spec or ask what they want. Never go silent — if you can't act, communicate.`,
365
+ ``,
366
+ `Format (place at the END of your response, OUTSIDE any code blocks):`,
367
+ `[AGENT_REQUEST]`,
368
+ `{"label": "short task name", "prompt": "Detailed instructions for the agent including file paths and what to do", "taskId": "internal-id-from-board"}`,
369
+ `[/AGENT_REQUEST]`,
370
+ `Include "taskId" when spawning for a specific board item (use the internal id, not the DASH-N identifier). This locks the task so other agents don't pick it up concurrently.`,
371
+ ``,
372
+ `You can include multiple [AGENT_REQUEST] blocks to run tasks in parallel.`,
373
+ `WRONG: Describing the agent request in prose. WRONG: Wrapping the block in \`\`\`markdown. WRONG: Putting non-JSON text inside the block.`,
374
+ `RIGHT: Plain [AGENT_REQUEST] tag, one line of JSON, [/AGENT_REQUEST] tag. Nothing else inside.`,
375
+ `Agent failures are normal (auth issues, timeouts, environment mismatches). Never stop spawning agents because of past failures.`,
376
+ `IMPORTANT: Do NOT announce agent spawns in your visible response text. No "Agent spawned to...", no "I'll spawn an agent...", no "Let me run an agent...". The UI shows agent status automatically. Just include the [AGENT_REQUEST] block silently at the end. Your visible text should answer the user's question or continue the conversation naturally.`,
377
+ ] : []), // end spawning gate
286
378
  // Inject instance-readable vault values (CORE_*/DASH_* prefixed only — never secrets)
287
379
  ...(() => {
288
380
  const readable = getDashReadableVault();
@@ -297,19 +389,21 @@ async function getOrCreateChatSession(sessionId, name) {
297
389
  ];
298
390
  })(),
299
391
  ``,
300
- `## Autonomous work (already running)`,
301
- `You have a background timer that checks the backlog every 15 minutes.`,
302
- `When agents are idle and actionable items exist, a planner LLM picks tasks and spawns agents automatically.`,
303
- `${name} does not need to be in chat for this to work work continues in the background.`,
304
- `Key facts:`,
305
- `- Fires 60s after boot, then every 15 min`,
306
- `- Only picks items in backlog/todo state, unassigned, not on cooldown`,
307
- `- Max 5 agents per round, up to 5 continuation rounds per session`,
308
- `- Failed tasks get escalating cooldowns (30min 1hr 2hr 4hr) so they won't retry immediately`,
309
- `- All activity logged to brain/ops/activity.jsonl`,
310
- `- Circuit breaker pauses work for 30min if API credits run out`,
311
- `When asked about autonomous work, explain this system accurately. You CAN work while ${name} is away.`,
312
- `The user can type "auto" in chat to see the current autonomous status.`,
392
+ ...(TIER_CAPS[activeTier].spawning ? [
393
+ `## Autonomous work (already running)`,
394
+ `You have a background timer that checks the backlog every 15 minutes.`,
395
+ `When agents are idle and actionable items exist, a planner LLM picks tasks and spawns agents automatically.`,
396
+ `${name} does not need to be in chat for this to work — work continues in the background.`,
397
+ `Key facts:`,
398
+ `- Fires 60s after boot, then every 15 min`,
399
+ `- Only picks items in backlog/todo state, unassigned, not on cooldown`,
400
+ `- Max 5 agents per round, up to 5 continuation rounds per session`,
401
+ `- Failed tasks get escalating cooldowns (30min → 1hr → 2hr → 4hr) so they won't retry immediately`,
402
+ `- All activity logged to brain/ops/activity.jsonl`,
403
+ `- Circuit breaker pauses work for 30min if API credits run out`,
404
+ `When asked about autonomous work, explain this system accurately. You CAN work while ${name} is away.`,
405
+ `The user can type "auto" in chat to see the current autonomous status.`,
406
+ ] : []), // end autonomous gate
313
407
  ``,
314
408
  `## Security: encrypted memories`,
315
409
  `Some of your memories (experiences, decisions, failures) are encrypted at rest. They are only available when ${name} has authenticated with their password.`,
@@ -345,17 +439,23 @@ async function getOrCreateChatSession(sessionId, name) {
345
439
  chatSessions.set(sessionId, cs);
346
440
  return cs;
347
441
  }
442
+ // --- App ---
443
+ import { TIER_CAPS } from "./tier/types.js";
348
444
  let activeTier = "local";
349
445
  const app = new Hono();
350
- // Global error handler — return JSON with details instead of plain "Internal Server Error"
446
+ // Global error handler — structured JSON errors
447
+ import { errorHandler, ApiError } from "./middleware/error-handler.js";
351
448
  app.onError((err, c) => {
449
+ // Use structured handler for ApiErrors; preserve original behavior for others
450
+ if (err instanceof ApiError)
451
+ return errorHandler(err, c);
352
452
  const msg = err instanceof Error ? err.message : String(err);
353
453
  const stack = err instanceof Error ? err.stack : undefined;
354
454
  log.error("Unhandled route error", { error: msg, stack, path: c.req.path, method: c.req.method });
355
- return c.json({ error: msg }, 500);
455
+ return c.json({ error: msg, code: "INTERNAL_ERROR", status: 500 }, 500);
356
456
  });
357
- // Serve static files from public/ (relative to package, not CWD)
358
- app.use("/public/*", serveStatic({ root: PKG_ROOT }));
457
+ // Serve static files from UI directory (CDN-synced or bundled fallback)
458
+ app.use("/public/*", serveStatic({ root: join(UI_DIR, ".."), rewriteRequestPath: (p) => p }));
359
459
  // --- HTML template cache (replaces {{INSTANCE_NAME}} with configured name) ---
360
460
  const htmlCache = new Map();
361
461
  async function serveHtmlTemplate(filePath) {
@@ -373,12 +473,21 @@ async function serveHtmlTemplate(filePath) {
373
473
  app.use("/api/*", requireSession());
374
474
  // Serve index.html at root
375
475
  app.get("/", async (c) => {
376
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "index.html"));
476
+ const html = await serveHtmlTemplate(join(UI_DIR, "index.html"));
377
477
  return c.html(html);
378
478
  });
479
+ // --- PWA assets (must be served from root for scope) ---
480
+ app.get("/manifest.json", async (c) => {
481
+ const data = await readFile(join(UI_DIR, "manifest.json"), "utf-8");
482
+ return c.json(JSON.parse(data));
483
+ });
484
+ app.get("/sw.js", async (c) => {
485
+ const data = await readFile(join(UI_DIR, "sw.js"), "utf-8");
486
+ return c.newResponse(data, 200, { "Content-Type": "application/javascript", "Service-Worker-Allowed": "/" });
487
+ });
379
488
  // --- Nerve endpoint (PWA) ---
380
489
  app.get("/nerve", async (c) => {
381
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "nerve", "index.html"));
490
+ const html = await serveHtmlTemplate(join(UI_DIR, "nerve", "index.html"));
382
491
  return c.html(html);
383
492
  });
384
493
  // --- Audit context middleware ---
@@ -396,6 +505,7 @@ app.use("/api/*", metricsMiddleware());
396
505
  app.use("/api/pair", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
397
506
  app.use("/api/auth", rateLimit({ windowMs: 15 * 60_000, max: 10 }));
398
507
  app.use("/api/recover", rateLimit({ windowMs: 15 * 60_000, max: 5 }));
508
+ app.use("/api/mobile/redeem", rateLimit({ windowMs: 15 * 60_000, max: 5 }));
399
509
  // Dashboard endpoints get a separate, more generous limit — they poll frequently.
400
510
  app.use("/api/ops/*", rateLimit({ windowMs: 60_000, max: 300 }));
401
511
  // General API rate limit — skip paths that already have their own limiter.
@@ -469,6 +579,8 @@ app.get("/api/tier", async (c) => {
469
579
  return c.json({
470
580
  tier,
471
581
  capabilities: TIER_CAPS[tier] ?? TIER_CAPS.local,
582
+ model: resolveChatModel() ?? "auto",
583
+ provider: resolveProvider(),
472
584
  });
473
585
  });
474
586
  // Pairing ceremony
@@ -583,6 +695,378 @@ app.post("/api/recover", async (c) => {
583
695
  await loadVault(result.sessionKey);
584
696
  return c.json({ sessionId: result.session.id, name: result.name });
585
697
  });
698
+ const deviceVouchers = new Map();
699
+ const pairedDevices = new Map();
700
+ // Load paired devices from brain on startup (called later in init)
701
+ async function loadPairedDevices() {
702
+ try {
703
+ const raw = await readFile(join(BRAIN_DIR, "identity", "devices.json"), "utf-8");
704
+ const devices = JSON.parse(raw);
705
+ for (const d of devices) {
706
+ pairedDevices.set(d.deviceToken, d);
707
+ // Re-create session so phone doesn't need to re-pair after Core restart
708
+ if (d.sessionId) {
709
+ createSession(d.humanName || d.label, d.sessionId);
710
+ log.debug("Restored session for paired device", { label: d.label, humanName: d.humanName });
711
+ }
712
+ }
713
+ }
714
+ catch { /* no devices yet */ }
715
+ }
716
+ async function savePairedDevices() {
717
+ try {
718
+ await mkdir(join(BRAIN_DIR, "identity"), { recursive: true });
719
+ await writeFile(join(BRAIN_DIR, "identity", "devices.json"), JSON.stringify([...pairedDevices.values()], null, 2), "utf-8");
720
+ }
721
+ catch { /* best effort */ }
722
+ }
723
+ // Issue a device voucher (requires active session — you're on the PC)
724
+ app.post("/api/mobile/voucher", async (c) => {
725
+ const sessionId = c.req.query("sessionId") || c.req.header("x-session-id");
726
+ if (!sessionId || !validateSession(sessionId)) {
727
+ return c.json({ error: "Unauthorized" }, 401);
728
+ }
729
+ const { randomBytes: rng } = await import("node:crypto");
730
+ const { createHash } = await import("node:crypto");
731
+ const token = `dv_${rng(8).toString("hex")}`;
732
+ const instanceHash = createHash("sha256")
733
+ .update(getInstanceName() + BRAIN_DIR)
734
+ .digest("hex")
735
+ .slice(0, 16);
736
+ const voucher = {
737
+ token,
738
+ instanceHash,
739
+ instanceName: getInstanceName(),
740
+ createdAt: Date.now(),
741
+ expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
742
+ consumed: false,
743
+ };
744
+ deviceVouchers.set(token, voucher);
745
+ // Clean expired vouchers
746
+ for (const [k, v] of deviceVouchers) {
747
+ if (v.expiresAt < Date.now())
748
+ deviceVouchers.delete(k);
749
+ }
750
+ // The QR payload — a URL the phone opens directly
751
+ const voucherPayload = encodeURIComponent(JSON.stringify({
752
+ relay: "https://runcore.sh",
753
+ token,
754
+ instance: instanceHash,
755
+ name: voucher.instanceName,
756
+ }));
757
+ const qrUrl = `https://runcore.sh/pair#${voucherPayload}`;
758
+ // Generate QR code as data URL (server-side, proven library)
759
+ let qrDataUrl = "";
760
+ try {
761
+ const QRCode = (await import("qrcode")).default;
762
+ qrDataUrl = await QRCode.toDataURL(qrUrl, {
763
+ width: 250,
764
+ margin: 2,
765
+ color: { dark: "#000000", light: "#ffffff" },
766
+ errorCorrectionLevel: "L",
767
+ });
768
+ }
769
+ catch (err) {
770
+ log.warn("QR generation failed", { error: err instanceof Error ? err.message : String(err) });
771
+ }
772
+ return c.json({
773
+ token,
774
+ expiresIn: 600,
775
+ qrData: qrUrl,
776
+ qrImage: qrDataUrl,
777
+ instanceName: voucher.instanceName,
778
+ });
779
+ });
780
+ // Get voucher info (phone hits this to personalize before asking for safe word)
781
+ // Public — returns only display info, nothing secret
782
+ app.get("/api/mobile/info/:token", async (c) => {
783
+ const token = c.req.param("token");
784
+ const voucher = deviceVouchers.get(token);
785
+ if (!voucher || voucher.consumed || voucher.expiresAt < Date.now()) {
786
+ return c.json({ error: "Invalid or expired voucher" }, 404);
787
+ }
788
+ return c.json({
789
+ instanceName: voucher.instanceName,
790
+ expiresIn: Math.max(0, Math.round((voucher.expiresAt - Date.now()) / 1000)),
791
+ });
792
+ });
793
+ // Redeem voucher with safe word → device token
794
+ app.post("/api/mobile/redeem", async (c) => {
795
+ const body = await c.req.json();
796
+ const { token, password } = body;
797
+ if (!token || !password) {
798
+ return c.json({ error: "Voucher token and password required" }, 400);
799
+ }
800
+ const voucher = deviceVouchers.get(token);
801
+ if (!voucher || voucher.consumed || voucher.expiresAt < Date.now()) {
802
+ return c.json({ error: "Invalid or expired voucher" }, 404);
803
+ }
804
+ // Validate safe word via existing auth
805
+ const authResult = await authenticate(password);
806
+ if ("error" in authResult) {
807
+ return c.json({ error: "Invalid safe word" }, 401);
808
+ }
809
+ // Consume voucher
810
+ voucher.consumed = true;
811
+ // Issue device token
812
+ const { randomBytes: rng } = await import("node:crypto");
813
+ const deviceToken = `dt_${rng(16).toString("hex")}`;
814
+ const label = body.label || "Phone";
815
+ const device = {
816
+ deviceToken,
817
+ sessionId: authResult.session.id,
818
+ humanName: authResult.name,
819
+ label,
820
+ pairedAt: new Date().toISOString(),
821
+ lastSeen: new Date().toISOString(),
822
+ };
823
+ pairedDevices.set(deviceToken, device);
824
+ await savePairedDevices();
825
+ // Return session + device token
826
+ sessionKeys.set(authResult.session.id, authResult.sessionKey);
827
+ setEncryptionKey(authResult.sessionKey);
828
+ return c.json({
829
+ deviceToken,
830
+ sessionId: authResult.session.id,
831
+ instanceName: getInstanceName(),
832
+ });
833
+ });
834
+ // List paired devices (requires session)
835
+ app.get("/api/mobile/devices", async (c) => {
836
+ const sessionId = c.req.query("sessionId") || c.req.header("x-session-id");
837
+ if (!sessionId || !validateSession(sessionId)) {
838
+ return c.json({ error: "Unauthorized" }, 401);
839
+ }
840
+ return c.json({
841
+ devices: [...pairedDevices.values()].map((d) => ({
842
+ label: d.label,
843
+ pairedAt: d.pairedAt,
844
+ lastSeen: d.lastSeen,
845
+ })),
846
+ });
847
+ });
848
+ // Revoke a device (requires session)
849
+ app.delete("/api/mobile/devices/:label", async (c) => {
850
+ const sessionId = c.req.query("sessionId") || c.req.header("x-session-id");
851
+ if (!sessionId || !validateSession(sessionId)) {
852
+ return c.json({ error: "Unauthorized" }, 401);
853
+ }
854
+ const label = c.req.param("label");
855
+ for (const [token, d] of pairedDevices) {
856
+ if (d.label === label) {
857
+ pairedDevices.delete(token);
858
+ await savePairedDevices();
859
+ return c.json({ ok: true });
860
+ }
861
+ }
862
+ return c.json({ error: "Device not found" }, 404);
863
+ });
864
+ // --- Relay polling (receive messages from paired phones) ---
865
+ let relayPollInterval = null;
866
+ function startRelayPoll(instanceHash) {
867
+ if (relayPollInterval)
868
+ return;
869
+ const POLL_MS = 1_500; // 1.5 seconds
870
+ relayPollInterval = setInterval(async () => {
871
+ // Check for chat envelopes
872
+ try {
873
+ const res = await fetch(`https://runcore.sh/api/relay/envelope?recipient=${encodeURIComponent(instanceHash)}`, { signal: AbortSignal.timeout(8_000) });
874
+ if (res.ok) {
875
+ const data = await res.json();
876
+ if (data.envelopes && data.envelopes.length > 0) {
877
+ for (const env of data.envelopes) {
878
+ try {
879
+ const decoded = JSON.parse(Buffer.from(env.payload, "base64").toString("utf-8"));
880
+ if (decoded.type === "chat" && decoded.sessionId && decoded.message) {
881
+ log.info("Relay message received", { from: env.from, messageLen: decoded.message.length });
882
+ await handleRelayChat(decoded.sessionId, decoded.message, env.from, instanceHash);
883
+ }
884
+ else if (decoded.type === "sync" && decoded.sessionId) {
885
+ log.info("Relay sync request", { from: env.from });
886
+ await handleRelaySync(decoded.sessionId, env.from, instanceHash);
887
+ }
888
+ }
889
+ catch (err) {
890
+ log.debug("Failed to process relay envelope", { error: err instanceof Error ? err.message : String(err) });
891
+ }
892
+ }
893
+ }
894
+ }
895
+ }
896
+ catch {
897
+ // Network error — will retry on next poll
898
+ }
899
+ // Check for pending pair requests (independent of envelope check)
900
+ try {
901
+ const pairRes = await fetch(`https://runcore.sh/api/relay/pair?instance=${encodeURIComponent(instanceHash)}`, { signal: AbortSignal.timeout(8_000) });
902
+ if (pairRes.ok) {
903
+ const pairData = await pairRes.json();
904
+ if (pairData.requests && pairData.requests.length > 0) {
905
+ log.info("Relay pair requests received", { count: pairData.requests.length });
906
+ for (const req of pairData.requests) {
907
+ await handleRelayPair(req.token, req.password, req.label, instanceHash);
908
+ }
909
+ }
910
+ }
911
+ }
912
+ catch {
913
+ // Pair poll failed — will retry on next cycle
914
+ }
915
+ }, POLL_MS);
916
+ }
917
+ /**
918
+ * Handle a chat message received via relay from a paired phone.
919
+ * Processes through the same LLM pipeline as a local chat, sends response back through relay.
920
+ */
921
+ /**
922
+ * Handle a sync request from a paired phone — send chat history back through relay.
923
+ */
924
+ async function handleRelaySync(sid, senderHash, instanceHash) {
925
+ try {
926
+ // Find chat session — check this sessionId or grab the single existing one
927
+ let history = [];
928
+ const cs = chatSessions.get(sid) || (chatSessions.size > 0 ? chatSessions.values().next().value : null);
929
+ if (cs) {
930
+ // Send last 50 messages to keep payload reasonable
931
+ history = cs.history.slice(-50).map((m) => ({ role: m.role, content: m.content }));
932
+ }
933
+ await fetch("https://runcore.sh/api/relay/envelope", {
934
+ method: "POST",
935
+ headers: { "Content-Type": "application/json" },
936
+ body: JSON.stringify({
937
+ recipientHash: senderHash,
938
+ senderHash: instanceHash,
939
+ payload: Buffer.from(JSON.stringify({
940
+ type: "history",
941
+ messages: history,
942
+ timestamp: new Date().toISOString(),
943
+ })).toString("base64"),
944
+ }),
945
+ signal: AbortSignal.timeout(10_000),
946
+ });
947
+ log.info("Sent chat history to phone", { messageCount: history.length });
948
+ }
949
+ catch (err) {
950
+ log.warn("Relay sync failed", { error: err instanceof Error ? err.message : String(err) });
951
+ }
952
+ }
953
+ async function handleRelayChat(sid, message, senderHash, instanceHash) {
954
+ log.info("handleRelayChat start", { sid: sid.slice(0, 8), senderHash, messageLen: message.length });
955
+ // Get the user name — check session first, then paired device, then fallback
956
+ const session = validateSession(sid);
957
+ let userName = session?.name || "User";
958
+ // Look up paired device for real name
959
+ for (const d of pairedDevices.values()) {
960
+ if (d.sessionId === sid && d.humanName) {
961
+ userName = d.humanName;
962
+ break;
963
+ }
964
+ }
965
+ log.info("handleRelayChat session", { userName, sessionValid: !!session });
966
+ const cs = await getOrCreateChatSession(sid, userName);
967
+ log.info("handleRelayChat chatSession ready", { turnCount: cs.turnCount, historyLen: cs.history.length });
968
+ // Add user message to history (tagged with source device)
969
+ cs.history.push({ role: "user", content: message, source: "phone" });
970
+ try {
971
+ const provider = resolveProvider();
972
+ const model = resolveChatModel() ?? undefined;
973
+ log.info("handleRelayChat calling LLM", { provider, model });
974
+ const llmProvider = getProvider(provider);
975
+ const response = await llmProvider.completeChat(cs.history, model);
976
+ log.info("handleRelayChat LLM responded", { responseLen: response.length });
977
+ // Add response to history
978
+ cs.history.push({ role: "assistant", content: response });
979
+ cs.turnCount++;
980
+ // Send response back through relay to the phone
981
+ await fetch("https://runcore.sh/api/relay/envelope", {
982
+ method: "POST",
983
+ headers: { "Content-Type": "application/json" },
984
+ body: JSON.stringify({
985
+ recipientHash: senderHash,
986
+ senderHash: instanceHash,
987
+ payload: Buffer.from(JSON.stringify({
988
+ type: "chat_response",
989
+ message: response,
990
+ timestamp: new Date().toISOString(),
991
+ })).toString("base64"),
992
+ }),
993
+ signal: AbortSignal.timeout(10_000),
994
+ });
995
+ }
996
+ catch (err) {
997
+ log.warn("Relay chat failed", { error: err instanceof Error ? err.message : String(err) });
998
+ // Send error back to phone
999
+ await fetch("https://runcore.sh/api/relay/envelope", {
1000
+ method: "POST",
1001
+ headers: { "Content-Type": "application/json" },
1002
+ body: JSON.stringify({
1003
+ recipientHash: senderHash,
1004
+ senderHash: instanceHash,
1005
+ payload: Buffer.from(JSON.stringify({
1006
+ type: "status",
1007
+ message: "Error: " + (err instanceof Error ? err.message : String(err)),
1008
+ timestamp: new Date().toISOString(),
1009
+ })).toString("base64"),
1010
+ }),
1011
+ signal: AbortSignal.timeout(10_000),
1012
+ }).catch(() => { });
1013
+ }
1014
+ }
1015
+ /**
1016
+ * Handle a pairing request received via relay.
1017
+ * Validates the safe word, issues device token, sends result back through relay.
1018
+ */
1019
+ async function handleRelayPair(token, password, label, instanceHash) {
1020
+ let result;
1021
+ try {
1022
+ const voucher = deviceVouchers.get(token);
1023
+ if (!voucher || voucher.consumed || voucher.expiresAt < Date.now()) {
1024
+ result = { ok: false, error: "Invalid or expired voucher" };
1025
+ }
1026
+ else {
1027
+ const authResult = await authenticate(password);
1028
+ if ("error" in authResult) {
1029
+ result = { ok: false, error: "Invalid safe word" };
1030
+ }
1031
+ else {
1032
+ // Consume voucher
1033
+ voucher.consumed = true;
1034
+ // Issue device token
1035
+ const { randomBytes: rng } = await import("node:crypto");
1036
+ const deviceToken = `dt_${rng(16).toString("hex")}`;
1037
+ const device = {
1038
+ deviceToken,
1039
+ sessionId: authResult.session.id,
1040
+ humanName: authResult.name,
1041
+ label,
1042
+ pairedAt: new Date().toISOString(),
1043
+ lastSeen: new Date().toISOString(),
1044
+ };
1045
+ pairedDevices.set(deviceToken, device);
1046
+ await savePairedDevices();
1047
+ sessionKeys.set(authResult.session.id, authResult.sessionKey);
1048
+ setEncryptionKey(authResult.sessionKey);
1049
+ log.info("Device paired via relay", { label, token: token.slice(0, 8) + "..." });
1050
+ result = { ok: true, deviceToken, sessionId: authResult.session.id, instanceName: getInstanceName() };
1051
+ }
1052
+ }
1053
+ }
1054
+ catch (err) {
1055
+ result = { ok: false, error: err instanceof Error ? err.message : "Pairing failed" };
1056
+ }
1057
+ // Send result back through relay
1058
+ try {
1059
+ await fetch("https://runcore.sh/api/relay/pair", {
1060
+ method: "POST",
1061
+ headers: { "Content-Type": "application/json" },
1062
+ body: JSON.stringify({ type: "result", token, result }),
1063
+ signal: AbortSignal.timeout(10_000),
1064
+ });
1065
+ }
1066
+ catch {
1067
+ log.warn("Failed to send pair result to relay", { token: token.slice(0, 8) + "..." });
1068
+ }
1069
+ }
586
1070
  // --- Vault routes ---
587
1071
  // List vault keys (names + labels only, no values)
588
1072
  app.get("/api/vault", async (c) => {
@@ -1267,6 +1751,61 @@ app.put("/api/prompt", async (c) => {
1267
1751
  await writeBrainFile(PERSONALITY_PATH, prompt ?? "");
1268
1752
  return c.json({ ok: true });
1269
1753
  });
1754
+ // --- Model discovery ---
1755
+ app.get("/api/models", async (c) => {
1756
+ const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434";
1757
+ try {
1758
+ const res = await fetch(`${ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
1759
+ if (!res.ok)
1760
+ return c.json({ models: [], error: "Ollama not responding" });
1761
+ const data = await res.json();
1762
+ const models = (data.models ?? []).map((m) => ({
1763
+ name: m.name,
1764
+ size: m.size,
1765
+ modified: m.modified_at,
1766
+ }));
1767
+ return c.json({ models });
1768
+ }
1769
+ catch {
1770
+ return c.json({ models: [], error: "Ollama not reachable" });
1771
+ }
1772
+ });
1773
+ // --- Sensitivity trainer ---
1774
+ app.post("/api/sensitive/flag", async (c) => {
1775
+ const body = await c.req.json().catch(() => null);
1776
+ if (!body?.value || typeof body.value !== "string") {
1777
+ return c.json({ error: "value required" }, 400);
1778
+ }
1779
+ const category = (body.category || "FLAGGED").toUpperCase();
1780
+ const value = body.value.trim();
1781
+ if (value.length < 2) {
1782
+ return c.json({ error: "value too short" }, 400);
1783
+ }
1784
+ if (!activeSensitiveRegistry) {
1785
+ return c.json({ error: "registry not initialized" }, 503);
1786
+ }
1787
+ const isNew = await activeSensitiveRegistry.addTerm(value, category);
1788
+ // Append-only exposure log: who saw this, when, which model, which turn
1789
+ const exposure = {
1790
+ timestamp: new Date().toISOString(),
1791
+ category,
1792
+ valueLength: value.length,
1793
+ model: body.model || null,
1794
+ threadId: body.threadId || null,
1795
+ turnIndex: body.turnIndex ?? null,
1796
+ provider: body.provider || null,
1797
+ action: "flag",
1798
+ isNew,
1799
+ };
1800
+ try {
1801
+ const logPath = join(BRAIN_DIR, "memory", "sensitivity-flags.jsonl");
1802
+ await appendFile(logPath, JSON.stringify(exposure) + "\n", "utf-8");
1803
+ }
1804
+ catch {
1805
+ // best-effort logging
1806
+ }
1807
+ return c.json({ ok: true, isNew, category });
1808
+ });
1270
1809
  // --- Settings routes ---
1271
1810
  app.get("/api/settings", async (c) => {
1272
1811
  const settings = getSettings();
@@ -1281,6 +1820,29 @@ app.get("/api/settings", async (c) => {
1281
1820
  });
1282
1821
  app.put("/api/settings", async (c) => {
1283
1822
  const body = await c.req.json();
1823
+ // Handle human name change — updates identity file, session, and paired devices
1824
+ if (typeof body.humanName === "string" && body.humanName.trim()) {
1825
+ const newName = body.humanName.trim();
1826
+ const sid = c.req.query("sessionId") || c.req.header("x-session-id") || "";
1827
+ // Update identity file
1828
+ try {
1829
+ const { updateHumanName } = await import("./auth/identity.js");
1830
+ await updateHumanName(newName);
1831
+ }
1832
+ catch (err) {
1833
+ log.warn("Failed to update human name in identity file");
1834
+ }
1835
+ // Update current session
1836
+ const session = validateSession(sid);
1837
+ if (session)
1838
+ session.name = newName;
1839
+ // Update all paired devices
1840
+ for (const d of pairedDevices.values()) {
1841
+ d.humanName = newName;
1842
+ }
1843
+ await savePairedDevices();
1844
+ return c.json({ ok: true, humanName: newName });
1845
+ }
1284
1846
  const updated = await updateSettings(body);
1285
1847
  return c.json({
1286
1848
  ...updated,
@@ -1391,7 +1953,7 @@ app.get("/api/avatar/video/:hash", async (c) => {
1391
1953
  if (!/^[a-f0-9]+\.mp4$/.test(hash)) {
1392
1954
  return c.json({ error: "Invalid hash" }, 400);
1393
1955
  }
1394
- const filePath = join(PKG_ROOT, "public", "avatar", "cache", hash);
1956
+ const filePath = join(UI_DIR, "avatar", "cache", hash);
1395
1957
  try {
1396
1958
  const mp4 = await readFile(filePath);
1397
1959
  return new Response(mp4, {
@@ -1421,7 +1983,7 @@ app.post("/api/avatar/photo", async (c) => {
1421
1983
  return c.json({ error: "Photo body required" }, 400);
1422
1984
  const avatarConfig = getAvatarConfig();
1423
1985
  const photoPath = join(process.cwd(), avatarConfig.photoPath);
1424
- await mkdir(join(PKG_ROOT, "public", "avatar"), { recursive: true });
1986
+ await mkdir(join(UI_DIR, "avatar"), { recursive: true });
1425
1987
  await writeFile(photoPath, Buffer.from(body));
1426
1988
  const ok = await preparePhoto(photoPath);
1427
1989
  if (ok) {
@@ -1480,7 +2042,139 @@ app.get("/api/history", async (c) => {
1480
2042
  .map((m) => ({ role: m.role, content: m.content }));
1481
2043
  return c.json({ messages });
1482
2044
  });
2045
+ // Persist intro message so it appears in all tabs/devices
2046
+ app.post("/api/history/intro", async (c) => {
2047
+ const body = await c.req.json();
2048
+ const { sessionId, message } = body;
2049
+ if (!sessionId || !message)
2050
+ return c.json({ error: "sessionId and message required" }, 400);
2051
+ const session = validateSession(sessionId);
2052
+ if (!session)
2053
+ return c.json({ error: "Invalid session" }, 401);
2054
+ const cs = await getOrCreateChatSession(sessionId, session.name);
2055
+ // Only add if history is empty (first run)
2056
+ if (cs.history.length === 0) {
2057
+ cs.history.push({ role: "assistant", content: message });
2058
+ }
2059
+ return c.json({ ok: true });
2060
+ });
2061
+ // --- Thread routes ---
2062
+ app.get("/api/threads", async (c) => {
2063
+ const sessionId = c.req.query("sessionId");
2064
+ if (!sessionId)
2065
+ return c.json({ error: "sessionId required" }, 400);
2066
+ const session = validateSession(sessionId);
2067
+ if (!session)
2068
+ return c.json({ error: "Invalid session" }, 401);
2069
+ const threads = getThreadsForSession(sessionId);
2070
+ const list = [...threads.values()]
2071
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
2072
+ .map(t => ({ id: t.id, title: t.title, createdAt: t.createdAt, updatedAt: t.updatedAt }));
2073
+ return c.json({ threads: list });
2074
+ });
2075
+ app.post("/api/threads", async (c) => {
2076
+ const body = await c.req.json();
2077
+ const sessionId = body.sessionId;
2078
+ if (!sessionId)
2079
+ return c.json({ error: "sessionId required" }, 400);
2080
+ const session = validateSession(sessionId);
2081
+ if (!session)
2082
+ return c.json({ error: "Invalid session" }, 401);
2083
+ const threads = getThreadsForSession(sessionId);
2084
+ const now = new Date().toISOString();
2085
+ const thread = {
2086
+ id: generateThreadId(),
2087
+ title: body.title || "New thread",
2088
+ history: [],
2089
+ historySummary: "",
2090
+ createdAt: now,
2091
+ updatedAt: now,
2092
+ };
2093
+ threads.set(thread.id, thread);
2094
+ // Save current main history as the previous thread, then clear for fresh conversation
2095
+ const cs = chatSessions.get(sessionId);
2096
+ if (cs) {
2097
+ cs.history = [];
2098
+ cs.historySummary = "";
2099
+ cs.foldedBack = false;
2100
+ cs.turnCount = 0;
2101
+ }
2102
+ return c.json({ thread: { id: thread.id, title: thread.title, createdAt: thread.createdAt, updatedAt: thread.updatedAt } });
2103
+ });
2104
+ app.get("/api/threads/:id/history", async (c) => {
2105
+ const sessionId = c.req.query("sessionId");
2106
+ if (!sessionId)
2107
+ return c.json({ error: "sessionId required" }, 400);
2108
+ const session = validateSession(sessionId);
2109
+ if (!session)
2110
+ return c.json({ error: "Invalid session" }, 401);
2111
+ const threads = getThreadsForSession(sessionId);
2112
+ const thread = threads.get(c.req.param("id"));
2113
+ if (!thread)
2114
+ return c.json({ error: "Thread not found" }, 404);
2115
+ const messages = thread.history
2116
+ .filter((m) => m.role === "user" || m.role === "assistant")
2117
+ .map((m) => ({ role: m.role, content: m.content }));
2118
+ return c.json({ messages });
2119
+ });
2120
+ app.patch("/api/threads/:id", async (c) => {
2121
+ const body = await c.req.json();
2122
+ const sessionId = body.sessionId;
2123
+ if (!sessionId)
2124
+ return c.json({ error: "sessionId required" }, 400);
2125
+ const session = validateSession(sessionId);
2126
+ if (!session)
2127
+ return c.json({ error: "Invalid session" }, 401);
2128
+ const threads = getThreadsForSession(sessionId);
2129
+ const thread = threads.get(c.req.param("id"));
2130
+ if (!thread)
2131
+ return c.json({ error: "Thread not found" }, 404);
2132
+ if (body.title)
2133
+ thread.title = body.title;
2134
+ thread.updatedAt = new Date().toISOString();
2135
+ return c.json({ ok: true });
2136
+ });
2137
+ app.delete("/api/threads/:id", async (c) => {
2138
+ const sessionId = c.req.query("sessionId");
2139
+ if (!sessionId)
2140
+ return c.json({ error: "sessionId required" }, 400);
2141
+ const session = validateSession(sessionId);
2142
+ if (!session)
2143
+ return c.json({ error: "Invalid session" }, 401);
2144
+ const threads = getThreadsForSession(sessionId);
2145
+ threads.delete(c.req.param("id"));
2146
+ return c.json({ ok: true });
2147
+ });
1483
2148
  // Activity log: poll for background actions
2149
+ // SSE stream — real-time activity push
2150
+ app.get("/api/activity/stream", async (c) => {
2151
+ const sessionId = c.req.query("sessionId");
2152
+ if (!sessionId)
2153
+ return c.json({ error: "sessionId required" }, 400);
2154
+ const session = validateSession(sessionId);
2155
+ if (!session)
2156
+ return c.json({ error: "Invalid or expired session" }, 401);
2157
+ const { onActivity } = await import("./activity/log.js");
2158
+ return streamSSE(c, async (stream) => {
2159
+ // Send heartbeat immediately
2160
+ await stream.writeSSE({ data: JSON.stringify({ type: "heartbeat" }) });
2161
+ const unsub = onActivity((entry) => {
2162
+ stream.writeSSE({
2163
+ data: JSON.stringify({ type: "action", action: entry }),
2164
+ }).catch(() => { });
2165
+ });
2166
+ // Heartbeat every 30s to keep connection alive
2167
+ const heartbeat = setInterval(() => {
2168
+ stream.writeSSE({ data: JSON.stringify({ type: "heartbeat" }) }).catch(() => { });
2169
+ }, 30_000);
2170
+ stream.onAbort(() => {
2171
+ unsub();
2172
+ clearInterval(heartbeat);
2173
+ });
2174
+ // Keep stream open
2175
+ await new Promise(() => { });
2176
+ });
2177
+ });
1484
2178
  app.get("/api/activity", async (c) => {
1485
2179
  const sessionId = c.req.query("sessionId");
1486
2180
  if (!sessionId)
@@ -1547,13 +2241,24 @@ app.post("/api/branch", async (c) => {
1547
2241
  throw err;
1548
2242
  }
1549
2243
  const reqSignal = c.req.raw.signal;
2244
+ // Apply membrane to branch messages before they reach the LLM
2245
+ const branchMembrane = getActiveMembrane();
2246
+ const redactedBranchMessages = messages.map((msg) => {
2247
+ if (!branchMembrane)
2248
+ return msg;
2249
+ const copy = { ...msg };
2250
+ if (typeof copy.content === "string") {
2251
+ copy.content = branchMembrane.apply(copy.content);
2252
+ }
2253
+ return copy;
2254
+ });
1550
2255
  return streamSSE(c, async (stream) => {
1551
2256
  // Send branch trace metadata so the UI can track lineage
1552
2257
  await stream.writeSSE({
1553
2258
  data: JSON.stringify({
1554
2259
  meta: {
1555
2260
  provider: activeProvider,
1556
- model: activeChatModel ?? (activeProvider === "ollama" ? "llama3.1:8b" : "claude-sonnet-4"),
2261
+ model: activeChatModel ?? (activeProvider === "ollama" ? "llama3.2:3b" : "claude-sonnet-4"),
1557
2262
  traceId: branchTraceId,
1558
2263
  backref: primaryBackref,
1559
2264
  },
@@ -1576,7 +2281,7 @@ app.post("/api/branch", async (c) => {
1576
2281
  stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
1577
2282
  };
1578
2283
  stream_fn({
1579
- messages,
2284
+ messages: redactedBranchMessages,
1580
2285
  model: activeChatModel,
1581
2286
  signal: reqSignal,
1582
2287
  onToken: (token) => {
@@ -1697,6 +2402,12 @@ app.post("/api/agents/locks/prune", async (_c) => {
1697
2402
  const pruned = await pruneAllStaleLocks();
1698
2403
  return _c.json({ pruned });
1699
2404
  });
2405
+ // --- Self-reported issues (autonomous agent findings) ---
2406
+ app.get("/api/agents/issues", async (c) => {
2407
+ const { listIssues } = await import("./agents/issues.js");
2408
+ const issues = await listIssues();
2409
+ return c.json({ issues });
2410
+ });
1700
2411
  // --- Agent runtime routes ---
1701
2412
  app.get("/api/runtime/status", async (c) => {
1702
2413
  const rt = getRuntime();
@@ -2630,82 +3341,216 @@ app.post("/api/board/review/trigger", async (c) => {
2630
3341
  // ---------------------------------------------------------------------------
2631
3342
  // List all registered skills (metadata only)
2632
3343
  app.get("/api/skills", async (c) => {
2633
- const registry = getSkillRegistry();
2634
- if (!registry)
2635
- return c.json({ error: "Skills registry not initialized" }, 503);
2636
- const stateFilter = c.req.query("state");
2637
- const slotFilter = c.req.query("slot");
2638
- const skills = registry.list({
2639
- state: stateFilter,
2640
- slot: slotFilter,
2641
- });
2642
- return c.json(skills.map((s) => ({
2643
- name: s.meta.name,
2644
- description: s.meta.description,
2645
- slot: s.meta.slot,
2646
- state: s.state,
2647
- userInvocable: s.meta.userInvocable,
2648
- source: s.meta.source.type,
2649
- version: s.meta.version,
2650
- registeredAt: s.registeredAt,
3344
+ const typeFilter = c.req.query("type");
3345
+ const skills = await _skillRegistry.list();
3346
+ const filtered = typeFilter ? skills.filter((s) => s.type === typeFilter) : skills;
3347
+ return c.json(filtered.map((s) => ({
3348
+ id: s.id,
3349
+ name: s.name,
3350
+ type: s.type,
3351
+ description: s.description,
3352
+ triggers: s.triggers,
3353
+ loads: s.loads,
2651
3354
  })));
2652
3355
  });
2653
- // Get a single skill (metadata + body)
3356
+ // Get a single skill (metadata + full content)
2654
3357
  app.get("/api/skills/:name", async (c) => {
2655
- const registry = getSkillRegistry();
2656
- if (!registry)
2657
- return c.json({ error: "Skills registry not initialized" }, 503);
2658
3358
  const name = c.req.param("name");
2659
- const skill = registry.get(name);
3359
+ const skill = await _skillRegistry.get(name);
2660
3360
  if (!skill)
2661
3361
  return c.json({ error: "Skill not found" }, 404);
2662
- // Load body on demand
2663
- await registry.loadBody(name);
3362
+ const content = await _skillRegistry.getContent(name);
2664
3363
  return c.json({
2665
- name: skill.meta.name,
2666
- description: skill.meta.description,
2667
- slot: skill.meta.slot,
2668
- state: skill.state,
2669
- userInvocable: skill.meta.userInvocable,
2670
- disableModelInvocation: skill.meta.disableModelInvocation,
2671
- source: skill.meta.source,
2672
- version: skill.meta.version,
2673
- body: skill.body,
2674
- referencedFiles: skill.referencedFiles,
2675
- registeredAt: skill.registeredAt,
2676
- refreshedAt: skill.refreshedAt,
3364
+ ...skill,
3365
+ content,
2677
3366
  });
2678
3367
  });
2679
- // Resolve intent → matching skills
3368
+ // Resolve trigger → matching skill
2680
3369
  app.post("/api/skills/resolve", async (c) => {
2681
- const registry = getSkillRegistry();
2682
- if (!registry)
2683
- return c.json({ error: "Skills registry not initialized" }, 503);
2684
- const { intent, includeReference, limit } = await c.req.json();
2685
- if (!intent)
2686
- return c.json({ error: "intent is required" }, 400);
2687
- const results = registry.resolve(intent, { includeReference, limit });
2688
- return c.json(results.map((r) => ({
2689
- name: r.skill.meta.name,
2690
- description: r.skill.meta.description,
2691
- slot: r.skill.meta.slot,
2692
- reason: r.reason,
2693
- confidence: r.confidence,
2694
- priority: r.priority,
2695
- })));
3370
+ const { trigger } = await c.req.json();
3371
+ if (!trigger)
3372
+ return c.json({ error: "trigger is required" }, 400);
3373
+ const skill = await _skillRegistry.findByTrigger(trigger);
3374
+ if (!skill)
3375
+ return c.json({ error: "No matching skill" }, 404);
3376
+ return c.json({
3377
+ id: skill.id,
3378
+ name: skill.name,
3379
+ type: skill.type,
3380
+ description: skill.description,
3381
+ triggers: skill.triggers,
3382
+ });
2696
3383
  });
2697
- // Validate a skill file
2698
- app.post("/api/skills/:name/validate", async (c) => {
2699
- const registry = getSkillRegistry();
2700
- if (!registry)
2701
- return c.json({ error: "Skills registry not initialized" }, 503);
2702
- const { content } = await c.req.json();
2703
- if (!content)
2704
- return c.json({ error: "content is required" }, 400);
2705
- const name = c.req.param("name");
2706
- const result = registry.validate(name, content);
3384
+ // --- Plugin status routes ---
3385
+ import { getPluginStatusSummary } from "./plugins/status.js";
3386
+ import { initPlugins, shutdownPlugins } from "./plugins/index.js";
3387
+ // --- File management routes ---
3388
+ import { fileRegistry, computeChecksum } from "./files/registry.js";
3389
+ import { validateUpload } from "./files/validate.js";
3390
+ import { slugify } from "./files/validate.js";
3391
+ app.get("/api/plugins", (c) => {
3392
+ return c.json(getPluginStatusSummary());
3393
+ });
3394
+ // --- File management routes ---
3395
+ // Upload file — persist to brain/files/data/, register in JSONL
3396
+ app.post("/api/files/upload", async (c) => {
3397
+ try {
3398
+ const formData = await c.req.formData();
3399
+ const file = formData.get("file");
3400
+ if (!file)
3401
+ return c.json({ error: "No file provided" }, 400);
3402
+ const source = formData.get("source") || "user-upload";
3403
+ const tagsRaw = formData.get("tags");
3404
+ const tags = tagsRaw ? tagsRaw.split(",").map(t => t.trim()).filter(Boolean) : [];
3405
+ // Folder path from directory upload — preserved as virtual folder tag
3406
+ const folder = formData.get("folder");
3407
+ if (folder)
3408
+ tags.push("folder:" + folder);
3409
+ const buffer = Buffer.from(await file.arrayBuffer());
3410
+ const maxUploadBytes = 50 * 1024 * 1024; // 50 MB
3411
+ // Validate: extension allowlist, magic bytes, size, content scan
3412
+ const validation = await validateUpload(buffer, file.name, file.type, maxUploadBytes);
3413
+ if (!validation.valid) {
3414
+ return c.json({ error: validation.rejected }, 400);
3415
+ }
3416
+ // Generate storage path: brain/files/data/YYYY-MM-DD/slug_id.ext
3417
+ const dateDir = new Date().toISOString().slice(0, 10);
3418
+ const slug = slugify(file.name.replace(/\.[^.]+$/, ""));
3419
+ const checksum = computeChecksum(buffer);
3420
+ // Check for duplicate by checksum
3421
+ const existing = await fileRegistry.list({});
3422
+ const dup = existing.find(r => r.checksum === checksum && r.status === "active");
3423
+ if (dup) {
3424
+ return c.json({ file: dup, duplicate: true });
3425
+ }
3426
+ const storageDir = join(BRAIN_DIR, "files", "data", dateDir);
3427
+ await mkdir(storageDir, { recursive: true });
3428
+ const storedName = `${slug}_${Date.now()}${validation.detectedExt}`;
3429
+ const storagePath = join("files", "data", dateDir, storedName);
3430
+ const fullPath = join(BRAIN_DIR, storagePath);
3431
+ await writeFile(fullPath, buffer);
3432
+ // Extract text preview for searchability
3433
+ let textPreview;
3434
+ const textExts = new Set([".txt", ".md", ".csv", ".json", ".yaml", ".yml", ".xml", ".log"]);
3435
+ if (textExts.has(validation.detectedExt)) {
3436
+ textPreview = buffer.toString("utf-8").slice(0, 500);
3437
+ }
3438
+ else if (validation.detectedExt === ".pdf") {
3439
+ try {
3440
+ const { extractPdfText } = await import("./files/extract.js");
3441
+ textPreview = (await extractPdfText(buffer)).slice(0, 500);
3442
+ }
3443
+ catch { /* PDF extraction optional */ }
3444
+ }
3445
+ // Register in file registry
3446
+ const record = await fileRegistry.register({
3447
+ filename: validation.sanitizedName,
3448
+ storagePath,
3449
+ mimeType: validation.detectedMime || file.type,
3450
+ sizeBytes: buffer.length,
3451
+ checksum,
3452
+ tags,
3453
+ source,
3454
+ status: "active",
3455
+ });
3456
+ // Trigger volume replication (on-write event)
3457
+ volumeManager.handleEvent({ type: "write", fileId: record.id, volume: "primary" }).catch((err) => log.warn("Volume on-write event failed", { error: String(err) }));
3458
+ return c.json({ file: record, duplicate: false });
3459
+ }
3460
+ catch (err) {
3461
+ const msg = err instanceof Error ? err.message : String(err);
3462
+ log.warn("File upload failed", { error: msg });
3463
+ return c.json({ error: `Upload failed: ${msg}` }, 500);
3464
+ }
3465
+ });
3466
+ // Download / serve a stored file
3467
+ app.get("/api/files/:id/download", async (c) => {
3468
+ const record = await fileRegistry.get(c.req.param("id"));
3469
+ if (!record)
3470
+ return c.json({ error: "File not found" }, 404);
3471
+ const fullPath = join(BRAIN_DIR, record.storagePath);
3472
+ try {
3473
+ const data = await readFile(fullPath);
3474
+ return c.newResponse(data, 200, {
3475
+ "Content-Type": record.mimeType,
3476
+ "Content-Disposition": `inline; filename="${record.filename}"`,
3477
+ "Content-Length": String(data.length),
3478
+ });
3479
+ }
3480
+ catch {
3481
+ return c.json({ error: "File data not found on disk" }, 404);
3482
+ }
3483
+ });
3484
+ // List virtual folders (must be before :id route)
3485
+ app.get("/api/files/folders", async (c) => {
3486
+ const folders = await fileRegistry.getFolders();
3487
+ return c.json({ folders });
3488
+ });
3489
+ app.get("/api/files", async (c) => {
3490
+ const status = c.req.query("status");
3491
+ const source = c.req.query("source");
3492
+ const q = c.req.query("q");
3493
+ if (q) {
3494
+ const results = await fileRegistry.search(q);
3495
+ return c.json({ files: results, total: results.length });
3496
+ }
3497
+ const results = await fileRegistry.list({ status, source });
3498
+ return c.json({ files: results, total: results.length });
3499
+ });
3500
+ app.get("/api/files/:id", async (c) => {
3501
+ const record = await fileRegistry.get(c.req.param("id"));
3502
+ if (!record)
3503
+ return c.json({ error: "File not found", code: "NOT_FOUND", status: 404 }, 404);
3504
+ return c.json(record);
3505
+ });
3506
+ app.post("/api/files/:id/archive", async (c) => {
3507
+ const result = await fileRegistry.archive(c.req.param("id"));
3508
+ if (!result)
3509
+ return c.json({ error: "File not found", code: "NOT_FOUND", status: 404 }, 404);
2707
3510
  return c.json(result);
2708
3511
  });
3512
+ // Update file tags / move to virtual folder
3513
+ app.put("/api/files/:id", async (c) => {
3514
+ const body = await c.req.json();
3515
+ const { tags, source, folder } = body;
3516
+ const id = c.req.param("id");
3517
+ const record = await fileRegistry.get(id);
3518
+ if (!record)
3519
+ return c.json({ error: "File not found" }, 404);
3520
+ // Handle virtual folder: stored as tag "folder:Name"
3521
+ let updatedTags = tags ?? [...(record.tags ?? [])];
3522
+ if (folder !== undefined) {
3523
+ // Remove existing folder tags, add new one
3524
+ updatedTags = updatedTags.filter(t => !t.startsWith("folder:"));
3525
+ if (folder)
3526
+ updatedTags.push("folder:" + folder);
3527
+ }
3528
+ const result = await fileRegistry.update(id, { tags: updatedTags, ...(source ? { source } : {}) });
3529
+ return c.json(result);
3530
+ });
3531
+ // --- Volume management routes ---
3532
+ app.get("/api/volumes", async (c) => {
3533
+ const states = volumeManager.getStates();
3534
+ const configs = volumeManager.getConfigs();
3535
+ return c.json({
3536
+ volumes: configs.map((cfg) => {
3537
+ const state = states.find((s) => s.name === cfg.name);
3538
+ return { ...cfg, ...state };
3539
+ }),
3540
+ pendingReplications: volumeManager.getPendingCount(),
3541
+ });
3542
+ });
3543
+ app.post("/api/volumes/probe", async (c) => {
3544
+ const states = await volumeManager.probeAll();
3545
+ return c.json({ volumes: states });
3546
+ });
3547
+ app.post("/api/volumes/event", async (c) => {
3548
+ const event = await c.req.json();
3549
+ if (!event?.type)
3550
+ return c.json({ error: "Missing event type" }, 400);
3551
+ await volumeManager.handleEvent(event);
3552
+ return c.json({ ok: true });
3553
+ });
2709
3554
  // --- Module discovery routes ---
2710
3555
  app.get("/api/modules", (c) => {
2711
3556
  const registry = getModuleRegistry();
@@ -3198,6 +4043,67 @@ app.get("/api/metrics/names", async (c) => {
3198
4043
  const names = await metricsStore.metricNames();
3199
4044
  return c.json({ names });
3200
4045
  });
4046
+ // Time-series bucketed data for a named metric.
4047
+ app.get("/api/metrics/series", async (c) => {
4048
+ const name = c.req.query("name");
4049
+ if (!name)
4050
+ return c.json({ error: "name parameter required" }, 400);
4051
+ const now = Date.now();
4052
+ const since = c.req.query("since") ?? new Date(now - 60 * 60 * 1000).toISOString();
4053
+ const until = c.req.query("until") ?? new Date(now).toISOString();
4054
+ const bucketCount = parseInt(c.req.query("buckets") ?? "60", 10);
4055
+ const points = await metricsStore.query({ name, since, until });
4056
+ const sinceMs = new Date(since).getTime();
4057
+ const untilMs = new Date(until).getTime();
4058
+ const intervalMs = (untilMs - sinceMs) / bucketCount;
4059
+ const buckets = [];
4060
+ for (let i = 0; i < bucketCount; i++) {
4061
+ const bucketStart = sinceMs + i * intervalMs;
4062
+ const bucketEnd = bucketStart + intervalMs;
4063
+ const bucketStartISO = new Date(bucketStart).toISOString();
4064
+ const bucketEndISO = new Date(bucketEnd).toISOString();
4065
+ const inBucket = points.filter((p) => {
4066
+ const t = p.timestamp;
4067
+ return t >= bucketStartISO && (i === bucketCount - 1 ? t <= bucketEndISO : t < bucketEndISO);
4068
+ });
4069
+ if (inBucket.length === 0) {
4070
+ buckets.push({ time: bucketStartISO, count: 0, avg: 0, min: 0, max: 0 });
4071
+ }
4072
+ else {
4073
+ const values = inBucket.map((p) => p.value);
4074
+ const sum = values.reduce((a, v) => a + v, 0);
4075
+ buckets.push({
4076
+ time: bucketStartISO,
4077
+ count: inBucket.length,
4078
+ avg: sum / inBucket.length,
4079
+ min: Math.min(...values),
4080
+ max: Math.max(...values),
4081
+ });
4082
+ }
4083
+ }
4084
+ return c.json({ name, since, until, buckets, interval: intervalMs });
4085
+ });
4086
+ // Export raw metric points as JSON or CSV.
4087
+ app.get("/api/metrics/export", async (c) => {
4088
+ const now = Date.now();
4089
+ const since = c.req.query("since") ?? new Date(now - 24 * 60 * 60 * 1000).toISOString();
4090
+ const until = c.req.query("until") ?? new Date(now).toISOString();
4091
+ const name = c.req.query("name") || undefined;
4092
+ const format = c.req.query("format") ?? "json";
4093
+ const points = await metricsStore.query({ name, since, until });
4094
+ if (format === "csv") {
4095
+ const header = "timestamp,name,value,unit,tags";
4096
+ const rows = points.map((p) => {
4097
+ const tags = p.tags ? JSON.stringify(p.tags).replace(/"/g, '""') : "";
4098
+ return `${p.timestamp},${p.name},${p.value},${p.unit ?? ""},\"${tags}\"`;
4099
+ });
4100
+ const csv = [header, ...rows].join("\n");
4101
+ c.header("Content-Type", "text/csv");
4102
+ c.header("Content-Disposition", 'attachment; filename="metrics-export.csv"');
4103
+ return c.body(csv);
4104
+ }
4105
+ return c.json({ points, count: points.length });
4106
+ });
3201
4107
  // Evaluate alert thresholds and return any fired alerts.
3202
4108
  app.post("/api/metrics/alerts/evaluate", async (c) => {
3203
4109
  const alerts = await evaluateAlerts(metricsStore);
@@ -3233,7 +4139,7 @@ app.get("/api/cache", (c) => {
3233
4139
  });
3234
4140
  // --- Help page routes (no auth — knowledge exchange for other AIs/humans) ---
3235
4141
  app.get("/help", async (c) => {
3236
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "help.html"));
4142
+ const html = await serveHtmlTemplate(join(UI_DIR, "help.html"));
3237
4143
  return c.html(html);
3238
4144
  });
3239
4145
  app.get("/api/help/context", async (c) => {
@@ -3276,33 +4182,33 @@ app.get("/api/help/context", async (c) => {
3276
4182
  // --- Ops dashboard routes (posture-gated: board level) ---
3277
4183
  // Board-level pages — only assembled when user has shown intent for full visibility
3278
4184
  app.get("/observatory", requireSurface("pages"), async (c) => {
3279
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "observatory.html"));
4185
+ const html = await serveHtmlTemplate(join(UI_DIR, "observatory.html"));
3280
4186
  return c.html(html);
3281
4187
  });
3282
4188
  app.get("/ops", requireSurface("pages"), async (c) => {
3283
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "ops.html"));
4189
+ const html = await serveHtmlTemplate(join(UI_DIR, "ops.html"));
3284
4190
  return c.html(html);
3285
4191
  });
3286
4192
  app.get("/board", requireSurface("pages"), async (c) => {
3287
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "board.html"));
4193
+ const html = await serveHtmlTemplate(join(UI_DIR, "board.html"));
3288
4194
  return c.html(html);
3289
4195
  });
3290
4196
  app.get("/library", requireSurface("pages"), async (c) => {
3291
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "library.html"));
4197
+ const html = await serveHtmlTemplate(join(UI_DIR, "library.html"));
3292
4198
  return c.html(html);
3293
4199
  });
3294
4200
  app.get("/browser", requireSurface("pages"), async (c) => {
3295
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "browser.html"));
4201
+ const html = await serveHtmlTemplate(join(UI_DIR, "browser.html"));
3296
4202
  return c.html(html);
3297
4203
  });
3298
4204
  // Registry is always available — it's the entry point
3299
4205
  app.get("/registry", async (c) => {
3300
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "registry.html"));
4206
+ const html = await serveHtmlTemplate(join(UI_DIR, "registry.html"));
3301
4207
  return c.html(html);
3302
4208
  });
3303
4209
  // Serve roadmap.html (strategic roadmap & rearview)
3304
4210
  app.get("/roadmap", async (c) => {
3305
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "roadmap.html"));
4211
+ const html = await serveHtmlTemplate(join(UI_DIR, "roadmap.html"));
3306
4212
  return c.html(html);
3307
4213
  });
3308
4214
  // Roadmap API — parse brain/operations/roadmap.yaml and return as JSON
@@ -3317,12 +4223,17 @@ app.get("/api/roadmap", async (c) => {
3317
4223
  }
3318
4224
  });
3319
4225
  // Roadmap rearview API — recent git commits grouped by hour
4226
+ // Git is an optional signal source — returns empty when unavailable.
3320
4227
  app.get("/api/roadmap/recent", async (c) => {
3321
4228
  const hours = parseInt(c.req.query("hours") || "24", 10);
3322
4229
  if (isNaN(hours) || hours < 1 || hours > 168) {
3323
4230
  return c.json({ error: "hours must be between 1 and 168" }, 400);
3324
4231
  }
3325
4232
  try {
4233
+ const { gitAvailable } = await import("./utils/git.js");
4234
+ if (!gitAvailable()) {
4235
+ return c.json({ commits: [], groups: [], hours, total: 0 });
4236
+ }
3326
4237
  const { execSync } = await import("child_process");
3327
4238
  const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
3328
4239
  const raw = execSync(`git log --after="${since}" --format="%H||%an||%ai||%s" --no-merges`, { cwd: process.cwd(), encoding: "utf-8", timeout: 10000 }).trim();
@@ -3364,7 +4275,7 @@ app.get("/api/roadmap/recent", async (c) => {
3364
4275
  // Serve personal.html (placeholder — instances populate this)
3365
4276
  app.get("/personal", async (c) => {
3366
4277
  try {
3367
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "personal.html"));
4278
+ const html = await serveHtmlTemplate(join(UI_DIR, "personal.html"));
3368
4279
  return c.html(html);
3369
4280
  }
3370
4281
  catch {
@@ -3374,7 +4285,7 @@ app.get("/personal", async (c) => {
3374
4285
  // Serve life.html (placeholder — instances populate this)
3375
4286
  app.get("/life", async (c) => {
3376
4287
  try {
3377
- const html = await serveHtmlTemplate(join(PKG_ROOT, "public", "life.html"));
4288
+ const html = await serveHtmlTemplate(join(UI_DIR, "life.html"));
3378
4289
  return c.html(html);
3379
4290
  }
3380
4291
  catch {
@@ -3832,6 +4743,26 @@ app.post("/api/nerve/accept-update", async (c) => {
3832
4743
  return c.json({ error: err.message }, 500);
3833
4744
  }
3834
4745
  });
4746
+ // Poll for new chat messages (phone → PC live feed)
4747
+ app.get("/api/chat/poll", async (c) => {
4748
+ const sessionId = c.req.query("sessionId") || c.req.header("x-session-id");
4749
+ if (!sessionId)
4750
+ return c.json({ error: "sessionId required" }, 400);
4751
+ const since = parseInt(c.req.query("since") || "0", 10);
4752
+ const cs = chatSessions.get(sessionId) || (chatSessions.size > 0 ? chatSessions.values().next().value : null);
4753
+ if (!cs)
4754
+ return c.json({ messages: [], total: 0 });
4755
+ const total = cs.history.length;
4756
+ if (since >= total)
4757
+ return c.json({ messages: [], total });
4758
+ const newMsgs = cs.history.slice(since).map((m, i) => ({
4759
+ index: since + i,
4760
+ role: m.role,
4761
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
4762
+ source: m.source || "pc",
4763
+ }));
4764
+ return c.json({ messages: newMsgs, total });
4765
+ });
3835
4766
  // Chat: streamed response (or learn command)
3836
4767
  app.post("/api/chat", async (c) => {
3837
4768
  const body = await c.req.json();
@@ -4164,33 +5095,30 @@ app.post("/api/chat", async (c) => {
4164
5095
  }
4165
5096
  }
4166
5097
  }
4167
- // Inject resolved skill content (reference skills auto-load by intent match)
5098
+ // Inject resolved skill content (reference skills auto-load by trigger match)
4168
5099
  try {
4169
- const registry = getSkillRegistry();
4170
- if (registry) {
4171
- const resolved = registry.resolve(chatMessage, { includeReference: true });
4172
- for (const res of resolved) {
4173
- const body = await registry.loadBody(res.skill.meta.name);
4174
- if (body) {
4175
- // Load files referenced by the skill (brain/ paths)
4176
- const refPaths = body.match(/(?:brain|docs)\/[\w\-\/]+\.\w+/g) ?? [];
4177
- const refContents = [];
4178
- for (const refPath of refPaths) {
4179
- try {
4180
- const content = await readBrainFile(join(process.cwd(), refPath));
4181
- refContents.push(`--- ${refPath} ---\n${content}\n--- end ${refPath} ---`);
4182
- }
4183
- catch { /* skip missing files */ }
5100
+ const matched = await _skillRegistry.findByTrigger(chatMessage);
5101
+ if (matched) {
5102
+ const body = await _skillRegistry.getContent(matched.id);
5103
+ if (body) {
5104
+ // Load files referenced by the skill (brain/ paths)
5105
+ const refPaths = body.match(/(?:brain|docs)\/[\w\-\/]+\.\w+/g) ?? [];
5106
+ const refContents = [];
5107
+ for (const refPath of refPaths) {
5108
+ try {
5109
+ const content = await readBrainFile(join(process.cwd(), refPath));
5110
+ refContents.push(`--- ${refPath} ---\n${content}\n--- end ${refPath} ---`);
4184
5111
  }
4185
- const skillSection = [
4186
- `--- Skill: ${res.skill.meta.name} (${res.reason}, confidence ${res.confidence.toFixed(2)}) ---`,
4187
- body,
4188
- ...refContents,
4189
- `--- end skill ---`,
4190
- ].join("\n");
4191
- ctx.messages.splice(1, 0, { role: "system", content: skillSection });
4192
- logActivity({ source: "system", summary: `Loaded skill: ${res.skill.meta.name} (${res.reason})` });
5112
+ catch { /* skip missing files */ }
4193
5113
  }
5114
+ const skillSection = [
5115
+ `--- Skill: ${matched.name} (${matched.type}) ---`,
5116
+ body,
5117
+ ...refContents,
5118
+ `--- end skill ---`,
5119
+ ].join("\n");
5120
+ ctx.messages.splice(1, 0, { role: "system", content: skillSection });
5121
+ logActivity({ source: "system", summary: `Loaded skill: ${matched.name} (${matched.type})` });
4194
5122
  }
4195
5123
  }
4196
5124
  }
@@ -4545,7 +5473,55 @@ app.post("/api/chat", async (c) => {
4545
5473
  const reqSignal = c.req.raw.signal;
4546
5474
  return streamSSE(c, async (stream) => {
4547
5475
  // Send metadata first so UI can show which model is responding
4548
- await stream.writeSSE({ data: JSON.stringify({ meta: { provider: activeProvider, model: activeChatModel ?? (activeProvider === "ollama" ? "llama3.1:8b" : "claude-sonnet-4") } }) });
5476
+ await stream.writeSSE({ data: JSON.stringify({ meta: { provider: activeProvider, model: activeChatModel ?? (activeProvider === "ollama" ? "llama3.2:3b" : "claude-sonnet-4") } }) });
5477
+ // --- Apply membrane: redact messages BEFORE they reach the LLM ---
5478
+ const membrane = getActiveMembrane();
5479
+ const redactedMessages = ctx.messages.map((msg) => {
5480
+ if (!membrane)
5481
+ return msg;
5482
+ const copy = { ...msg };
5483
+ if (typeof copy.content === "string") {
5484
+ copy.content = membrane.apply(copy.content);
5485
+ }
5486
+ else if (Array.isArray(copy.content)) {
5487
+ copy.content = copy.content.map((block) => {
5488
+ if (block.type === "text" && typeof block.text === "string") {
5489
+ return { ...block, text: membrane.apply(block.text) };
5490
+ }
5491
+ return block;
5492
+ });
5493
+ }
5494
+ return copy;
5495
+ });
5496
+ // --- Membrane view: emit what the LLM will see (redacted) ---
5497
+ try {
5498
+ const membraneView = [];
5499
+ for (const msg of redactedMessages) {
5500
+ const raw = typeof msg.content === "string" ? msg.content
5501
+ : Array.isArray(msg.content) ? msg.content.map((b) => b.text || b.type || "").join(" ") : "";
5502
+ if (!raw)
5503
+ continue;
5504
+ // Count redactions by counting placeholders (already redacted)
5505
+ const placeholders = raw.match(/<<[A-Z_]+_\d+>>|\[REDACTED:[^\]]+\]/g);
5506
+ membraneView.push({
5507
+ role: msg.role,
5508
+ preview: raw.slice(0, 300) + (raw.length > 300 ? "..." : ""),
5509
+ redactions: placeholders ? placeholders.length : 0,
5510
+ });
5511
+ }
5512
+ const totalRedactions = membraneView.reduce((sum, m) => sum + m.redactions, 0);
5513
+ await stream.writeSSE({ data: JSON.stringify({
5514
+ membrane: {
5515
+ messageCount: redactedMessages.length,
5516
+ totalRedactions,
5517
+ messages: membraneView,
5518
+ sealValues: membrane ? membrane.knownValues.map(v => v.value) : [],
5519
+ },
5520
+ }) });
5521
+ }
5522
+ catch (memErr) {
5523
+ log.warn("membrane view error", { error: String(memErr) });
5524
+ }
4549
5525
  let fullResponse = "";
4550
5526
  const savePartial = () => {
4551
5527
  if (fullResponse) {
@@ -4571,16 +5547,17 @@ app.post("/api/chat", async (c) => {
4571
5547
  const flushBuf2 = () => {
4572
5548
  if (!tokenBuf2)
4573
5549
  return;
5550
+ // Debug: trace rehydration
4574
5551
  const rehydrated = rehydrateResponse(tokenBuf2);
5552
+ fullResponse += rehydrated;
4575
5553
  tokenBuf2 = "";
4576
5554
  stream.writeSSE({ data: JSON.stringify({ token: rehydrated }) }).catch(() => { });
4577
5555
  };
4578
5556
  stream_fn({
4579
- messages: ctx.messages,
5557
+ messages: redactedMessages,
4580
5558
  model: activeChatModel,
4581
5559
  signal: reqSignal,
4582
5560
  onToken: (token) => {
4583
- fullResponse += token;
4584
5561
  tokenBuf2 += token;
4585
5562
  // Hold if buffer ends with partial placeholder
4586
5563
  const lastOpen = tokenBuf2.lastIndexOf("<<");
@@ -4588,7 +5565,7 @@ app.post("/api/chat", async (c) => {
4588
5565
  return;
4589
5566
  flushBuf2();
4590
5567
  },
4591
- onDone: () => {
5568
+ onDone: async () => {
4592
5569
  flushBuf2(); // flush remainder
4593
5570
  reqSignal?.removeEventListener("abort", onAbort);
4594
5571
  // Process action blocks BEFORE sending done — ensures SSE events reach client before stream closes.
@@ -4640,20 +5617,23 @@ app.post("/api/chat", async (c) => {
4640
5617
  }
4641
5618
  spawnCount++;
4642
5619
  agentLog.info(` Spawning: ${label}${(isVague || isWishList) ? " (grounded)" : ""}`);
4643
- stream.writeSSE({ data: JSON.stringify({ agentSpawned: { label } }) }).catch(() => { });
4644
- submitTask({
4645
- label,
4646
- prompt: finalPrompt,
4647
- origin: "ai",
4648
- sessionId,
4649
- boardTaskId: req.taskId,
4650
- }).then((task) => {
5620
+ // Await task submission so we can send the real task ID to the client
5621
+ try {
5622
+ const task = await submitTask({
5623
+ label,
5624
+ prompt: finalPrompt,
5625
+ origin: "ai",
5626
+ sessionId,
5627
+ boardTaskId: req.taskId,
5628
+ });
5629
+ stream.writeSSE({ data: JSON.stringify({ agentSpawned: { label, taskId: task.id } }) }).catch(() => { });
4651
5630
  logActivity({ source: "agent", summary: `AI-triggered agent: ${task.label}`, detail: `Task ${task.id}, PID ${task.pid}`, actionLabel: "PROMPTED", reason: "user chat triggered agent" });
4652
- }).catch((err) => {
5631
+ }
5632
+ catch (err) {
4653
5633
  agentLog.error(`Spawn failed for "${label}": ${err.message}`);
4654
5634
  logActivity({ source: "agent", summary: `AI agent spawn failed: ${err.message}`, actionLabel: "PROMPTED", reason: "user chat triggered agent spawn failed" });
4655
5635
  stream.writeSSE({ data: JSON.stringify({ agentError: { label, error: err.message } }) }).catch(() => { });
4656
- });
5636
+ }
4657
5637
  }
4658
5638
  else {
4659
5639
  agentLog.warn(`Parsed JSON but missing "prompt" field: ${jsonMatch[0].slice(0, 200)}`);
@@ -4819,12 +5799,16 @@ app.post("/api/chat", async (c) => {
4819
5799
  }
4820
5800
  else {
4821
5801
  let errorMsg = err instanceof LLMError ? err.userMessage : (err.message || "Stream error");
5802
+ // Include raw detail for health drilldown — client shows this, not the friendly version
5803
+ const rawDetail = err instanceof LLMError
5804
+ ? `${err.provider} ${err.statusCode || ""}: ${err.message}`.trim()
5805
+ : (err.message || "Stream error");
4822
5806
  if (isPrivateMode() && /ECONNREFUSED|fetch failed|network|socket/i.test(errorMsg)) {
4823
5807
  const health = await checkOllamaHealth();
4824
5808
  if (!health.ok)
4825
5809
  errorMsg += " — Check that Ollama is running. " + health.message;
4826
5810
  }
4827
- stream.writeSSE({ data: JSON.stringify({ error: errorMsg }) }).catch(() => { });
5811
+ stream.writeSSE({ data: JSON.stringify({ error: errorMsg, errorDetail: rawDetail }) }).catch(() => { });
4828
5812
  }
4829
5813
  resolve(); // Still resolve so stream closes
4830
5814
  },
@@ -4954,9 +5938,9 @@ async function start(opts) {
4954
5938
  // Install fetch guard before any routes or outbound calls
4955
5939
  installFetchGuard();
4956
5940
  // Initialize PrivacyMembrane for reversible redaction
4957
- const sensitiveRegistry = new SensitiveRegistry();
4958
- await sensitiveRegistry.load(BRAIN_DIR);
4959
- const membrane = new PrivacyMembrane(sensitiveRegistry);
5941
+ activeSensitiveRegistry = new SensitiveRegistry();
5942
+ await activeSensitiveRegistry.load(BRAIN_DIR);
5943
+ const membrane = new PrivacyMembrane(activeSensitiveRegistry);
4960
5944
  setActiveMembrane(membrane);
4961
5945
  // Run independent initialization in parallel: LLM cache, auth, and sidecars
4962
5946
  const [, pairingCode, sidecarResults] = await Promise.all([
@@ -4979,6 +5963,33 @@ async function start(opts) {
4979
5963
  ]);
4980
5964
  const code = pairingCode;
4981
5965
  const [searchAvailable, ttsAvailable, sttAvailable] = sidecarResults;
5966
+ // Load paired mobile devices
5967
+ await loadPairedDevices();
5968
+ // Register with runcore.sh relay (fire-and-forget, non-blocking)
5969
+ (async () => {
5970
+ try {
5971
+ const { createHash } = await import("node:crypto");
5972
+ const instanceHash = createHash("sha256")
5973
+ .update(getInstanceName() + BRAIN_DIR)
5974
+ .digest("hex")
5975
+ .slice(0, 16);
5976
+ await fetch("https://runcore.sh/api/relay/register", {
5977
+ method: "POST",
5978
+ headers: { "Content-Type": "application/json" },
5979
+ body: JSON.stringify({
5980
+ instanceHash,
5981
+ displayName: getInstanceName(),
5982
+ }),
5983
+ signal: AbortSignal.timeout(10_000),
5984
+ });
5985
+ log.info("Registered with runcore.sh relay", { instanceHash });
5986
+ // Start polling relay for incoming phone messages
5987
+ startRelayPoll(instanceHash);
5988
+ }
5989
+ catch {
5990
+ log.debug("Relay registration skipped (offline or unreachable)");
5991
+ }
5992
+ })();
4982
5993
  // Pre-warm notification queue from disk (encryption key is set by now)
4983
5994
  const notifCount = await initNotifications();
4984
5995
  if (notifCount > 0)
@@ -5030,12 +6041,13 @@ async function start(opts) {
5030
6041
  // of skills/module init, so running them concurrently improves startup time (DASH-60).
5031
6042
  // Note: initAgents() only creates directories — recovery runs after the runtime
5032
6043
  // is ready so the monitor can skip runtime-managed tasks (DASH-82 fix).
5033
- const [skillRegistry, moduleRegistry, , runtime] = await Promise.all([
5034
- createSkillRegistry({ skillsDir: SKILLS_DIR, brainDir: BRAIN_DIR }),
6044
+ const [, moduleRegistry, , runtime] = await Promise.all([
6045
+ _skillRegistry.refresh(),
5035
6046
  Promise.resolve(createModuleRegistry(BRAIN_DIR)),
5036
6047
  initAgents(),
5037
6048
  createRuntime(),
5038
6049
  ]);
6050
+ const skillRegistry = _skillRegistry;
5039
6051
  // Recover tasks from previous session AFTER runtime is initialized.
5040
6052
  // The monitor checks the runtime registry to skip tasks that RuntimeManager
5041
6053
  // already handles, preventing the double-recovery race (DASH-82).
@@ -5110,6 +6122,10 @@ async function start(opts) {
5110
6122
  }
5111
6123
  if (isTasksAvailable())
5112
6124
  startTasksTimer();
6125
+ // Initialize plugin registry (authenticate + start all registered plugins)
6126
+ await initPlugins().catch((err) => {
6127
+ log.warn("Plugin init failed", { error: err instanceof Error ? err.message : String(err) });
6128
+ });
5113
6129
  // Wire batch continuation: when all agents finish, commit + decide what's next (direct LLM, no HTTP)
5114
6130
  setOnBatchComplete(async (sessionId, results) => {
5115
6131
  try {
@@ -5279,9 +6295,9 @@ async function start(opts) {
5279
6295
  log.info("Google: add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in vault to enable");
5280
6296
  }
5281
6297
  // Show skills registry status
5282
- if (skillRegistry) {
5283
- const counts = skillRegistry.countByState();
5284
- log.info(`Skills: ${skillRegistry.size} registered (${counts.registered} active)`);
6298
+ {
6299
+ const allSkills = await skillRegistry.list();
6300
+ log.info(`Skills: ${allSkills.length} registered`);
5285
6301
  }
5286
6302
  // Show module registry status
5287
6303
  if (moduleRegistry) {
@@ -5294,9 +6310,21 @@ async function start(opts) {
5294
6310
  const taskCount = await queueProvider.getStore().count();
5295
6311
  log.info(`Board: ${board.name} (local, ${taskCount} tasks)`);
5296
6312
  }
6313
+ // Sync UI from CDN (non-blocking — falls back to bundled if offline)
6314
+ syncUi().then(({ source, revision }) => {
6315
+ if (source === "cdn") {
6316
+ UI_DIR = getUiPublicDir(PKG_ROOT);
6317
+ log.info(`UI synced from CDN: revision ${revision}`);
6318
+ }
6319
+ else {
6320
+ log.info(`UI source: ${source}${revision ? ` (revision ${revision})` : ""}`);
6321
+ }
6322
+ }).catch(() => { });
5297
6323
  // Generate startup token for zero-friction local auth
5298
6324
  const { randomBytes: rng } = await import("node:crypto");
5299
6325
  startupToken = rng(32).toString("hex");
6326
+ // Warm up local model in background (non-blocking)
6327
+ import("./llm/ollama.js").then(({ warmupOllama }) => warmupOllama()).catch(() => { });
5300
6328
  if (code) {
5301
6329
  log.info(`First launch detected. Pairing code: ${code}`);
5302
6330
  }
@@ -5319,6 +6347,7 @@ async function start(opts) {
5319
6347
  const emailBrain = new Brain({
5320
6348
  systemPrompt: [
5321
6349
  `You are ${getInstanceName()}, a personal AI agent paired with ${name}. You run locally on ${name}'s machine.`,
6350
+ `Today is ${new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })}.`,
5322
6351
  `You are responding to an email that was sent to you. This is a working email channel — you received this email and your reply will be sent back automatically via Gmail.`,
5323
6352
  ``,
5324
6353
  `Your capabilities — USE THEM when the email requests action:`,
@@ -5385,12 +6414,15 @@ async function start(opts) {
5385
6414
  });
5386
6415
  log.info(`${getInstanceName()} email handler registered — emails with '${getInstanceName()}' in subject will be auto-replied`);
5387
6416
  await new Promise((resolve) => {
5388
- const server = serve({ fetch: app.fetch, port: PORT }, () => {
6417
+ function onListening(server) {
5389
6418
  const addr = server.address();
5390
6419
  if (typeof addr === "object" && addr) {
5391
6420
  actualPort = addr.port;
5392
6421
  }
5393
6422
  log.info(`Listening on http://localhost:${actualPort}`);
6423
+ acquireLock(actualPort, getInstanceName());
6424
+ console.log(`\n ${getInstanceName()} is running:\n`);
6425
+ console.log(` → http://localhost:${actualPort}\n`);
5394
6426
  // Show LAN IP for phone access
5395
6427
  try {
5396
6428
  import("node:os").then(({ networkInterfaces }) => {
@@ -5398,7 +6430,7 @@ async function start(opts) {
5398
6430
  for (const name of Object.keys(nets)) {
5399
6431
  for (const net of nets[name] ?? []) {
5400
6432
  if (net.family === "IPv4" && !net.internal) {
5401
- log.info(`Nerve (phone): http://${net.address}:${actualPort}/nerve`);
6433
+ console.log(` http://${net.address}:${actualPort} (LAN)\n`);
5402
6434
  }
5403
6435
  }
5404
6436
  }
@@ -5417,7 +6449,29 @@ async function start(opts) {
5417
6449
  }
5418
6450
  }
5419
6451
  resolve();
5420
- });
6452
+ }
6453
+ try {
6454
+ const server = serve({ fetch: app.fetch, port: PORT }, () => onListening(server));
6455
+ server.on("error", (err) => {
6456
+ if (err.code === "EADDRINUSE" && PORT !== 0) {
6457
+ log.warn(`Port ${PORT} in use, falling back to random port`);
6458
+ const fallback = serve({ fetch: app.fetch, port: 0 }, () => onListening(fallback));
6459
+ }
6460
+ else {
6461
+ throw err;
6462
+ }
6463
+ });
6464
+ }
6465
+ catch (err) {
6466
+ // If serve() throws synchronously (unlikely but safe)
6467
+ if (PORT !== 0) {
6468
+ log.warn(`Port ${PORT} failed, falling back to random port`);
6469
+ const fallback = serve({ fetch: app.fetch, port: 0 }, () => onListening(fallback));
6470
+ }
6471
+ else {
6472
+ throw err;
6473
+ }
6474
+ }
5421
6475
  });
5422
6476
  }
5423
6477
  /** Returns the port the server is actually listening on (resolves port 0). */
@@ -5554,8 +6608,10 @@ async function gracefulShutdown(signal) {
5554
6608
  stopSidecar();
5555
6609
  await closeBrowser();
5556
6610
  shutdownAgents();
6611
+ await shutdownPlugins().catch(() => { });
5557
6612
  await shutdownLLMCache();
5558
6613
  await shutdownTracing();
6614
+ releaseLock();
5559
6615
  process.exit(0);
5560
6616
  }
5561
6617
  process.on("SIGINT", () => { gracefulShutdown("SIGINT"); });