@circuitwall/jarela 2.3.0 → 2.5.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 (290) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +14 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
  19. package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
  20. package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
  21. package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
  22. package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
  23. package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
  24. package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
  25. package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
  26. package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
  27. package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
  28. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
  29. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
  31. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
  32. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
  33. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
  35. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
  37. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
  39. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
  40. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
  41. package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
  43. package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
  45. package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
  47. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
  48. package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
  50. package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
  51. package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
  52. package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
  53. package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
  54. package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
  55. package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
  56. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
  58. package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
  59. package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
  60. package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
  61. package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
  62. package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
  63. package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
  64. package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
  65. package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
  66. package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
  67. package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
  68. package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
  69. package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
  70. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
  71. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
  72. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
  73. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
  74. package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
  75. package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
  76. package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
  77. package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
  78. package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
  79. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
  80. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
  81. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
  82. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
  83. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
  84. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
  85. package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
  86. package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
  87. package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
  88. package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
  89. package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
  90. package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
  91. package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
  92. package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
  93. package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
  94. package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
  95. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
  96. package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
  97. package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
  98. package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
  99. package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
  100. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
  101. package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
  102. package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
  103. package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
  104. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +585 -58
  105. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
  106. package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
  107. package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
  108. package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
  109. package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
  110. package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
  111. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
  112. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
  113. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
  114. package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
  115. package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
  116. package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
  117. package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
  118. package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
  119. package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
  120. package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
  121. package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
  122. package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
  123. package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
  124. package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
  125. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
  126. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
  127. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +125 -26
  128. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -2
  129. package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
  130. package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
  131. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  132. package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
  133. package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
  134. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
  135. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
  136. package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
  137. package/.next/standalone/.next/server/app/page.js +27323 -27034
  138. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  139. package/.next/standalone/.next/server/app/setup/page.js +8 -0
  140. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  141. package/.next/standalone/.next/server/app-paths-manifest.json +5 -5
  142. package/.next/standalone/.next/server/chunks/1388.js +106 -36
  143. package/.next/standalone/.next/server/chunks/2068.js +47 -10
  144. package/.next/standalone/.next/server/chunks/210.js +6 -6
  145. package/.next/standalone/.next/server/chunks/2447.js +10 -5
  146. package/.next/standalone/.next/server/chunks/2653.js +80 -1
  147. package/.next/standalone/.next/server/chunks/3042.js +10 -8
  148. package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
  149. package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
  150. package/.next/standalone/.next/server/chunks/3524.js +150 -67
  151. package/.next/standalone/.next/server/chunks/3566.js +1 -1
  152. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  153. package/.next/standalone/.next/server/chunks/4596.js +28 -4
  154. package/.next/standalone/.next/server/chunks/46.js +91 -31
  155. package/.next/standalone/.next/server/chunks/466.js +53 -7
  156. package/.next/standalone/.next/server/chunks/4955.js +30 -3
  157. package/.next/standalone/.next/server/chunks/5585.js +28 -4
  158. package/.next/standalone/.next/server/chunks/5908.js +63 -42
  159. package/.next/standalone/.next/server/chunks/6142.js +10 -2
  160. package/.next/standalone/.next/server/chunks/8461.js +10 -8
  161. package/.next/standalone/.next/server/chunks/9032.js +5 -5
  162. package/.next/standalone/.next/server/chunks/9164.js +487 -177
  163. package/.next/standalone/.next/server/chunks/9304.js +51 -12
  164. package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +84 -24
  165. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  166. package/.next/standalone/.next/server/middleware.js +89 -21
  167. package/.next/standalone/.next/server/pages/404.html +14 -2
  168. package/.next/standalone/.next/server/pages/500.html +1 -1
  169. package/.next/standalone/.next/server/preview-props.json +3 -3
  170. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  171. package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
  172. package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
  173. package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-2ef8d75d40eb4311.js} +10643 -10295
  174. package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
  175. package/.next/standalone/docs/adoption/changesets.json +2805 -0
  176. package/.next/standalone/package.json +1 -1
  177. package/CHANGELOG.md +52 -0
  178. package/api/client.models.test.ts +10 -0
  179. package/api/client.startup.test.ts +129 -0
  180. package/api/client.ts +51 -9
  181. package/api/thread-metadata.test.ts +72 -0
  182. package/api/types.ts +6 -3
  183. package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
  184. package/app/api/v1/providers/[provider]/models/route.ts +259 -40
  185. package/app/api/v1/threads/[thread_id]/route.ts +26 -23
  186. package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
  187. package/app/globals.css +119 -29
  188. package/app/layout.tsx +12 -0
  189. package/components/agents/AgentsPanel.tsx +5 -13
  190. package/components/chat/ChatView.tsx +9 -18
  191. package/components/chat/InputBar.tsx +1 -1
  192. package/components/chat/MessageBubble.tsx +13 -1
  193. package/components/chat/MessageList.test.tsx +45 -4
  194. package/components/chat/MessageList.tsx +123 -89
  195. package/components/chat/ToolList.tsx +88 -84
  196. package/components/chat/useThreadActivity.test.tsx +2 -2
  197. package/components/chat/useThreadActivity.ts +2 -2
  198. package/components/chat/useThreadData.ts +1 -30
  199. package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
  200. package/components/chat/useThreadMetadataPolling.ts +50 -0
  201. package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
  202. package/components/chat/useUserProfileAndAgent.ts +13 -2
  203. package/components/dashboard/DashboardPanel.tsx +1 -1
  204. package/components/dashboard/Series.tsx +34 -21
  205. package/components/dashboard/SummaryCards.tsx +1 -1
  206. package/components/integrations/NetworkSection.tsx +2 -2
  207. package/components/layout/AppShell.tsx +22 -7
  208. package/components/layout/MenuPanel.tsx +14 -24
  209. package/components/profile/ProfilePanel.tsx +2 -2
  210. package/components/settings/AppearancePanel.tsx +70 -3
  211. package/components/settings/SettingsPanel.tsx +2 -2
  212. package/components/tools/ToolsPanel.tsx +2 -7
  213. package/components/ui/AppearancePreferences.test.tsx +68 -0
  214. package/components/ui/BootScreen.test.tsx +44 -0
  215. package/components/ui/BootScreen.tsx +24 -11
  216. package/components/ui/NotificationStatus.test.tsx +2 -2
  217. package/components/ui/NotificationStatus.tsx +5 -5
  218. package/components/ui/SubTabBar.tsx +2 -3
  219. package/contexts/ThemeContext.tsx +84 -1
  220. package/hooks/useAgentSession.test.ts +12 -1
  221. package/hooks/useAgentSession.ts +6 -4
  222. package/hooks/useUrlSync.test.ts +13 -0
  223. package/hooks/useUrlSync.ts +0 -4
  224. package/lib/agents/citation-checker.test.ts +40 -2
  225. package/lib/agents/citation-checker.ts +56 -11
  226. package/lib/agents/context-boundary.ts +5 -6
  227. package/lib/agents/instruction-edits.test.ts +120 -0
  228. package/lib/agents/instruction-edits.ts +65 -42
  229. package/lib/agents/prepare/request.ts +0 -2
  230. package/lib/agents/prepare/system-prompt.test.ts +10 -0
  231. package/lib/agents/prepare/system-prompt.ts +8 -2
  232. package/lib/agents/proposals.test.ts +76 -0
  233. package/lib/agents/proposals.ts +13 -5
  234. package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
  235. package/lib/agents/run-thread.ts +24 -9
  236. package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
  237. package/lib/agents/warm-summary-background.ts +22 -2
  238. package/lib/agents/warm-topics.test.ts +155 -1
  239. package/lib/agents/warm-topics.ts +27 -7
  240. package/lib/bridges/dispatcher.ts +1 -0
  241. package/lib/bridges/message-role.test.ts +81 -0
  242. package/lib/bridges/message-role.ts +23 -10
  243. package/lib/bridges/types.ts +6 -0
  244. package/lib/bridges/whatsapp-link.test.ts +32 -0
  245. package/lib/bridges/whatsapp-link.ts +21 -0
  246. package/lib/bridges/whatsapp.inbound.test.ts +239 -0
  247. package/lib/bridges/whatsapp.sender.test.ts +80 -1
  248. package/lib/bridges/whatsapp.ts +76 -38
  249. package/lib/db/migrations.test.ts +61 -0
  250. package/lib/db/migrations.ts +13 -0
  251. package/lib/embeddings/performance.test.ts +114 -0
  252. package/lib/pricing/snapshot.test.ts +70 -0
  253. package/lib/pricing/snapshot.ts +10 -2
  254. package/lib/proxy/dispatcher.test.ts +119 -1
  255. package/lib/proxy/dispatcher.ts +22 -9
  256. package/lib/stores/agent-configs.ts +10 -5
  257. package/lib/stores/bridges.test.ts +81 -0
  258. package/lib/stores/bridges.ts +9 -8
  259. package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
  260. package/lib/stores/dashboard-metrics.ts +30 -4
  261. package/lib/stores/message-embedding-jobs.test.ts +27 -0
  262. package/lib/stores/message-embedding-jobs.ts +5 -3
  263. package/lib/stores/message-usage.test.ts +21 -1
  264. package/lib/stores/pricing-cache.test.ts +31 -0
  265. package/lib/stores/pricing.ts +3 -0
  266. package/lib/stores/proxy-config.test.ts +120 -0
  267. package/lib/stores/proxy-config.ts +22 -4
  268. package/lib/stores/threads.ts +12 -8
  269. package/lib/tools/communications/calendar.test.ts +43 -2
  270. package/lib/tools/communications/calendar.ts +2 -0
  271. package/lib/tools/communications/gmail.test.ts +51 -1
  272. package/lib/tools/communications/gmail.ts +44 -2
  273. package/lib/tools/communications/outlook-calendar.ts +2 -0
  274. package/lib/tools/communications/outlook.test.ts +33 -2
  275. package/lib/tools/communications/outlook.ts +2 -0
  276. package/lib/tools/filesystem/edit-match.test.ts +133 -0
  277. package/lib/tools/filesystem/edit-match.ts +7 -2
  278. package/lib/tools/packages/default-packages.test.ts +21 -0
  279. package/lib/tools/packages/optional-package.test.ts +72 -0
  280. package/lib/tools/packages/optional-package.ts +33 -4
  281. package/lib/tools/support/result-refs.test.ts +131 -0
  282. package/lib/tools/support/result-refs.ts +41 -9
  283. package/lib/ui/navigate.test.ts +13 -0
  284. package/lib/ui/navigate.ts +10 -2
  285. package/lib/utils/source-link.test.ts +51 -0
  286. package/lib/utils/source-link.ts +45 -0
  287. package/package.json +1 -1
  288. package/.next/standalone/.next/static/css/312542a5725ba0d5.css +0 -5
  289. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
  290. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
