@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
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-proxy-config-"));
7
+ process.env.JARELA_DB_DIR = tmpRoot;
8
+
9
+ const store = await import("./proxy-config");
10
+ const { getDb } = await import("@/lib/db");
11
+ const { SECRET_MASK } = await import("./integrations");
12
+
13
+ afterAll(() => {
14
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
15
+ });
16
+
17
+ const PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n";
18
+
19
+ function storedPasswordColumn(): string | null {
20
+ const row = getDb().prepare("SELECT password FROM proxy_config WHERE id = 1").get() as { password: string | null } | undefined;
21
+ return row?.password ?? null;
22
+ }
23
+
24
+ describe("proxy-config store", () => {
25
+ beforeEach(() => {
26
+ getDb().exec("DELETE FROM proxy_config");
27
+ });
28
+
29
+ it("returns the default off status before anything is saved", () => {
30
+ expect(store.getProxyConfigStatus()).toMatchObject({ mode: "off", scheme: "http", host: null, password: null });
31
+ expect(store.getProxyConfigRaw()).toBeNull();
32
+ expect(store.deleteProxyConfig()).toBe(false);
33
+ });
34
+
35
+ it("encrypts the password at rest, masks it in status and decrypts it for the dispatcher", () => {
36
+ const saved = store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, username: "u", password: "p@ss:word" });
37
+ expect(saved).toMatchObject({ mode: "manual", host: "proxy.corp", port: 3128, password: SECRET_MASK });
38
+ expect(storedPasswordColumn()).not.toContain("p@ss:word");
39
+ expect(store.getProxyConfigRaw()?.password).toBe("p@ss:word");
40
+ });
41
+
42
+ it("keeps the existing password when the UI echoes the mask back, without re-encrypting it", () => {
43
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, username: "u", password: "secret" });
44
+ const before = storedPasswordColumn();
45
+ store.saveProxyConfig({ mode: "manual", host: "proxy2.corp", port: 8080, username: "u", password: SECRET_MASK });
46
+ expect(storedPasswordColumn()).toBe(before);
47
+ expect(store.getProxyConfigRaw()).toMatchObject({ host: "proxy2.corp", port: 8080, password: "secret" });
48
+ });
49
+
50
+ it("never stores the mask itself as a password when none existed", () => {
51
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: SECRET_MASK });
52
+ expect(store.getProxyConfigRaw()?.password).toBeNull();
53
+ expect(store.getProxyConfigStatus().password).toBeNull();
54
+ });
55
+
56
+ it("clears the password when an empty string or null is sent", () => {
57
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "secret" });
58
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "" });
59
+ expect(store.getProxyConfigRaw()?.password).toBeNull();
60
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "secret" });
61
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: null });
62
+ expect(store.getProxyConfigRaw()?.password).toBeNull();
63
+ });
64
+
65
+ it("trims host, username and no_proxy and stores blanks as null", () => {
66
+ store.saveProxyConfig({ mode: "manual", host: " proxy.corp ", port: 3128, username: " ", no_proxy: " " });
67
+ expect(store.getProxyConfigRaw()).toMatchObject({ host: "proxy.corp", username: null, no_proxy: null });
68
+ });
69
+
70
+ it.each([
71
+ ["a URL with a scheme", "http://proxy.corp"],
72
+ ["a host with a port", "proxy.corp:8080"],
73
+ ["a hex-only host with a port", "dead.beef:3128"],
74
+ ["a host with a path", "proxy.corp/pac"],
75
+ ["a host with credentials", "user:pw@proxy.corp"],
76
+ ["a host with spaces", "proxy corp"],
77
+ ])("rejects %s in manual mode instead of building a broken proxy URL", (_label, host) => {
78
+ const result = store.saveProxyConfig({ mode: "manual", host, port: 3128 });
79
+ expect(result).toHaveProperty("error");
80
+ expect((result as { error: string }).error).toMatch(/host/i);
81
+ expect(store.getProxyConfigRaw()).toBeNull();
82
+ });
83
+
84
+ it.each([
85
+ ["fd00::1", "fd00::1"],
86
+ ["[fd00::1]", "fd00::1"],
87
+ ["10.0.0.5", "10.0.0.5"],
88
+ ["proxy-1.corp.example", "proxy-1.corp.example"],
89
+ ])("accepts host %s and stores it as %s", (input, stored) => {
90
+ const result = store.saveProxyConfig({ mode: "manual", host: input, port: 3128 });
91
+ expect(result).not.toHaveProperty("error");
92
+ expect(store.getProxyConfigRaw()?.host).toBe(stored);
93
+ });
94
+
95
+ it.each([0, -1, 65536, 3128.5, Number.NaN, Number.POSITIVE_INFINITY])("rejects port %s", (port) => {
96
+ expect(store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port })).toHaveProperty("error");
97
+ });
98
+
99
+ it("requires host and port only in manual mode", () => {
100
+ expect(store.saveProxyConfig({ mode: "manual", port: 3128 })).toHaveProperty("error");
101
+ expect(store.saveProxyConfig({ mode: "manual", host: "proxy.corp" })).toHaveProperty("error");
102
+ expect(store.saveProxyConfig({ mode: "system" })).toMatchObject({ mode: "system" });
103
+ expect(store.saveProxyConfig({ mode: "off" })).toMatchObject({ mode: "off" });
104
+ });
105
+
106
+ it("rejects an invalid mode and a non-PEM CA bundle, and clears a blank bundle", () => {
107
+ expect(store.saveProxyConfig({ mode: "auto" as never })).toHaveProperty("error");
108
+ expect(store.saveProxyConfig({ mode: "off", ca_bundle: "not a cert" })).toHaveProperty("error");
109
+ store.saveProxyConfig({ mode: "off", ca_bundle: PEM });
110
+ expect(store.getProxyConfigRaw()?.ca_bundle).toBe(PEM);
111
+ store.saveProxyConfig({ mode: "off", ca_bundle: " " });
112
+ expect(store.getProxyConfigRaw()?.ca_bundle).toBeNull();
113
+ });
114
+
115
+ it("normalises an unknown persisted scheme to http", () => {
116
+ store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128 });
117
+ getDb().prepare("UPDATE proxy_config SET scheme = 'socks5' WHERE id = 1").run();
118
+ expect(store.getProxyConfigRaw()).toMatchObject({ mode: "manual", scheme: "http" });
119
+ });
120
+ });
@@ -15,6 +15,7 @@
15
15
  // existing password" so the UI can echo back the masked form without
