@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
@@ -130,10 +130,13 @@ function fuzzyUniqueRange(raw: string, oldString: string): MatchRange[] {
130
130
  if (oldLines.some((line) => line.length === 0)) return [];
131
131
  const rawLines = raw.split(/\r\n|\r|\n/);
132
132
  const rawLineStarts: number[] = [];
133
+ const eol = /\r\n|\r|\n/g;
133
134
  let offset = 0;
134
135
  for (const line of rawLines) {
135
136
  rawLineStarts.push(offset);
136
- offset += line.length + 1;
137
+ eol.lastIndex = offset + line.length;
138
+ const brk = eol.exec(raw);
139
+ offset = offset + line.length + (brk ? brk[0].length : 0);
137
140
  }
138
141
  const scores: Array<{ score: number; start: number; end: number }> = [];
139
142
  for (let i = 0; i + oldLines.length <= rawLines.length; i++) {
@@ -188,7 +191,9 @@ function findAllExact(haystack: string, needle: string): number[] {
188
191
  function findWithTransforms(raw: string, oldString: string, fns: Array<(s: string) => Transform>): MatchRange[] {
189
192
  const bufT = composeTransforms(raw, fns);
190
193
  const oldT = composeTransforms(oldString, fns);
191
- if (oldT.text.length === 0) return [];
194
+ // A needle that normalizes to bare whitespace would match an arbitrary
195
+ // line break, so it can never identify an edit target.
196
+ if (oldT.text.trim().length === 0) return [];
192
197
  const starts = findAllExact(bufT.text, oldT.text);
193
198
  return starts.map((s) => ({
194
199
  start: bufT.toOriginal[s],
@@ -14,6 +14,7 @@ const {
14
14
  registerDefaultPackages,
15
15
  _resetDefaultPackages,
16
16
  } = await import("./default-packages");
17
+ const { registeredTools, registeredCategory, registeredCapability } = await import("../runtime/registry");
17
18
 
18
19
  const { setPackageDisabled, listDisabledPackages } = await import(
19
20
  "@/lib/stores/disabled-packages"
@@ -52,6 +53,26 @@ describe("default LangChain packages", () => {
52
53
  expect(listDefaultPackages().every((p) => p.installed === false)).toBe(true);
53
54
  });
54
55
 
56
+ it("registers workspace packages with their declared categories and capabilities", () => {
57
+ const previousPackagesDir = process.env.JARELA_PACKAGES_DIR;
58
+ process.env.JARELA_PACKAGES_DIR = "";
59
+ try {
60
+ registerDefaultPackages();
61
+
62
+ expect(listDefaultPackages().every((p) => p.installed)).toBe(true);
63
+ const tools = registeredTools();
64
+ expect(tools.length).toBeGreaterThan(0);
65
+ for (const tool of tools) {
66
+ expect(registeredCategory(tool.name)).toBeDefined();
67
+ expect(["read", "write", "execute"]).toContain(registeredCapability(tool.name));
68
+ }
69
+ } finally {
70
+ _resetDefaultPackages();
71
+ if (previousPackagesDir === undefined) delete process.env.JARELA_PACKAGES_DIR;
72
+ else process.env.JARELA_PACKAGES_DIR = previousPackagesDir;
73
+ }
74
+ });
75
+
55
76
  it("findDefaultPackage returns the descriptor for a known id", () => {
56
77
  const d = findDefaultPackage("github");
57
78
  expect(d).not.toBeNull();
@@ -0,0 +1,72 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { isOptionalPackageInstalled, loadOptionalPackage } from "./optional-package";
6
+
7
+ describe("optional package resolution", () => {
8
+ let managedDir: string;
9
+ let originalPackagesDir: string | undefined;
10
+
11
+ beforeEach(() => {
12
+ originalPackagesDir = process.env.JARELA_PACKAGES_DIR;
13
+ managedDir = mkdtempSync(join(tmpdir(), "jarela-optional-packages-"));
14
+ process.env.JARELA_PACKAGES_DIR = managedDir;
15
+ });
16
+
17
+ afterEach(() => {
18
+ if (originalPackagesDir === undefined) delete process.env.JARELA_PACKAGES_DIR;
19
+ else process.env.JARELA_PACKAGES_DIR = originalPackagesDir;
20
+ rmSync(managedDir, { recursive: true, force: true });
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ it("prefers a package installed in the managed packages directory", () => {
25
+ const packageDir = join(managedDir, "node_modules", "fake-optional-package");
26
+ mkdirSync(packageDir, { recursive: true });
27
+ writeFileSync(join(packageDir, "package.json"), JSON.stringify({
28
+ name: "fake-optional-package",
29
+ version: "1.0.0",
30
+ main: "index.cjs",
31
+ }));
32
+ writeFileSync(join(packageDir, "index.cjs"), 'module.exports = { source: "managed" };');
33
+
34
+ expect(loadOptionalPackage("fake-optional-package")).toEqual({ source: "managed" });
35
+ });
36
+
37
+ it.each([
38
+ "@circuitwall/atlassian-langchain",
39
+ "@circuitwall/github-langchain",
40
+ "@circuitwall/icloud-langchain",
41
+ "@circuitwall/jira-align-langchain",
42
+ "@circuitwall/ms-todo-langchain",
43
+ ])("falls back to the app workspace for %s", (packageName) => {
44
+ delete process.env.JARELA_PACKAGES_DIR;
45
+ expect(loadOptionalPackage(packageName)).not.toBeNull();
46
+ expect(isOptionalPackageInstalled(packageName)).toBe(true);
47
+ });
48
+
49
+ it("exposes exports from the workspace fallback", () => {
50
+ delete process.env.JARELA_PACKAGES_DIR;
51
+ const loaded = loadOptionalPackage<{ githubFetch?: unknown }>("@circuitwall/github-langchain");
52
+
53
+ expect(loaded).not.toBeNull();
54
+ expect(typeof loaded?.githubFetch).toBe("function");
55
+ });
56
+
57
+ it("treats an absent optional package as a normal miss", () => {
58
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
59
+
60
+ expect(loadOptionalPackage("@circuitwall/not-installed")).toBeNull();
61
+ expect(error).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("does not treat unrelated app dependencies as installed optional packages", () => {
65
+ expect(loadOptionalPackage("@langchain/core")).toBeNull();
66
+ });
67
+
68
+ it("honors an explicit managed directory even when a workspace package exists", () => {
69
+ expect(loadOptionalPackage("@circuitwall/github-langchain")).toBeNull();
70
+ expect(isOptionalPackageInstalled("@circuitwall/github-langchain")).toBe(false);
71
+ });
72
+ });
@@ -22,12 +22,40 @@ function managedPackagePath(packageName: string): string {
22
22
  return join(getPackagesDir(), "node_modules", packageName);
23
23
  }
24
24
 
25
+ interface ResolvedOptionalPackage {
26
+ req: OptionalRequire;
27
+ path: string;
28
+ }
29
+
30
+ function resolveOptionalPackage(packageName: string): ResolvedOptionalPackage | null {
31
+ const managedReq = createRuntimeRequire(join(getPackagesDir(), "_anchor"));
32
+ try {
33
+ return {
34
+ req: managedReq,
35
+ path: managedReq.resolve(managedPackagePath(packageName)),
36
+ };
37
+ } catch {
38
+ // Workspace builds expose bundled @circuitwall packages from the app root.
39
+ }
40
+
41
+ if (process.env.JARELA_PACKAGES_DIR?.trim()) return null;
42
+ if (!packageName.startsWith("@circuitwall/")) return null;
43
+
44
+ const appReq = createRuntimeRequire(join(process.cwd(), "package.json"));
45
+ try {
46
+ return { req: appReq, path: appReq.resolve(packageName) };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
25
52
  export function loadOptionalPackage<T extends AnyModule>(packageName: string): T | null {
26
53
  const override = testOverrides.get(packageName);
27
54
  if (override) return override as T;
55
+ const resolved = resolveOptionalPackage(packageName);
56
+ if (!resolved) return null;
28
57
  try {
29
- const req = createRuntimeRequire(join(getPackagesDir(), "_anchor"));
30
- return req(managedPackagePath(packageName)) as T;
58
+ return resolved.req(resolved.path) as T;
31
59
  } catch (err) {
32
60
  // Keep the optional boundary non-fatal, but leave the actual load failure
33
61
  // visible so missing peer dependencies are diagnosable from server logs.
@@ -68,9 +96,10 @@ export function optionalFunction<T extends (...args: any[]) => any>(
68
96
  }
69
97
 
70
98
  export function isOptionalPackageInstalled(packageName: string): boolean {
99
+ const resolved = resolveOptionalPackage(packageName);
100
+ if (!resolved) return false;
71
101
  try {
72
- const req = createRuntimeRequire(join(getPackagesDir(), "_anchor"));
73
- req(managedPackagePath(packageName));
102
+ resolved.req(resolved.path);
74
103
  return true;
75
104
  } catch (err) {
76
105
  console.error(`[optional-package] failed to load ${packageName}: ${err instanceof Error ? err.message : String(err)}`);
@@ -0,0 +1,131 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-result-refs-"));
7
+ process.env.JARELA_DB_DIR = tmpRoot;
8
+
9
+ const {
10
+ getToolResultMaxBytes,
11
+ parseToolResultReferenceEnvelope,
12
+ postProcessToolResult,
13
+ readToolResultRef,
14
+ serializeToolResult,
15
+ } = await import("./result-refs");
16
+
17
+ afterEach(() => vi.unstubAllEnvs());
18
+ afterAll(() => rmSync(tmpRoot, { recursive: true, force: true }));
19
+
20
+ async function spill(result: unknown, maxBytes = 64) {
21
+ vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", String(maxBytes));
22
+ const envelope = parseToolResultReferenceEnvelope(await postProcessToolResult("my tool/x", result));
23
+ if (!envelope) throw new Error("expected a reference envelope");
24
+ return envelope;
25
+ }
26
+
27
+ async function readAll(name: string, limit: number) {
28
+ const pages: string[] = [];
29
+ let offset: number | null = 0;
30
+ for (let guard = 0; offset !== null && guard < 10_000; guard++) {
31
+ const page = await readToolResultRef({ name, offset, limit });
32
+ expect(page.ok).toBe(true);
33
+ pages.push(page.result as string);
34
+ offset = page.next_offset as number | null;
35
+ }
36
+ return pages;
37
+ }
38
+
39
+ describe("max inline bytes", () => {
40
+ it("ignores invalid overrides", () => {
41
+ for (const bad of ["0", "-5", "abc", "Infinity"]) {
42
+ vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", bad);
43
+ expect(getToolResultMaxBytes()).toBe(16 * 1024);
44
+ }
45
+ vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", "100.9");
46
+ expect(getToolResultMaxBytes()).toBe(100);
47
+ });
48
+ });
49
+
50
+ describe("postProcessToolResult", () => {
51
+ it("passes small results through unchanged", async () => {
52
+ vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", "1000");
53
+ expect(await postProcessToolResult("t", { a: 1 })).toBe('{"a":1}');
54
+ expect(serializeToolResult(undefined)).toBe("undefined");
55
+ });
56
+
57
+ it("measures the limit in bytes, not characters", async () => {
58
+ vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", "10");
59
+ // 4 characters, 16 bytes.
60
+ const out = await postProcessToolResult("t", "😀😀😀😀");
61
+ expect(parseToolResultReferenceEnvelope(out)?.bytes).toBe(16);
62
+ });
63
+
64
+ it("sanitizes the tool name and records the total array length", async () => {
65
+ const envelope = await spill(Array.from({ length: 50 }, (_, i) => ({ i })));
66
+ expect(envelope.result_ref.mimeType).toBe("application/json");
67
+ expect(envelope.total_count).toBe(50);
68
+ expect((envelope.preview as unknown[]).length).toBe(20);
69
+ });
70
+
71
+ it("keeps array previews within the preview byte budget when rows are huge", async () => {
72
+ const rows = Array.from({ length: 3 }, (_, i) => ({ i, body: "x".repeat(50_000) }));
73
+ const envelope = await spill(rows);
74
+ expect(envelope.preview_bytes).toBeLessThanOrEqual(4 * 1024);
75
+ expect(typeof envelope.preview).toBe("string");
76
+ expect(Buffer.byteLength(envelope.preview as string)).toBe(envelope.preview_bytes);
77
+ expect(envelope.total_count).toBe(3);
78
+ });
79
+
80
+ it("never ends a text preview on half of a surrogate pair", async () => {
81
+ const envelope = await spill("a" + "😀".repeat(3000));
82
+ const preview = envelope.preview as string;
83
+ expect(Buffer.byteLength(preview)).toBeLessThanOrEqual(4 * 1024);
84
+ expect(preview.isWellFormed()).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe("readToolResultRef", () => {
89
+ it("pages multi-byte text without corrupting characters at page edges", async () => {
90
+ const text = "héllo wörld — 日本語のテキスト 😀🎉 ".repeat(40);
91
+ const envelope = await spill(text);
92
+ for (const limit of [1, 2, 3, 5, 7, 64]) {
93
+ const pages = await readAll(envelope.result_ref.name, limit);
94
+ expect(pages.join("")).toBe(text);
95
+ expect(pages.every((p) => !p.includes("\uFFFD"))).toBe(true);
96
+ }
97
+ });
98
+
99
+ it("realigns a caller offset that lands inside a character", async () => {
100
+ const envelope = await spill("€".repeat(100)); // 3 bytes each
101
+ const page = await readToolResultRef({ name: envelope.result_ref.name, offset: 4, limit: 6 });
102
+ expect(page).toMatchObject({ offset: 3, result: "€€", next_offset: 9 });
103
+ });
104
+
105
+ it("returns an empty final page for offsets past the end", async () => {
106
+ const envelope = await spill("z".repeat(200));
107
+ const page = await readToolResultRef({ name: envelope.result_ref.name, offset: 10_000 });
108
+ expect(page).toMatchObject({ ok: true, result: "", next_offset: null, done: true });
109
+ });
110
+
111
+ it("clamps negative and fractional offsets and limits", async () => {
112
+ const envelope = await spill("abcdefghij".repeat(10));
113
+ const page = await readToolResultRef({ name: envelope.result_ref.name, offset: -3.7, limit: 2.9 });
114
+ expect(page).toMatchObject({ offset: 0, limit: 2, result: "ab", next_offset: 2 });
115
+ });
116
+
117
+ it("rejects path traversal and unknown refs without throwing", async () => {
118
+ expect(await readToolResultRef({ name: "../secrets.json" })).toMatchObject({ ok: false, error: "unsafe result_ref name" });
119
+ expect(await readToolResultRef({ name: "deadbeef.json" })).toMatchObject({ ok: false, status: "unknown" });
120
+ });
121
+ });
122
+
123
+ describe("parseToolResultReferenceEnvelope", () => {
124
+ it("rejects look-alike payloads", () => {
125
+ expect(parseToolResultReferenceEnvelope("not json")).toBeNull();
126
+ expect(parseToolResultReferenceEnvelope({ ok: true, truncated: true, bytes: 1 })).toBeNull();
127
+ expect(parseToolResultReferenceEnvelope({ ok: true, truncated: "yes", bytes: 1, result_ref: { name: "a" } })).toBeNull();
128
+ expect(parseToolResultReferenceEnvelope('{"ok":true,"truncated":true,"bytes":"1","result_ref":{"name":"a"}}')).toBeNull();
129
+ expect(parseToolResultReferenceEnvelope(null)).toBeNull();
130
+ });
131
+ });
@@ -93,23 +93,27 @@ export async function readToolResultRef(args: {
93
93
  }
94
94
  const offset = Math.max(0, Math.floor(args.offset ?? 0));
95
95
  const limit = Math.min(MAX_READ_BYTES, Math.max(1, Math.floor(args.limit ?? DEFAULT_PREVIEW_BYTES)));
96
- let buf: Buffer;
97
96
  try {
98
97
  const file = await fsp.open(abs, "r");
99
98
  try {
100
99
  const stat = await file.stat();
101
- const readLength = Math.max(0, Math.min(limit, stat.size - offset));
102
- buf = Buffer.alloc(readLength);
103
- if (readLength > 0) await file.read(buf, 0, readLength, offset);
104
- const nextOffset = offset + readLength;
100
+ // Read a few bytes either side so the window can be moved onto UTF-8
101
+ // character boundaries; paging must never split a multi-byte character.
102
+ const windowStart = Math.min(Math.max(0, offset - 3), stat.size);
103
+ const windowLength = Math.max(0, Math.min(stat.size - windowStart, offset - windowStart + limit + 3));
104
+ const buf = Buffer.alloc(windowLength);
105
+ if (windowLength > 0) await file.read(buf, 0, windowLength, windowStart);
106
+ const { start, end } = utf8Window(buf, Math.min(offset, stat.size) - windowStart, limit);
107
+ const actualOffset = windowStart + start;
108
+ const nextOffset = windowStart + end;
105
109
  return {
106
110
  ok: true,
107
111
  status: "done",
108
112
  name: args.name,
109
- offset,
113
+ offset: actualOffset,
110
114
  limit,
111
115
  bytes: stat.size,
112
- result: buf.toString("utf8"),
116
+ result: buf.subarray(start, end).toString("utf8"),
113
117
  next_offset: nextOffset < stat.size ? nextOffset : null,
114
118
  done: nextOffset >= stat.size,
115
119
  };
@@ -126,6 +130,25 @@ export async function readToolResultRef(args: {
126
130
  }
127
131
  }
128
132
 
133
+ const isUtf8Continuation = (byte: number | undefined) => byte !== undefined && (byte & 0xc0) === 0x80;
134
+
135
+ // Moves `[start, start + limit)` inside `buf` onto character boundaries: the
136
+ // start backs up to its lead byte, and the end backs up so it does not cut a
137
+ // character — unless that character alone exceeds `limit`, in which case the
138
+ // whole character is returned so paging always makes progress.
139
+ function utf8Window(buf: Buffer, start: number, limit: number): { start: number; end: number } {
140
+ let s = Math.max(0, Math.min(start, buf.length));
141
+ while (s > 0 && isUtf8Continuation(buf[s])) s--;
142
+ let e = Math.min(buf.length, s + limit);
143
+ if (e < buf.length && isUtf8Continuation(buf[e])) {
144
+ let lead = e;
145
+ while (lead > s && isUtf8Continuation(buf[lead])) lead--;
146
+ if (lead > s) e = lead;
147
+ else while (e < buf.length && isUtf8Continuation(buf[e])) e++;
148
+ }
149
+ return { start: s, end: e };
150
+ }
151
+
129
152
  export function serializeToolResult(result: unknown): string {
130
153
  if (typeof result === "string") return result;
131
154
  return JSON.stringify(result) ?? String(result);
@@ -145,8 +168,15 @@ function buildPreview(value: string, mimeType: string): { value: unknown; bytes:
145
168
  try {
146
169
  const parsed = JSON.parse(value) as unknown;
147
170
  if (Array.isArray(parsed)) {
148
- const preview = parsed.slice(0, JSON_ARRAY_PREVIEW_ITEMS);
149
- return { value: preview, bytes: Buffer.byteLength(JSON.stringify(preview)), total_count: parsed.length };
171
+ // Cap by bytes as well as items: a few huge rows must not smuggle the
172
+ // whole result back inline.
173
+ for (let n = Math.min(parsed.length, JSON_ARRAY_PREVIEW_ITEMS); n > 0; n--) {
174
+ const preview = parsed.slice(0, n);
175
+ const bytes = Buffer.byteLength(JSON.stringify(preview));
176
+ if (bytes <= DEFAULT_PREVIEW_BYTES) return { value: preview, bytes, total_count: parsed.length };
177
+ }
178
+ const text = takeUtf8Prefix(value, DEFAULT_PREVIEW_BYTES);
179
+ return { value: text, bytes: Buffer.byteLength(text), total_count: parsed.length };
150
180
  }
151
181
  } catch {
152
182
  // Fall through to byte preview.
@@ -165,6 +195,8 @@ function takeUtf8Prefix(value: string, maxBytes: number): string {
165
195
  if (Buffer.byteLength(value.slice(0, mid)) <= maxBytes) lo = mid;
166
196
  else hi = mid - 1;
167
197
  }
198
+ const code = value.charCodeAt(lo - 1);
199
+ if (lo > 0 && code >= 0xd800 && code <= 0xdbff) lo--;
168
200
  return value.slice(0, lo);
169
201
  }
170
202
 
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseHref } from "./navigate";
3
+
4
+ describe("parseHref legacy Knowledge routes", () => {
5
+ it.each([
6
+ "/?tab=documents",
7
+ "/?tab=memory",
8
+ "/?tab=tools&item=documents",
9
+ "/?tab=tools&item=memory",
10
+ ])("routes %s to Knowledge", (href) => {
11
+ expect(parseHref(href)).toMatchObject({ tab: "memory", item: undefined, external: false });
12
+ });
13
+ });
@@ -40,8 +40,16 @@ export function parseHref(input: string | null | undefined): ParsedHref {
40
40
 
41
41
  const params = new URLSearchParams(queryStr);
42
42
  const tabRaw = params.get("tab");
43
- const tab = TABS.includes(tabRaw as Tab) ? (tabRaw as Tab) : undefined;
44
- const item = params.get("item") ?? undefined;
43
+ let tab = TABS.includes(tabRaw as Tab) ? (tabRaw as Tab) : undefined;
44
+ let item = params.get("item") ?? undefined;
45
+ if (
46
+ tab === "documents" ||
47
+ tab === "memory" ||
48
+ (tab === "tools" && (item === "memory" || item === "documents"))
49
+ ) {
50
+ tab = "memory";
51
+ item = undefined;
52
+ }
45
53
  const thread = params.get("thread") ?? undefined;
46
54
  const agent = params.get("agent") ?? undefined;
47
55
  return { tab, item, thread, agent, hash, external: false };
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { citeAs, httpsUrlOrNull } from "./source-link";
3
+
4
+ describe("httpsUrlOrNull", () => {
5
+ it("accepts absolute https URLs", () => {
6
+ expect(httpsUrlOrNull("https://outlook.office365.com/owa/?ItemID=AAk%3D&exvsurl=1")).toBe(
7
+ "https://outlook.office365.com/owa/?ItemID=AAk%3D&exvsurl=1",
8
+ );
9
+ });
10
+
11
+ it("rejects missing, relative, and non-https URLs", () => {
12
+ for (const url of [null, undefined, "", "/mail/123", "http://example.com", "javascript:alert(1)", "message://%3cid%3e", "not a url"]) {
13
+ expect(httpsUrlOrNull(url)).toBeNull();
14
+ }
15
+ });
16
+ });
17
+
18
+ describe("citeAs", () => {
19
+ it("builds a markdown link from a label and URL", () => {
20
+ expect(citeAs("Quarterly report", "https://www.google.com/calendar/event?eid=abc")).toBe(
21
+ "[Quarterly report](https://www.google.com/calendar/event?eid=abc)",
22
+ );
23
+ });
24
+
25
+ it("returns null without a usable URL", () => {
26
+ expect(citeAs("Subject", null)).toBeNull();
27
+ expect(citeAs("Subject", "http://insecure.example")).toBeNull();
28
+ });
29
+
30
+ it("escapes brackets and flattens whitespace in the label", () => {
31
+ expect(citeAs("Re: [EXT]\n budget\\plan", "https://x.test/a")).toBe("[Re: \\[EXT\\] budget\\\\plan](https://x.test/a)");
32
+ });
33
+
34
+ it("falls back to a default label for an empty subject", () => {
35
+ expect(citeAs(" ", "https://x.test/a", "Outlook message")).toBe("[Outlook message](https://x.test/a)");
36
+ expect(citeAs(null, "https://x.test/a")).toBe("[source](https://x.test/a)");
37
+ });
38
+
39
+ it("caps long labels", () => {
40
+ const link = citeAs("x".repeat(200), "https://x.test/a")!;
41
+ const label = link.slice(1, link.indexOf("]("));
42
+ expect(label.length).toBe(80);
43
+ expect(label.endsWith("…")).toBe(true);
44
+ });
45
+
46
+ it("encodes characters that would end the markdown destination", () => {
47
+ const link = citeAs("Event", "https://x.test/path(1)?q=a b")!;
48
+ expect(link).toBe("[Event](https://x.test/path%281%29?q=a%20b)");
49
+ expect(/^\[[^\]]*\]\([^()\s]+\)$/.test(link)).toBe(true);
50
+ });
51
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Source-link fields for tool results, so an agent can link a reply back to
3
+ * the email, calendar event, or chat it came from without building URLs.
4
+ *
5
+ * `cite_as` is a ready-made markdown link the system prompt tells agents to
6
+ * paste verbatim. Building it in code keeps the label and URL well-formed:
7
+ * a stray `]` in a subject or `)` in a URL would otherwise break the link.
8
+ */
9
+
10
+ const MAX_LABEL_CHARS = 80;
11
+
12
+ /** Returns `url` when it is an absolute https URL, otherwise null. */
13
+ export function httpsUrlOrNull(url: string | null | undefined): string | null {
14
+ if (!url) return null;
15
+ try {
16
+ const parsed = new URL(url);
17
+ return parsed.protocol === "https:" ? parsed.href : null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function markdownLabel(label: string | null | undefined, fallback: string): string {
24
+ const flat = (label ?? "").replace(/\s+/g, " ").trim();
25
+ const capped = flat.length > MAX_LABEL_CHARS ? `${flat.slice(0, MAX_LABEL_CHARS - 1).trimEnd()}…` : flat;
26
+ return (capped || fallback).replace(/[\\[\]]/g, (c) => `\\${c}`);
27
+ }
28
+
29
+ function markdownDestination(url: string): string {
30
+ return url.replace(/[()\s<>]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
31
+ }
32
+
33
+ /**
34
+ * Pre-built markdown link for `cite_as`, or null when there is no usable URL.
35
+ * Only https URLs qualify: the chat renderer drops other schemes.
36
+ */
37
+ export function citeAs(
38
+ label: string | null | undefined,
39
+ url: string | null | undefined,
40
+ fallbackLabel = "source",
41
+ ): string | null {
42
+ const safe = httpsUrlOrNull(url);
43
+ if (!safe) return null;
44
+ return `[${markdownLabel(label, fallbackLabel)}](${markdownDestination(safe)})`;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "Jarela — local chat interface for LangGraph agents (multi-provider, single-process, SQLite-backed).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Andrew Ge Wu",