@@ -10,6 +10,7 @@ process.env.JARELA_MAX_THREAD_MESSAGES = "4";
10
10
 
11
11
  const streamWithConfigMock = vi.fn();
12
12
  let providerSummary = "AUTO-COMPACT-RECAP";
13
+ let providerCalls = 0;
13
14
 
14
15
  vi.mock("@/lib/agents/llm", () => ({
15
16
  streamWithConfig: (...args: unknown[]) => streamWithConfigMock(...args),
@@ -21,7 +22,7 @@ vi.mock("@/lib/scheduler", () => ({
21
22
 
22
23
  vi.mock("@/lib/providers", () => ({
23
24
  getProvider: () => ({
24
- chat: async () => ({
25
+ chat: async () => (providerCalls++, {
25
26
  stream: (async function* () { yield providerSummary; })(),
26
27
  }),
27
28
  }),
@@ -164,7 +165,6 @@ describe("prepareThreadRun auto context boundary", () => {
164
165
  thread_id: thread.thread_id,
165
166
  message: "Persist this before the assistant draft.",
166
167
  _user_message_persisted: true,
167
- _message_count_before_user: persisted.messageCountBefore,
168
168
  _idle_ms_before_user: persisted.idleMsBefore,
169
169
  context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
170
170
  });
@@ -271,7 +271,6 @@ describe("prepareThreadRun auto context boundary", () => {
271
271
  .run(new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), message.msg_id);
272
272
  });
273
273
  const orderedSeeded = getMessages(thread.thread_id);
274
- const messageCountBefore = getThread(thread.thread_id)!.message_count;
275
274
  const activePrompt = addMessage(thread.thread_id, "user", "topic 3 question");
276
275
  providerSummary = [
277
276
  "AUTO-COMPACT-RECAP",
@@ -292,7 +291,6 @@ describe("prepareThreadRun auto context boundary", () => {
292
291
  thread_id: thread.thread_id,
293
292
  message: "topic 3 question",
294
293
  _user_message_persisted: true,
295
- _message_count_before_user: messageCountBefore,
296
294
  _idle_ms_before_user: null,
297
295
  context_profile: {
298
296
  include_hot: true,
@@ -374,4 +372,126 @@ describe("prepareThreadRun auto context boundary", () => {
374
372
  "continue after retention guard",
375
373
  ]);
376
374
  });
377
- });
375
+
376
+ async function seedBridgeHeavyAgent(agentId: string, foregroundTurns: number) {
377
+ upsertModelConfig("default", "openai", "gpt-4o-mini", { api_key: "sk-test" }, true);
378
+ upsertAgentConfig({
379
+ id: agentId,
380
+ name: agentId,
381
+ identity: "helper",
382
+ instructions: "Be helpful.",
383
+ tools: [],
384
+ model_config_name: null,
385
+ });
386
+ const thread = createThread(agentId);
387
+ const metadata = { bridge_conversation: { key: "wa:chat-1" } };
388
+ for (let i = 0; i < 20; i++) {
389
+ addMessage(thread.thread_id, i % 2 === 0 ? "user" : "assistant", `bridge turn ${i}`, undefined, "bridge", metadata);
390
+ }
391
+ for (let i = 0; i < foregroundTurns; i++) {
392
+ addMessage(thread.thread_id, "user", `chat question ${i}`);
393
+ addMessage(thread.thread_id, "assistant", `chat answer ${i}`);
394
+ }
395
+ return { thread, metadata };
396
+ }
397
+
398
+ it("does not start size compaction for bridge rows that compaction can never prune", async () => {
399
+ const { thread, metadata } = await seedBridgeHeavyAgent("agent-bridge-only", 0);
400
+ const onCompacting = vi.fn();
401
+ await prepareThreadRun({
402
+ thread_id: thread.thread_id,
403
+ message: "new bridge message",
404
+ user_category: "bridge",
405
+ message_metadata: metadata,
406
+ history_bridge_key: "wa:chat-1",
407
+ context_profile: { history_scope: "bridge", include_hot: true, include_warm: false, include_facts: false, include_recall: false },
408
+ _onCompacting: onCompacting,
409
+ });
410
+ expect(onCompacting).not.toHaveBeenCalled();
411
+ expect(getMessages(thread.thread_id).filter((m) => m.category === "bridge")).toHaveLength(21);
412
+ });
413
+
414
+ it("does not report compacting on a short chat just because the bridge history is long", async () => {
415
+ const { thread } = await seedBridgeHeavyAgent("agent-bridge-mixed", 1);
416
+ const onCompacting = vi.fn();
417
+ await prepareThreadRun({
418
+ thread_id: thread.thread_id,
419
+ message: "quick chat question",
420
+ context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
421
+ _onCompacting: onCompacting,
422
+ });
423
+ expect(onCompacting).not.toHaveBeenCalled();
424
+ });
425
+
426
+ it("still size-compacts once the chat itself outgrows the cap alongside bridge rows", async () => {
427
+ const { thread } = await seedBridgeHeavyAgent("agent-bridge-busy-chat", 6);
428
+ const onCompacting = vi.fn();
429
+ await prepareThreadRun({
430
+ thread_id: thread.thread_id,
431
+ message: "long chat question",
432
+ context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
433
+ _onCompacting: onCompacting,
434
+ });
435
+ expect(onCompacting).toHaveBeenCalledTimes(1);
436
+ });
437
+
438
+ it("does not re-run hot-turn compaction on the very next turn after a commit", async () => {
439
+ upsertModelConfig("default", "openai", "gpt-4o-mini", { api_key: "sk-test" }, true);
440
+ upsertAgentConfig({
441
+ id: "agent-hot-turn-rolling",
442
+ name: "Rolling Hot Turn Agent",
443
+ identity: "helper",
444
+ instructions: "Be helpful.",
445
+ tools: [],
446
+ model_config_name: null,
447
+ history_window_hours: 0,
448
+ hot_turn_limit: 2,
449
+ });
450
+ const thread = createThread("agent-hot-turn-rolling");
451
+ for (let i = 0; i < 4; i += 1) {
452
+ addMessage(thread.thread_id, "user", `topic ${i} question`);
453
+ addMessage(thread.thread_id, "assistant", `topic ${i} answer`);
454
+ }
455
+ const seeded = getMessages(thread.thread_id);
456
+ seeded.forEach((message, index) => {
457
+ getDb().prepare("UPDATE messages SET created_at=? WHERE msg_id=?")
458
+ .run(new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), message.msg_id);
459
+ });
460
+ const rows = getMessages(thread.thread_id);
461
+ providerSummary = [
462
+ "AUTO-COMPACT-RECAP",
463
+ "```jarela-topics",
464
+ JSON.stringify([{
465
+ title: "later topic",
466
+ start_seq: rows[4].seq,
467
+ end_seq: rows[7].seq,
468
+ start_at: rows[4].created_at,
469
+ end_at: rows[7].created_at,
470
+ recap: "Topics 2 and 3.",
471
+ facts: [],
472
+ }]),
473
+ "```",
474
+ ].join("\n");
475
+ const turn = (message: string) => prepareThreadRun({
476
+ thread_id: thread.thread_id,
477
+ message,
478
+ context_profile: { include_hot: true, include_warm: false, include_facts: false, include_recall: false },
479
+ });
480
+
481
+ await turn("topic 4 question");
482
+ await waitForCondition(() => getThread(thread.thread_id)?.hot_since_seq === rows[4].seq);
483
+ addMessage(thread.thread_id, "assistant", "topic 4 answer");
484
+ await new Promise((resolve) => setTimeout(resolve, 50));
485
+
486
+ providerCalls = 0;
487
+ await turn("topic 5 question");
488
+ await new Promise((resolve) => setTimeout(resolve, 100));
489
+ expect(providerCalls).toBe(0);
490
+ addMessage(thread.thread_id, "assistant", "topic 5 answer");
491
+
492
+ // The cooldown is half the turn limit; the turn after it re-evaluates.
493
+ await turn("topic 6 question");
494
+ await waitForCondition(() => providerCalls > 0);
495
+ expect(getThread(thread.thread_id)?.hot_since_seq).toBe(rows[4].seq);
496
+ });
497
+ });
@@ -13,7 +13,7 @@ import { autoCompactionKeepLast, compactAgentThread } from "@/lib/agents/thread-
13
13
  import { kickBoundaryCompaction, kickWarmSummaryRefresh, pendingCompactionBoundarySeq } from "@/lib/agents/warm-summary-background";
14
14
  import { moveThreadContextBoundary } from "@/lib/agents/context-boundary";
15
15
  import { getForegroundTabPresence } from "@/lib/api/foreground-presence";
16
- import { addMessage, capToolEventPayload, countThreadMessagesFromSeq, getMessagesPage, isValidThreadContextCursor, pruneThreadMessages, getRecentlyUsedToolNames, getRecentMessagesWindow, getThread, getThreadMessageBySeq, mergeMessageMetadata, touchThread, updateMessageContent, type MessageTranscriptStatus, type PersistedToolEvent } from "@/lib/stores/threads";
16
+ import { addMessage, capToolEventPayload, countForegroundThreadMessages, getMessagesPage, isValidThreadContextCursor, pruneThreadMessages, getRecentlyUsedToolNames, getRecentMessagesWindow, getThread, getThreadMessageBySeq, mergeMessageMetadata, touchThread, updateMessageContent, type MessageTranscriptStatus, type PersistedToolEvent } from "@/lib/stores/threads";
17
17
  import { transcriptText } from "@/lib/agents/conversation-summary";
18
18
  import { getMaskRunContext } from "@/lib/redaction/context";
19
19
  import { recordToolUsage } from "@/lib/stores/tool-stats";
@@ -170,10 +170,19 @@ function hotTurnBoundary(
170
170
  return boundary?.seq ?? null;
171
171
  }
172
172
 
173
+ // The topic-aware boundary keeps the whole active topic hot, so user turns
174
+ // since the pin usually still exceed the limit right after a commit. Without a
175
+ // cooldown every following turn re-runs the topic LLM pass only to land on the
176
+ // same pin. Skip the next half-limit turns (2 rows per turn) before re-checking.
177
+ export function hotTurnCooldownUntil(messageCount: number, hotTurnLimit: number | null | undefined): number {
178
+ const turnLimit = hotTurnLimit ?? DEFAULT_HOT_TURN_LIMIT;
179
+ const skipTurns = Math.max(1, Math.ceil(Math.max(1, turnLimit) / 2));
180
+ return messageCount + skipTurns * 2 + 1;
181
+ }
182
+
173
183
  async function maybeAutoCompactOversizedThread(
174
184
  agentId: string,
175
185
  threadId: string,
176
- messageCount: number,
177
186
  onCompacting?: () => void,
178
187
  preserveNewestMessages = 0,
179
188
  ): Promise<void> {
@@ -181,12 +190,17 @@ async function maybeAutoCompactOversizedThread(
181
190
  const keepLast = autoCompactionKeepLast(cap);
182
191
  const retainedMessages = keepLast + preserveNewestMessages;
183
192
  const triggerAt = cap + (cap - keepLast);
184
- if (!Number.isFinite(cap) || cap <= 0 || messageCount < triggerAt) return;
193
+ if (!Number.isFinite(cap) || cap <= 0) return;
194
+ // Only foreground rows are ever pruned; counting bridge/watcher/scheduled
195
+ // rows would re-trigger an LLM compaction on every turn of a busy bridge
196
+ // agent without ever bringing the count back under the cap.
197
+ const messageCount = countForegroundThreadMessages(threadId);
198
+ if (messageCount < triggerAt) return;
185
199
  try {
186
200
  // A pin that already keeps the hot tier within the retained range must not
187
201
  // be pulled back to the cutoff, nor re-summarised by an LLM on every turn.
188
202
  const pinSeq = getThread(threadId)?.hot_since_seq ?? null;
189
- if (pinSeq !== null && countThreadMessagesFromSeq(threadId, pinSeq) <= retainedMessages) {
203
+ if (pinSeq !== null && countForegroundThreadMessages(threadId, pinSeq) <= retainedMessages) {
190
204
  pruneThreadMessages(threadId, retainedMessages, pinSeq);
191
205
  return;
192
206
  }
@@ -237,7 +251,6 @@ export class RunThreadError extends Error {
237
251
 
238
252
  export interface PersistedThreadRunUserMessage {
239
253
  attachments?: ContentPart[];
240
- messageCountBefore: number;
241
254
  idleMsBefore: number | null;
242
255
  userMessageId: string;
243
256
  storedContent: string;
@@ -256,7 +269,6 @@ export async function persistThreadRunUserMessage(
256
269
  throw new RunThreadError(400, "message required", "message_required");
257
270
  }
258
271
 
259
- const messageCountBefore = thread.message_count;
260
272
  const autoBoundaryScope = req.context_profile?.history_scope
261
273
  ?? (req.user_category === "bridge" ? "bridge" : "foreground");
262
274
  const idleMsBefore = isAutoBoundaryEligibleCategory(req.user_category)
@@ -287,7 +299,6 @@ export async function persistThreadRunUserMessage(
287
299
 
288
300
  return {
289
301
  attachments: spilledAttachments,
290
- messageCountBefore,
291
302
  idleMsBefore,
292
303
  userMessageId: userMessage.msg_id,
293
304
  storedContent: stored,
@@ -486,8 +497,8 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
486
497
  await maybeAutoCompactOversizedThread(
487
498
  agentCfg.id,
488
499
  req.thread_id,
489
- req._message_count_before_user ?? thread.message_count,
490
500
  req._onCompacting,
501
+
491
502
  req._user_message_persisted ? 2 : 0,
492
503
  );
493
504
  }
@@ -552,7 +563,11 @@ export async function prepareThreadRun(req: ThreadRunRequest): Promise<PreparedT
552
563
  && latestThread.message_count < (latestThread.auto_boundary_locked_until_msg_count ?? 0);
553
564
  if (!locked) {
554
565
  const boundary = hotTurnBoundary(req.thread_id, agentCfg, autoBoundaryScope);
555
- if (boundary !== null) kickBoundaryCompaction(req.thread_id, boundary);
566
+ if (boundary !== null) {
567
+ kickBoundaryCompaction(req.thread_id, boundary, {
568
+ autoBoundaryLockedUntilMessageCount: hotTurnCooldownUntil(latestThread?.message_count ?? 0, agentCfg.hot_turn_limit),
569
+ });
570
+ }
556
571
  }
557
572
  }
558
573
  }
@@ -165,3 +165,94 @@ describe("NodeSqliteSaver", () => {
165
165
  expect(tuple).toBeUndefined();
166
166
  });
167
167
  });
168
+
169
+ describe("NodeSqliteSaver edge cases", () => {
170
+ async function chain(saver: NodeSqliteSaver, thread: string, count: number, ns = ""): Promise<string[]> {
171
+ const ids: string[] = [];
172
+ let parent: string | undefined;
173
+ for (let i = 0; i < count; i++) {
174
+ const ck = newCheckpoint();
175
+ await saver.put(
176
+ { configurable: { thread_id: thread, checkpoint_ns: ns, ...(parent ? { checkpoint_id: parent } : {}) } },
177
+ ck,
178
+ { ...META_BASE, step: i },
179
+ );
180
+ ids.push(ck.id);
181
+ parent = ck.id;
182
+ }
183
+ return ids;
184
+ }
185
+
186
+ it("links each checkpoint to its parent and returns no parent for the first one", async () => {
187
+ const saver = newSaver();
188
+ const [first, second] = await chain(saver, "t-parent", 2);
189
+ const latest = await saver.getTuple(cfg("t-parent"));
190
+ expect(latest?.checkpoint.id).toBe(second);
191
+ expect(latest?.parentConfig?.configurable?.checkpoint_id).toBe(first);
192
+ expect((await saver.getTuple(cfg("t-parent", first)))?.parentConfig).toBeUndefined();
193
+ });
194
+
195
+ it("keeps checkpoint namespaces (subgraphs) isolated within one thread", async () => {
196
+ const saver = newSaver();
197
+ const [root] = await chain(saver, "t-ns", 1, "");
198
+ const [child] = await chain(saver, "t-ns", 1, "sub:1");
199
+ const rootLatest = await saver.getTuple({ configurable: { thread_id: "t-ns", checkpoint_ns: "" } });
200
+ const childLatest = await saver.getTuple({ configurable: { thread_id: "t-ns", checkpoint_ns: "sub:1" } });
201
+ expect(rootLatest?.checkpoint.id).toBe(root);
202
+ expect(childLatest?.checkpoint.id).toBe(child);
203
+ expect(childLatest?.config.configurable?.checkpoint_ns).toBe("sub:1");
204
+ });
205
+
206
+ it("pages list() with before, newest first, without repeating the cursor", async () => {
207
+ const saver = newSaver();
208
+ const ids = await chain(saver, "t-before", 4);
209
+ const page = [];
210
+ for await (const tuple of saver.list(cfg("t-before"), { before: cfg("t-before", ids[2]), limit: 10 })) page.push(tuple.checkpoint.id);
211
+ expect(page).toEqual([ids[1], ids[0]]);
212
+ });
213
+
214
+ it("returns pending writes for the checkpoint they belong to, and replaces a retried task's writes", async () => {
215
+ const saver = newSaver();
216
+ const [first, second] = await chain(saver, "t-writes", 2);
217
+ await saver.putWrites(cfg("t-writes", second), [["messages", { text: "draft" }], ["status", "running"]], "task-1");
218
+ await saver.putWrites(cfg("t-writes", second), [["messages", { text: "final" }], ["status", "done"]], "task-1");
219
+ await saver.putWrites(cfg("t-writes", first), [["messages", { text: "old" }]], "task-0");
220
+
221
+ const latest = await saver.getTuple(cfg("t-writes"));
222
+ expect(latest?.pendingWrites).toEqual([
223
+ ["task-1", "messages", { text: "final" }],
224
+ ["task-1", "status", "done"],
225
+ ]);
226
+ const older = await saver.getTuple(cfg("t-writes", first));
227
+ expect(older?.pendingWrites).toEqual([["task-0", "messages", { text: "old" }]]);
228
+ });
229
+
230
+ it("round-trips non-ASCII and large channel values", async () => {
231
+ const saver = newSaver();
232
+ const ck = newCheckpoint();
233
+ const text = `你好 👋 ${"x".repeat(200_000)}`;
234
+ (ck.channel_values as Record<string, unknown>).messages = [{ text }];
235
+ await saver.put(cfg("t-big"), ck, META_BASE);
236
+ await saver.putWrites(cfg("t-big", ck.id), [["note", text]], "task");
237
+ const tuple = await saver.getTuple(cfg("t-big"));
238
+ expect((tuple?.checkpoint.channel_values as Record<string, unknown>).messages).toEqual([{ text }]);
239
+ expect(tuple?.pendingWrites?.[0][2]).toBe(text);
240
+ });
241
+
242
+ it("rejects writes without a checkpoint_id and puts without a thread_id", async () => {
243
+ const saver = newSaver();
244
+ await expect(saver.putWrites(cfg("t-x"), [["c", 1]], "task")).rejects.toThrow(/checkpoint_id/);
245
+ await expect(saver.put({ configurable: {} }, newCheckpoint(), META_BASE)).rejects.toThrow(/thread_id/);
246
+ });
247
+
248
+ it("deleteThread leaves other threads untouched", async () => {
249
+ const saver = newSaver();
250
+ const [keep] = await chain(saver, "t-keep", 1);
251
+ const [drop] = await chain(saver, "t-drop", 1);
252
+ await saver.putWrites(cfg("t-keep", keep), [["c", 1]], "task");
253
+ await saver.putWrites(cfg("t-drop", drop), [["c", 2]], "task");
254
+ await saver.deleteThread("t-drop");
255
+ expect(await saver.getTuple(cfg("t-drop"))).toBeUndefined();
256
+ expect((await saver.getTuple(cfg("t-keep")))?.pendingWrites).toEqual([["task", "c", 1]]);
257
+ });
258
+ });
@@ -16,6 +16,7 @@ import {
16
16
  getThreadMessageBySeq,
17
17
  getThreadChannelSummary,
18
18
  listThreadChannelSummaries,
19
+ setAutoBoundaryLock,
19
20
  } from "@/lib/stores/threads";
20
21
  import { getOrCreateGlobal } from "@/lib/utils/global-state";
21
22
  import {
@@ -163,9 +164,10 @@ function runBoundaryCompaction(
163
164
  ): void {
164
165
  const baseThread = getThread(threadId);
165
166
  const basePinSeq = baseThread?.hot_since_seq ?? null;
167
+ const baseLock = baseThread?.auto_boundary_locked_until_msg_count ?? 0;
166
168
  void runTrackedCompaction(threadId, "auto", boundarySeq, async (attempt) => {
167
169
  await Promise.resolve();
168
- await commitBoundaryCompaction(threadId, boundarySeq, basePinSeq, options, attempt);
170
+ await commitBoundaryCompaction(threadId, boundarySeq, basePinSeq, baseLock, options, attempt);
169
171
  })
170
172
  .catch((err) => console.warn(`[context-boundary:auto] compaction failed thread=${threadId}: ${String(err)}`))
171
173
  .finally(() => drainQueuedBoundary(threadId));
@@ -174,6 +176,7 @@ async function commitBoundaryCompaction(
174
176
  threadId: string,
175
177
  boundarySeq: number,
176
178
  basePinSeq: number | null,
179
+ baseLock: number,
177
180
  options: Pick<WarmContextCompactionOptions, "autoBoundaryLockedUntilMessageCount">,
178
181
  attempt?: CompactionAttempt,
179
182
  ): Promise<void> {
@@ -184,7 +187,24 @@ async function commitBoundaryCompaction(
184
187
  ...options,
185
188
  attempt,
186
189
  });
187
- if (!committed) return;
190
+ if (!committed) {
191
+ // A pass that resolved to no change (the active topic still starts at the
192
+ // current pin) must not be retried on every following turn either. Skip
193
+ // it when the boundary or lock changed mid-pass (e.g. the user cleared or
194
+ // moved the boundary): that newer state wins over this stale pass.
195
+ const lockUntil = options.autoBoundaryLockedUntilMessageCount;
196
+ const current = getThread(threadId);
197
+ if (
198
+ lockUntil !== undefined &&
199
+ current &&
200
+ (current.hot_since_seq ?? null) === basePinSeq &&
201
+ (current.auto_boundary_locked_until_msg_count ?? 0) === baseLock &&
202
+ lockUntil > baseLock
203
+ ) {
204
+ setAutoBoundaryLock(threadId, lockUntil);
205
+ }
206
+ return;
207
+ }
188
208
  console.info(
189
209
  `[context-boundary:auto] thread=${threadId} committed boundary_seq=${committed.boundarySeq} warm_msgs=${committed.sourceMessages}`,
190
210
  );
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { canMergeIntoTopics, savedMemoryFacts } from "./warm-topics";
2
+ import type { SummaryTopicFact, SummaryTopicSegment } from "./conversation-summary";
3
+ import {
4
+ canMergeIntoTopics,
5
+ finalizeTopic,
6
+ foldSingleMessageTopics,
7
+ mergeTopicFacts,
8
+ mergeWarmTopics,
9
+ savedMemoryFacts,
10
+ slugifyForMemoryKey,
11
+ } from "./warm-topics";
3
12
 
4
13
  const events = (...list: unknown[]) => JSON.stringify(list);
5
14
 
@@ -35,3 +44,148 @@ describe("canMergeIntoTopics", () => {
35
44
  expect(canMergeIntoTopics([topic], null, 9)).toBe(false);
36
45
  });
37
46
  });
47
+
48
+ const fact = (subject: string, key?: string, content = subject): SummaryTopicFact => ({
49
+ subject, content, tags: [], confidence: "explicit", ...(key ? { key } : {}),
50
+ });
51
+
52
+ const topic = (start: number, end: number, extra: Partial<SummaryTopicSegment> = {}): SummaryTopicSegment => ({
53
+ title: `T${start}-${end}`,
54
+ start_seq: start,
55
+ end_seq: end,
56
+ start_at: null,
57
+ end_at: null,
58
+ recap: `recap ${start}-${end}`,
59
+ facts: [],
60
+ ...extra,
61
+ } as SummaryTopicSegment);
62
+
63
+ const rowsFor = (...seqs: number[]) => seqs.map((seq) => ({ seq, content: `message ${seq}` }));
64
+
65
+ describe("slugifyForMemoryKey", () => {
66
+ it("keeps non-Latin subjects distinct instead of collapsing them to an empty key", () => {
67
+ const a = slugifyForMemoryKey("发布节奏");
68
+ const b = slugifyForMemoryKey("部署目标");
69
+ expect(a).not.toBe("");
70
+ expect(b).not.toBe("");
71
+ expect(a).not.toBe(b);
72
+ expect(slugifyForMemoryKey("Café Öffnungszeiten")).toBe("café-öffnungszeiten");
73
+ });
74
+
75
+ it("falls back to a stable non-empty key for subjects with no letters or digits", () => {
76
+ const key = slugifyForMemoryKey("🚀🚀");
77
+ expect(key).toMatch(/^fact-[0-9a-f]+$/);
78
+ expect(slugifyForMemoryKey("🚀🚀")).toBe(key);
79
+ expect(slugifyForMemoryKey("🎉")).not.toBe(key);
80
+ });
81
+
82
+ it("caps the key length and trims separators", () => {
83
+ expect(slugifyForMemoryKey(" --Release Cadence!! ")).toBe("release-cadence");
84
+ expect(slugifyForMemoryKey("x".repeat(200))).toHaveLength(80);
85
+ });
86
+ });
87
+
88
+ describe("mergeTopicFacts", () => {
89
+ it("merges by subject case-insensitively, later lists winning", () => {
90
+ const merged = mergeTopicFacts([fact("Editor", undefined, "old")], [fact("editor", undefined, "new")]);
91
+ expect(merged).toHaveLength(1);
92
+ expect(merged[0].content).toBe("new");
93
+ });
94
+
95
+ it("replaces a fact saved under the same key even when the subject was reworded", () => {
96
+ const merged = mergeTopicFacts([fact("Editor", "facts/editor", "VS Code")], [fact("Preferred editor", "facts/editor", "Zed")]);
97
+ expect(merged.map((f) => f.content)).toEqual(["Zed"]);
98
+ });
99
+
100
+ it("does not drop an unrelated fact through a key the subject no longer uses", () => {
101
+ const merged = mergeTopicFacts(
102
+ [fact("Region", "facts/region-old")],
103
+ [fact("Region", "facts/region")],
104
+ [fact("Old region alias", "facts/region-old")],
105
+ );
106
+ expect(merged.map((f) => f.key)).toEqual(["facts/region", "facts/region-old"]);
107
+ });
108
+
109
+ it("keeps the newest 16 facts", () => {
110
+ const merged = mergeTopicFacts(Array.from({ length: 20 }, (_, i) => fact(`s${i}`)));
111
+ expect(merged).toHaveLength(16);
112
+ expect(merged[0].subject).toBe("s4");
113
+ expect(merged[15].subject).toBe("s19");
114
+ });
115
+ });
116
+
117
+ describe("foldSingleMessageTopics", () => {
118
+ it("folds a leading single-message topic forward and a later one backward", () => {
119
+ const folded = foldSingleMessageTopics(
120
+ [topic(1, 1, { recap: "a" }), topic(2, 4, { recap: "b" }), topic(5, 5, { recap: "c" })],
121
+ rowsFor(1, 2, 3, 4, 5),
122
+ );
123
+ expect(folded).toHaveLength(1);
124
+ expect(folded[0]).toMatchObject({ start_seq: 1, end_seq: 5, recap: "a b c" });
125
+ });
126
+
127
+ it("counts rows, not seq width, so gaps from pruned rows still fold", () => {
128
+ const folded = foldSingleMessageTopics([topic(1, 3), topic(4, 9)], rowsFor(1, 2, 3, 9));
129
+ expect(folded).toHaveLength(1);
130
+ expect(folded[0]).toMatchObject({ start_seq: 1, end_seq: 9 });
131
+ });
132
+
133
+ it("leaves a lone topic alone even when it covers one message", () => {
134
+ expect(foldSingleMessageTopics([topic(1, 1)], rowsFor(1))).toHaveLength(1);
135
+ });
136
+
137
+ it("does not mutate its input", () => {
138
+ const input = [topic(1, 1, { facts: [fact("a")] }), topic(2, 3, { facts: [fact("b")] })];
139
+ const snapshot = structuredClone(input);
140
+ foldSingleMessageTopics(input, rowsFor(1, 2, 3));
141
+ expect(input).toEqual(snapshot);
142
+ });
143
+ });
144
+
145
+ describe("finalizeTopic", () => {
146
+ it("derives keys and counts in code and merges memory the agent saved in range", () => {
147
+ const covered = [
148
+ { seq: 1, content: "hello" },
149
+ {
150
+ seq: 2,
151
+ content: "saved",
152
+ tool_events: events(
153
+ { id: "m", phase: "call", name: "memory_write", payload: { namespace: "facts", key: "tz", value: "Europe/Amsterdam" } },
154
+ { id: "m", phase: "result", name: "memory_write", payload: JSON.stringify({ ok: true, key: "tz" }) },
155
+ ),
156
+ },
157
+ ];
158
+ const out = finalizeTopic(topic(1, 2, { facts: [fact("Release cadence")] }), covered);
159
+ expect(out.message_count).toBe(2);
160
+ expect(out.source_chars).toBe("hello".length + "saved".length);
161
+ expect(out.facts).toEqual([
162
+ expect.objectContaining({ key: "facts/release-cadence", origin: "compaction" }),
163
+ expect.objectContaining({ key: "facts/tz", origin: "agent", content: "Europe/Amsterdam" }),
164
+ ]);
165
+ });
166
+ });
167
+
168
+ describe("mergeWarmTopics", () => {
169
+ it("updates a continued topic in place, keeps its start, sums counts and re-sorts by recency", () => {
170
+ const prior = [topic(1, 4, { message_count: 4, source_chars: 40 }), topic(5, 8, { message_count: 4, source_chars: 40 })];
171
+ const fresh = [topic(9, 12, { continues: "t1", recap: "newer", message_count: 4, source_chars: 10 })];
172
+ const merged = mergeWarmTopics(prior, fresh, 100);
173
+ expect(merged.topics.map((t) => [t.start_seq, t.end_seq])).toEqual([[5, 8], [1, 12]]);
174
+ expect(merged.topics[1]).toMatchObject({ recap: "newer", message_count: 8, source_chars: 50 });
175
+ expect(merged.topics[1]).not.toHaveProperty("continues");
176
+ });
177
+
178
+ it("appends a fresh topic whose continues id does not exist", () => {
179
+ const merged = mergeWarmTopics([topic(1, 4, { message_count: 4 })], [topic(5, 8, { continues: "t9", message_count: 4 })], 100);
180
+ expect(merged.topics).toHaveLength(2);
181
+ expect(merged.topics[1]).not.toHaveProperty("continues");
182
+ });
183
+
184
+ it("evicts the least recently active topics until within the limit, keeping at least one", () => {
185
+ const prior = [topic(1, 4, { message_count: 4 }), topic(5, 8, { message_count: 4 })];
186
+ const merged = mergeWarmTopics(prior, [topic(9, 20, { message_count: 12 })], 10);
187
+ expect(merged.evicted).toBe(2);
188
+ expect(merged.topics.map((t) => t.start_seq)).toEqual([9]);
189
+ expect(merged.sourceMessages).toBe(10);
190
+ });
191
+ });
@@ -24,12 +24,26 @@ function rowsInRange<T extends WarmTopicSourceRow>(rows: readonly T[], topic: Su
24
24
  return rows.filter((row) => row.seq >= (topic.start_seq ?? Infinity) && row.seq <= (topic.end_seq ?? -Infinity));
25
25
  }
26
26
 
27
+ // Unicode-aware so non-Latin subjects get distinct keys; an ASCII-only slug
28
+ // collapsed them all to "" and the uplift skipped them. Subjects with no
29
+ // letters or digits fall back to a stable hash.
27
30
  export function slugifyForMemoryKey(subject: string): string {
28
- return subject
31
+ const slug = subject
32
+ .normalize("NFC")
29
33
  .toLowerCase()
30
- .replace(/[^a-z0-9]+/g, "-")
31
- .replace(/^-+|-+$/g, "")
32
- .slice(0, 80);
34
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, "-")
35
+ .replace(/^-+|-+$/g, "");
36
+ const capped = Array.from(slug).slice(0, 80).join("").replace(/-+$/, "");
37
+ return capped || `fact-${fnv1a(subject)}`;
38
+ }
39
+
40
+ function fnv1a(text: string): string {
41
+ let hash = 0x811c9dc5;
42
+ for (let i = 0; i < text.length; i++) {
43
+ hash ^= text.charCodeAt(i);
44
+ hash = Math.imul(hash, 0x01000193);
45
+ }
46
+ return (hash >>> 0).toString(16).padStart(8, "0");
33
47
  }
34
48
 
35
49
  /**
@@ -43,9 +57,15 @@ export function mergeTopicFacts(...lists: readonly (readonly SummaryTopicFact[])
43
57
  for (const list of lists) {
44
58
  for (const fact of list) {
45
59
  const subjectId = fact.subject.trim().toLowerCase();
46
- const existingSubject = fact.key ? keyToSubject.get(fact.key) : undefined;
47
- if (existingSubject && existingSubject !== subjectId) merged.delete(existingSubject);
48
- merged.delete(subjectId);
60
+ const sameKeySubject = fact.key ? keyToSubject.get(fact.key) : undefined;
61
+ for (const replaced of [sameKeySubject, subjectId]) {
62
+ if (replaced === undefined) continue;
63
+ // Drop the replaced fact's key mapping too, so a later fact reusing
64
+ // that key cannot delete whatever now lives under this subject.
65
+ const previousKey = merged.get(replaced)?.key;
66
+ if (previousKey) keyToSubject.delete(previousKey);
67
+ merged.delete(replaced);
68
+ }
49
69
  merged.set(subjectId, fact);
50
70
  if (fact.key) keyToSubject.set(fact.key, subjectId);
51
71
  }
@@ -87,6 +87,7 @@ export async function handleInboundMessage(
87
87
  role: msg.role,
88
88
  sender_id: senderJid,
89
89
  sender_name: senderName,
90
+ chat_link: adapter.chatLink?.(msg.remote_jid) ?? null,
90
91
  text: msg.text,
91
92
  silent,
92
93
  event: msg.event,