16
16
  // blanking the secret.
17
17
 
18
+ import { isIPv6 } from "node:net";
18
19
  import { getDb } from "@/lib/db";
19
20
  import { decryptIfNeeded, encrypt } from "@/lib/crypto/envelope";
20
21
  import { SECRET_MASK } from "@/lib/stores/integrations";
@@ -122,11 +123,18 @@ export function saveProxyConfig(input: ProxyConfigInput): ProxyConfigStatus | {
122
123
  const scheme: ProxyScheme = input.scheme === "https" ? "https" : "http";
123
124
 
124
125
  // Manual mode requires host + port. system / off don't.
126
+ let host = nullable(input.host);
125
127
  if (input.mode === "manual") {
126
- if (!input.host || !input.host.trim()) return { error: "host is required in manual mode" };
127
- if (input.port == null || !Number.isFinite(input.port) || input.port <= 0 || input.port > 65535) {
128
- return { error: "port must be between 1 and 65535" };
128
+ if (!host) return { error: "host is required in manual mode" };
129
+ const normalisedHost = normaliseProxyHost(host);
130
+ if (!normalisedHost) {
131
+ return { error: "host must be a hostname or IP address, without scheme, port, path or credentials" };
129
132
  }
133
+ host = normalisedHost;
134
+ if (input.port == null) return { error: "port must be between 1 and 65535" };
135
+ }
136
+ if (input.port != null && (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535)) {
137
+ return { error: "port must be between 1 and 65535" };
130
138
  }
131
139
 
132
140
  // CA bundle: empty/null clears; non-empty must look like PEM.
@@ -167,7 +175,7 @@ export function saveProxyConfig(input: ProxyConfigInput): ProxyConfigStatus | {
167
175
  ).run(
168
176
  input.mode,
169
177
  scheme,
170
- nullable(input.host),
178
+ host,
171
179
  input.port ?? null,
172
180
  nullable(input.username),
173
181
  storedPassword,
@@ -207,6 +215,16 @@ function normaliseScheme(s: string | null | undefined): ProxyScheme {
207
215
  return s === "https" ? "https" : "http";
208
216
  }
209
217
 
218
+ // The dispatcher builds `${scheme}://${auth}${host}:${port}`, so a pasted URL
219
+ // or host:port would produce an unusable proxy URL. IPv6 is stored unbracketed;
220
+ // the dispatcher adds the brackets.
221
+ function normaliseProxyHost(host: string): string | null {
222
+ const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
223
+ if (/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(unbracketed)) return unbracketed;
224
+ if (isIPv6(unbracketed)) return unbracketed;
225
+ return null;
226
+ }
227
+
210
228
  function nullable(v: string | null | undefined): string | null {
211
229
  if (v == null) return null;
212
230
  const trimmed = v.trim();
@@ -157,11 +157,15 @@ export function getThreadMessageBySeq(thread_id: string, seq: number): MessageRo
157
157
  return (getDb().prepare(MSG_COLS_SQL + " WHERE thread_id=? AND rowid=?").get(thread_id, seq) as unknown as MessageRow | undefined) ?? null;
158
158
  }
159
159
 
160
- export function countThreadMessagesFromSeq(thread_id: string, seq: number): number {
161
- const row = getDb().prepare("SELECT COUNT(*) AS n FROM messages WHERE thread_id=? AND rowid>=?").get(thread_id, seq) as { n: number } | undefined;
160
+
161
+ // Rows that size compaction can prune: automation-channel rows are kept
162
+ // (see pruneThreadMessages), so they must not count toward its trigger.
163
+ export function countForegroundThreadMessages(thread_id: string, fromSeq = 0): number {
164
+ const row = getDb()
165
+ .prepare("SELECT COUNT(*) AS n FROM messages WHERE thread_id=? AND rowid>=? AND " + foregroundCategoryGuardSql())
166
+ .get(thread_id, fromSeq, ...FOREGROUND_EXCLUDED_CATEGORIES) as { n: number } | undefined;
162
167
  return row?.n ?? 0;
163
168
  }
164
-
165
169
  export function isValidThreadContextCursor(thread_id: string, seq: number): boolean {
166
170
  if (getThreadMessageBySeq(thread_id, seq)) return true;
167
171
  return getThread(thread_id)?.hot_since_seq === seq;
@@ -733,11 +737,11 @@ export function setThreadContextPin(thread_id: string, hot_since: string | null,
733
737
  .run(hot_since, hot_since === null ? null : hot_since_seq!, thread_id);
734
738
  }
735
739
 
736
- // Called by moveThreadContextBoundary whenever the user explicitly moves the
737
- // boundary (drag, or manual /compact) — never by the auto-detector's own
738
- // commit path, which calls setThreadContextPin directly. `untilMessageCount`
739
- // is a one-time write; the read side (lib/agents/run-thread.ts) just compares
740
- // against it, no per-turn write needed.
740
+ // Armed by moveThreadContextBoundary whenever the user explicitly moves the
741
+ // boundary (drag, or manual /compact), and by the auto-detector's hot-turn
742
+ // cooldown when a compaction pass ends without a commit (a commit writes the
743
+ // lock in the same transaction). `untilMessageCount` is a one-time write; the
744
+ // read side (lib/agents/run-thread.ts) just compares against it.
741
745
  export function setAutoBoundaryLock(thread_id: string, untilMessageCount: number): void {
742
746
  getDb()
743
747
  .prepare("UPDATE threads SET auto_boundary_locked_until_msg_count=? WHERE thread_id=?")
@@ -1,6 +1,47 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- const { calendarListEventsTool } = await import("./calendar");
3
+ const { calendarGetEventTool, calendarListEventsTool } = await import("./calendar");
4
+ const { outlookCalendarGetEventTool } = await import("./outlook-calendar");
5
+
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ vi.unstubAllEnvs();
9
+ });
10
+
11
+ function stubApi(tokenHost: string, body: unknown) {
12
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
13
+ if (url.includes(tokenHost)) return new Response(JSON.stringify({ access_token: "at", expires_in: 3600 }), { status: 200 });
14
+ return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
15
+ }));
16
+ }
17
+
18
+ describe("calendar source links", () => {
19
+ it("returns Google's htmlLink as html_link and cite_as", async () => {
20
+ vi.stubEnv("GMAIL_CLIENT_ID", "test-client");
21
+ vi.stubEnv("GMAIL_CLIENT_SECRET", "test-secret");
22
+ vi.stubEnv("GMAIL_REFRESH_TOKEN", "calendar-source-link-refresh");
23
+ const htmlLink = "https://www.google.com/calendar/event?eid=ZXZ0MSB1c2VyQGV4YW1wbGUudGVzdA";
24
+ stubApi("oauth2", { id: "evt1", summary: "Dentist", htmlLink });
25
+
26
+ const out = JSON.parse(await calendarGetEventTool.invoke({ event_id: "evt1" })) as Record<string, unknown>;
27
+
28
+ expect(out.html_link).toBe(htmlLink);
29
+ expect(out.cite_as).toBe(`[Dentist](${htmlLink})`);
30
+ });
31
+
32
+ it("returns Outlook's webLink as web_link and cite_as", async () => {
33
+ vi.stubEnv("OUTLOOK_CLIENT_ID", "test-client");
34
+ vi.stubEnv("OUTLOOK_CLIENT_SECRET", "test-secret");
35
+ vi.stubEnv("OUTLOOK_REFRESH_TOKEN", "outlook-calendar-source-link-refresh");
36
+ const webLink = "https://outlook.office365.com/owa/?itemid=AAMk%3D&exvsurl=1&path=/calendar/item";
37
+ stubApi("/oauth2/", { id: "evt2", subject: "Standup", webLink });
38
+
39
+ const out = JSON.parse(await outlookCalendarGetEventTool.invoke({ event_id: "evt2" })) as Record<string, unknown>;
40
+
41
+ expect(out.web_link).toBe(webLink);
42
+ expect(out.cite_as).toBe(`[Standup](${webLink})`);
43
+ });
44
+ });
4
45
 
5
46
  describe("calendar_list_events", () => {
6
47
  it("rejects date-only time bounds before calling Google Calendar", async () => {
@@ -25,6 +25,7 @@
25
25
  import { tool } from "@langchain/core/tools";
26
26
  import { z } from "zod";
27
27
  import { registerLangChainPackage } from "../packages/langchain-package";
28
+ import { citeAs } from "@/lib/utils/source-link";
28
29
  import {
29
30
  googleFetch,
30
31
  resolveGoogleAuth,
@@ -94,6 +95,7 @@ function slimEvent(e: CalendarEvent): Record<string, unknown> {
94
95
  })),
95
96
  meet_link: e.hangoutLink ?? null,
96
97
  html_link: e.htmlLink ?? null,
98
+ cite_as: citeAs(e.summary, e.htmlLink, "Calendar event"),
97
99
  };
98
100
  }
99
101
 
@@ -2,10 +2,60 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { gzipSync } from "node:zlib";
3
3
  import { googleFetch } from "@/lib/integrations/gmail-oauth";
4
4
 
5
- const { buildRawMessage, gmailCreateDraftTool, gmailModifyMessageTool, gmailSendEmailTool } = await import("./gmail");
5
+ const { buildRawMessage, gmailCreateDraftTool, gmailGetMessageTool, gmailModifyMessageTool, gmailSendEmailTool, gmailWebLink } = await import("./gmail");
6
6
 
7
7
  afterEach(() => {
8
8
  vi.unstubAllGlobals();
9
+ vi.unstubAllEnvs();
10
+ });
11
+
12
+ describe("gmail source links", () => {
13
+ it("builds an All Mail thread link scoped to the account", () => {
14
+ expect(gmailWebLink("18c2f0a1b2c3d4e5", "me+x@example.test"))
15
+ .toBe("https://mail.google.com/mail/u/me%2Bx%40example.test/#all/18c2f0a1b2c3d4e5");
16
+ expect(gmailWebLink("18c2f0a1b2c3d4e5", null)).toBe("https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3d4e5");
17
+ });
18
+
19
+ it("refuses ids that are not Gmail API hex ids", () => {
20
+ expect(gmailWebLink(undefined, null)).toBeNull();
21
+ expect(gmailWebLink("../inbox", null)).toBeNull();
22
+ expect(gmailWebLink("18c2f0a1)x", null)).toBeNull();
23
+ });
24
+
25
+ function stubGmail(profile: Response) {
26
+ vi.stubEnv("GMAIL_CLIENT_ID", "test-client");
27
+ vi.stubEnv("GMAIL_CLIENT_SECRET", "test-secret");
28
+ const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
29
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
30
+ if (url.includes("oauth2")) return json({ access_token: "at", expires_in: 3600 });
31
+ if (url.endsWith("/profile")) return profile;
32
+ return json({
33
+ id: "18c2f0a1b2c3d4e6",
34
+ threadId: "18c2f0a1b2c3d4e5",
35
+ payload: { headers: [{ name: "Subject", value: "Invoice [March]" }] },
36
+ });
37
+ }));
38
+ }
39
+
40
+ it("returns web_link and cite_as from gmail_get_message", async () => {
41
+ vi.stubEnv("GMAIL_REFRESH_TOKEN", "gmail-link-ok-refresh-token");
42
+ stubGmail(new Response(JSON.stringify({ emailAddress: "owner@example.test" }), { status: 200, headers: { "content-type": "application/json" } }));
43
+
44
+ const out = JSON.parse(await gmailGetMessageTool.invoke({ id: "18c2f0a1b2c3d4e6" })) as Record<string, unknown>;
45
+
46
+ const link = "https://mail.google.com/mail/u/owner%40example.test/#all/18c2f0a1b2c3d4e5";
47
+ expect(out.web_link).toBe(link);
48
+ expect(out.cite_as).toBe(`[Invoice \\[March\\]](${link})`);
49
+ });
50
+
51
+ it("falls back to the first signed-in account when the profile lookup fails", async () => {
52
+ vi.stubEnv("GMAIL_REFRESH_TOKEN", "gmail-link-fail-refresh-token");
53
+ stubGmail(new Response(JSON.stringify({ error: { message: "forbidden" } }), { status: 403, headers: { "content-type": "application/json" } }));
54
+
55
+ const out = JSON.parse(await gmailGetMessageTool.invoke({ id: "18c2f0a1b2c3d4e6" })) as Record<string, unknown>;
56
+
57
+ expect(out.web_link).toBe("https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3d4e5");
58
+ });
9
59
  });
10
60
 
11
61
  describe("gmail tools", () => {
@@ -30,6 +30,7 @@ import {
30
30
  type GoogleAuth,
31
31
  } from "@/lib/integrations/gmail-oauth";
32
32
  import { errorMessage } from "@/lib/utils/error";
33
+ import { citeAs } from "@/lib/utils/source-link";
33
34
 
34
35
  // Re-export under the old name so existing imports (e.g. the integrations
35
36
  // test endpoint) keep working unchanged.
@@ -62,6 +63,38 @@ function resolveGmailAuthForTool(): GmailAuth | { error: string } {
62
63
  }
63
64
  }
64
65
 
66
+ // ── Source links ────────────────────────────────────────────────────────────
67
+
68
+ // Gmail's web URL scheme is undocumented. `#all/<threadId>` opens the
69
+ // conversation from All Mail whatever its labels, and `/u/<email>/` selects
70
+ // the signed-in account (`/u/0/` is whichever account signed in first).
71
+ const GMAIL_API_ID = /^[0-9a-f]{8,32}$/i;
72
+
73
+ export function gmailWebLink(threadId: string | null | undefined, account: string | null): string | null {
74
+ if (!threadId || !GMAIL_API_ID.test(threadId)) return null;
75
+ const slot = account ? encodeURIComponent(account) : "0";
76
+ return `https://mail.google.com/mail/u/${slot}/#all/${threadId}`;
77
+ }
78
+
79
+ const accountEmailCache = new Map<string, Promise<string | null>>();
80
+
81
+ // getProfile works with every Gmail scope; a failure only drops the account
82
+ // from the link, so it is not retried within the process.
83
+ function gmailAccountEmail(auth: GmailAuth): Promise<string | null> {
84
+ const key = auth.refresh_token.slice(0, 20);
85
+ let pending = accountEmailCache.get(key);
86
+ if (!pending) {
87
+ pending = gmailFetch(auth, "/profile")
88
+ .then((p) => {
89
+ const email = (p as { emailAddress?: unknown }).emailAddress;
90
+ return typeof email === "string" && email.includes("@") ? email : null;
91
+ })
92
+ .catch(() => null);
93
+ accountEmailCache.set(key, pending);
94
+ }
95
+ return pending;
96
+ }
97
+
65
98
  // ── Decoders / helpers ──────────────────────────────────────────────────────
66
99
 
67
100
  // Gmail uses base64url (no padding, +/ → -_). Node's Buffer handles it directly.
@@ -164,6 +197,7 @@ export const gmailSearchTool = tool(
164
197
  messages: [],
165
198
  });
166
199
  }
200
+ const account = await gmailAccountEmail(auth);
167
201
  // Parallel metadata fetch; the API recommends batching but for ≤100 ids
168
202
  // parallel single calls are simpler and well within quota.
169
203
  const details = await Promise.all(ids.map(async (id) => {
@@ -172,15 +206,19 @@ export const gmailSearchTool = tool(
172
206
  `/messages/${id}?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date`,
173
207
  ) as { id?: string; threadId?: string; snippet?: string; labelIds?: string[]; payload?: { headers?: Header[] }; error?: string };
174
208
  if (m.error) return { id, error: m.error };
209
+ const subject = header(m.payload?.headers, "Subject");
210
+ const webLink = gmailWebLink(m.threadId, account);
175
211
  return {
176
212
  id: m.id,
177
213
  thread_id: m.threadId,
178
214
  from: header(m.payload?.headers, "From"),
179
215
  to: header(m.payload?.headers, "To"),
180
- subject: header(m.payload?.headers, "Subject"),
216
+ subject,
181
217
  date: header(m.payload?.headers, "Date"),
182
218
  snippet: m.snippet ?? null,
183
219
  labels: m.labelIds ?? [],
220
+ web_link: webLink,
221
+ cite_as: citeAs(subject, webLink, "Gmail message"),
184
222
  };
185
223
  }));
186
224
  return JSON.stringify({
@@ -220,14 +258,18 @@ export const gmailGetMessageTool = tool(
220
258
  if (m.error) return JSON.stringify(m);
221
259
  const body = extractBody(m.payload);
222
260
  const { text: capped, truncated } = truncateBytes(body, MAX_BODY_BYTES);
261
+ const subject = header(m.payload?.headers, "Subject");
262
+ const webLink = gmailWebLink(m.threadId, await gmailAccountEmail(auth));
223
263
  return JSON.stringify({
224
264
  id: m.id,
225
265
  thread_id: m.threadId,
266
+ web_link: webLink,
267
+ cite_as: citeAs(subject, webLink, "Gmail message"),
226
268
  labels: m.labelIds ?? [],
227
269
  from: header(m.payload?.headers, "From"),
228
270
  to: header(m.payload?.headers, "To"),
229
271
  cc: header(m.payload?.headers, "Cc"),
230
- subject: header(m.payload?.headers, "Subject"),
272
+ subject,
231
273
  date: header(m.payload?.headers, "Date"),
232
274
  message_id: header(m.payload?.headers, "Message-Id") ?? header(m.payload?.headers, "Message-ID"),
233
275
  snippet: m.snippet ?? null,
@@ -19,6 +19,7 @@
19
19
  import { tool } from "@langchain/core/tools";
20
20
  import { z } from "zod";
21
21
  import { registerLangChainPackage } from "../packages/langchain-package";
22
+ import { citeAs } from "@/lib/utils/source-link";
22
23
  import {
23
24
  graphFetch,
24
25
  resolveMicrosoftAuth,
@@ -81,6 +82,7 @@ function slimEvent(e: GraphEvent): Record<string, unknown> {
81
82
  show_as: e.showAs ?? null,
82
83
  teams_link: e.onlineMeeting?.joinUrl ?? null,
83
84
  web_link: e.webLink ?? null,
85
+ cite_as: citeAs(e.subject, e.webLink, "Outlook event"),
84
86
  };
85
87
  }
86
88
 
@@ -1,6 +1,37 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- const { outlookCreateDraftTool, outlookSendEmailTool } = await import("./outlook");
3
+ const { outlookCreateDraftTool, outlookSearchTool, outlookSendEmailTool } = await import("./outlook");
4
+
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals();
7
+ vi.unstubAllEnvs();
8
+ });
9
+
10
+ describe("outlook source links", () => {
11
+ it("returns the Graph webLink and a cite_as markdown link per message", async () => {
12
+ vi.stubEnv("OUTLOOK_CLIENT_ID", "test-client");
13
+ vi.stubEnv("OUTLOOK_CLIENT_SECRET", "test-secret");
14
+ vi.stubEnv("OUTLOOK_REFRESH_TOKEN", "outlook-source-link-refresh");
15
+ const webLink = "https://outlook.office365.com/owa/?ItemID=AAMk%2BA%3D&exvsurl=1&viewmodel=ReadMessageItem";
16
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
17
+ if (url.includes("/oauth2/")) return new Response(JSON.stringify({ access_token: "at", expires_in: 3600 }), { status: 200 });
18
+ return new Response(JSON.stringify({
19
+ value: [
20
+ { id: "m1", subject: "Budget [Q3]", webLink },
21
+ { id: "m2", subject: "No link" },
22
+ ],
23
+ }), { status: 200, headers: { "content-type": "application/json" } });
24
+ }));
25
+
26
+ const out = JSON.parse(await outlookSearchTool.invoke({ query: "budget" })) as {
27
+ messages: Array<{ web_link: string | null; cite_as: string | null }>;
28
+ };
29
+
30
+ expect(out.messages[0].web_link).toBe(webLink);
31
+ expect(out.messages[0].cite_as).toBe(`[Budget \\[Q3\\]](${webLink})`);
32
+ expect(out.messages[1]).toMatchObject({ web_link: null, cite_as: null });
33
+ });
34
+ });
4
35
 
5
36
  describe("outlook_create_draft", () => {
6
37
  it("rejects oversized draft bodies before calling Microsoft Graph", async () => {
@@ -14,6 +14,7 @@ import { tool } from "@langchain/core/tools";
14
14
  import { z } from "zod";
15
15
  import { stripHtml } from "@/lib/utils/html";
16
16
  import { truncateBytes } from "@/lib/utils/text";
17
+ import { citeAs } from "@/lib/utils/source-link";
17
18
  import { registerLangChainPackage } from "../packages/langchain-package";
18
19
  import {
19
20
  graphFetch,
@@ -84,6 +85,7 @@ function summarizeMessage(m: GraphMessage): Record<string, unknown> {
84
85
  folder_id: m.parentFolderId ?? null,
85
86
  snippet: m.bodyPreview ?? null,
86
87
  web_link: m.webLink ?? null,
88
+ cite_as: citeAs(m.subject, m.webLink, "Outlook message"),
87
89
  };
88
90
  }
89
91
 
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildMultipleMatchesDiagnostic,
4
+ buildNotFoundDiagnostic,
5
+ conformEol,
6
+ dominantEol,
7
+ lineNumberAt,
8
+ locateOldString,
9
+ } from "./edit-match";
10
+
11
+ const slice = (raw: string, r: { start: number; end: number }) => raw.slice(r.start, r.end);
12
+
13
+ describe("locateOldString", () => {
14
+ it("returns every non-overlapping exact occurrence", () => {
15
+ const attempt = locateOldString("aaaa", "aa", "exact");
16
+ expect(attempt.ranges).toEqual([{ start: 0, end: 2 }, { start: 2, end: 4 }]);
17
+ expect(attempt).toMatchObject({ usedStrategy: "exact", crlfAdjusted: false });
18
+ });
19
+
20
+ it("never matches an empty old_string", () => {
21
+ expect(locateOldString("abc", "", "fuzzy").ranges).toEqual([]);
22
+ });
23
+
24
+ it("maps an LF old_string onto a CRLF file in raw coordinates", () => {
25
+ const raw = "one\r\ntwo\r\nthree\r\n";
26
+ const attempt = locateOldString(raw, "two\nthree\n", "exact");
27
+ expect(attempt.crlfAdjusted).toBe(true);
28
+ expect(slice(raw, attempt.ranges[0])).toBe("two\r\nthree\r\n");
29
+ });
30
+
31
+ it("maps a CRLF old_string onto an LF file", () => {
32
+ const raw = "a\nb\nc";
33
+ expect(slice(raw, locateOldString(raw, "b\r\nc", "exact").ranges[0])).toBe("b\nc");
34
+ });
35
+
36
+ it("does not apply whitespace strategies unless requested", () => {
37
+ const raw = "if (x) {\n y();\n}";
38
+ expect(locateOldString(raw, "if (x) {", "exact").ranges).toEqual([]);
39
+ const attempt = locateOldString(raw, "if (x) {", "normalize_whitespace");
40
+ expect(attempt.usedStrategy).toBe("normalize_whitespace");
41
+ expect(slice(raw, attempt.ranges[0])).toBe("if (x) {");
42
+ });
43
+
44
+ it("matches trailing-whitespace drift and keeps the original text in range", () => {
45
+ const raw = "alpha \t\nbeta\n";
46
+ const attempt = locateOldString(raw, "alpha\nbeta", "trim_trailing");
47
+ expect(attempt.usedStrategy).toBe("trim_trailing");
48
+ expect(slice(raw, attempt.ranges[0])).toBe("alpha \t\nbeta");
49
+ });
50
+
51
+ it("keeps the leading indentation of the file when normalizing whitespace", () => {
52
+ const raw = "class A {\n method() {\n return 1;\n }\n}";
53
+ const attempt = locateOldString(raw, "method() {\n return 1;", "normalize_whitespace");
54
+ expect(slice(raw, attempt.ranges[0])).toBe("method() {\n return 1;");
55
+ });
56
+
57
+ it("returns a fuzzy range covering whole lines in an LF file", () => {
58
+ const raw = "header\nconst total = items.reduce((a, b) => a + b, 0);\nfooter\n";
59
+ const attempt = locateOldString(raw, "const totl = items.reduce((a, b) => a + b, 0);", "fuzzy");
60
+ expect(attempt.usedStrategy).toBe("fuzzy");
61
+ expect(slice(raw, attempt.ranges[0])).toBe("const total = items.reduce((a, b) => a + b, 0);");
62
+ });
63
+
64
+ it("returns a fuzzy range in raw coordinates for a CRLF file", () => {
65
+ const raw = "line one\r\nline two\r\nconst total = items.reduce((a, b) => a + b, 0);\r\nreturn total;\r\n";
66
+ const attempt = locateOldString(raw, "const totl = items.reduce((a, b) => a + b, 0);\nreturn total;", "fuzzy");
67
+ expect(attempt.usedStrategy).toBe("fuzzy");
68
+ expect(slice(raw, attempt.ranges[0])).toBe("const total = items.reduce((a, b) => a + b, 0);\r\nreturn total;");
69
+ });
70
+
71
+ it("returns a fuzzy range in raw coordinates for a classic-Mac CR file", () => {
72
+ const raw = "first\rsecond\rconst total = items.reduce((a, b) => a + b, 0);\rend";
73
+ const attempt = locateOldString(raw, "const totl = items.reduce((a, b) => a + b, 0);", "fuzzy");
74
+ expect(slice(raw, attempt.ranges[0])).toBe("const total = items.reduce((a, b) => a + b, 0);");
75
+ });
76
+
77
+ it("refuses an ambiguous fuzzy match", () => {
78
+ const line = "const total = items.reduce((a, b) => a + b, 0);";
79
+ const raw = `${line}\nmiddle\n${line}\n`;
80
+ expect(locateOldString(raw, line.replace("total", "totl"), "fuzzy").ranges).toEqual([]);
81
+ });
82
+
83
+ it("refuses fuzzy matching for short old_strings", () => {
84
+ expect(locateOldString("const x = 1;\n", "const x = 2", "fuzzy").ranges).toEqual([]);
85
+ });
86
+
87
+ it("handles non-BMP characters without splitting surrogate pairs", () => {
88
+ const raw = "emoji 😀 here\r\nnext";
89
+ const attempt = locateOldString(raw, "😀 here\nnext", "exact");
90
+ expect(slice(raw, attempt.ranges[0])).toBe("😀 here\r\nnext");
91
+ });
92
+ });
93
+
94
+ describe("EOL helpers", () => {
95
+ it("picks the majority line ending and null for single-line text", () => {
96
+ expect(dominantEol("a\r\nb\r\nc\n")).toBe("\r\n");
97
+ expect(dominantEol("a\nb\r\nc\n")).toBe("\n");
98
+ expect(dominantEol("single line")).toBeNull();
99
+ });
100
+
101
+ it("conforms mixed endings without doubling carriage returns", () => {
102
+ expect(conformEol("a\r\nb\nc", "\r\n")).toBe("a\r\nb\r\nc");
103
+ expect(conformEol("a\r\nb\nc", "\n")).toBe("a\nb\nc");
104
+ });
105
+
106
+ it("counts lines up to an offset and clamps past the end", () => {
107
+ expect(lineNumberAt("a\nb\nc", 0)).toBe(1);
108
+ expect(lineNumberAt("a\nb\nc", 4)).toBe(3);
109
+ expect(lineNumberAt("a\nb", 99)).toBe(2);
110
+ });
111
+ });
112
+
113
+ describe("diagnostics", () => {
114
+ it("points at the nearest line and suggests a strategy that would match", () => {
115
+ const raw = "function run() {\n doWork( 1 );\n}\n";
116
+ const diag = buildNotFoundDiagnostic(raw, "doWork( 1 );");
117
+ expect(diag.nearest_match?.line).toBe(2);
118
+ expect(diag.nearest_match?.snippet).toContain("2: doWork( 1 );");
119
+ expect(diag.normalized_hint).toBe('matches uniquely at line 2 if strategy="normalize_whitespace" is used');
120
+ });
121
+
122
+ it("returns no nearest match for whitespace-only old_strings", () => {
123
+ const diag = buildNotFoundDiagnostic("abc\n", " \n ");
124
+ expect(diag.nearest_match).toBeNull();
125
+ expect(diag.normalized_hint).toBeNull();
126
+ });
127
+
128
+ it("reports every occurrence line for multiple matches", () => {
129
+ const raw = "x\nfoo\ny\nfoo\n";
130
+ const { ranges } = locateOldString(raw, "foo", "exact");
131
+ expect(buildMultipleMatchesDiagnostic(raw, ranges)).toEqual({ reason: "multiple_matches", occurrences: [2, 4] });
132
+ });
133
+ });