@circuitwall/jarela 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +4 -4
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  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 +2 -2
  13. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  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/documents/search/route.js +7 -0
  49. package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
  50. package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
  51. package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
  52. package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
  53. package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
  55. package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
  56. package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
  57. package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
  58. package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
  59. package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
  60. package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
  61. package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
  62. package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
  63. package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
  64. package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
  65. package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
  66. package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
  67. package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
  68. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
  69. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
  70. package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
  71. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
  72. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
  73. package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
  74. package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
  75. package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
  76. package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
  77. package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
  78. package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
  79. package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
  80. package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
  81. package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
  82. package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
  83. package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
  84. package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
  85. package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
  86. package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
  87. package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
  88. package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
  89. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +7 -0
  90. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
  91. package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
  92. package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
  93. package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
  94. package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
  95. package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
  96. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
  97. package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
  98. package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
  99. package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
  100. package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
  101. package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
  102. package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
  103. package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
  104. package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
  105. package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
  106. package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
  107. package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
  108. package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
  109. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
  110. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
  111. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +7 -0
  112. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +0 -1
  113. package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
  114. package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
  115. package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
  116. package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
  117. package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
  118. package/.next/standalone/.next/server/app/page.js +322 -275
  119. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/app/setup/page.js +8 -0
  121. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  122. package/.next/standalone/.next/server/app-paths-manifest.json +4 -4
  123. package/.next/standalone/.next/server/chunks/1388.js +106 -36
  124. package/.next/standalone/.next/server/chunks/2068.js +47 -10
  125. package/.next/standalone/.next/server/chunks/210.js +6 -6
  126. package/.next/standalone/.next/server/chunks/2447.js +10 -5
  127. package/.next/standalone/.next/server/chunks/2471.js +17 -17
  128. package/.next/standalone/.next/server/chunks/3042.js +10 -8
  129. package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
  130. package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
  131. package/.next/standalone/.next/server/chunks/3399.js +525 -525
  132. package/.next/standalone/.next/server/chunks/3524.js +150 -67
  133. package/.next/standalone/.next/server/chunks/4189.js +1 -1
  134. package/.next/standalone/.next/server/chunks/4508.js +2 -2
  135. package/.next/standalone/.next/server/chunks/46.js +73 -28
  136. package/.next/standalone/.next/server/chunks/5908.js +63 -42
  137. package/.next/standalone/.next/server/chunks/6142.js +10 -2
  138. package/.next/standalone/.next/server/chunks/6649.js +1 -1
  139. package/.next/standalone/.next/server/chunks/8461.js +10 -8
  140. package/.next/standalone/.next/server/chunks/9032.js +5 -5
  141. package/.next/standalone/.next/server/chunks/9164.js +435 -176
  142. package/.next/standalone/.next/server/chunks/9304.js +51 -12
  143. package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +66 -21
  144. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  145. package/.next/standalone/.next/server/middleware.js +71 -18
  146. package/.next/standalone/.next/server/pages/404.html +2 -2
  147. package/.next/standalone/.next/server/pages/500.html +1 -1
  148. package/.next/standalone/.next/server/preview-props.json +3 -3
  149. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  150. package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-04748847c6ff866f.js} +171 -132
  151. package/.next/standalone/.next/static/css/{312542a5725ba0d5.css → 2669e8744cc8c212.css} +2 -2
  152. package/.next/standalone/docs/adoption/changesets.json +49 -0
  153. package/.next/standalone/package.json +1 -1
  154. package/CHANGELOG.md +19 -0
  155. package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
  156. package/components/chat/MessageBubble.tsx +12 -0
  157. package/components/integrations/NetworkSection.tsx +2 -2
  158. package/lib/agents/citation-checker.test.ts +40 -2
  159. package/lib/agents/citation-checker.ts +56 -11
  160. package/lib/agents/context-boundary.ts +5 -6
  161. package/lib/agents/instruction-edits.test.ts +120 -0
  162. package/lib/agents/instruction-edits.ts +65 -42
  163. package/lib/agents/prepare/request.ts +0 -2
  164. package/lib/agents/prepare/system-prompt.test.ts +10 -0
  165. package/lib/agents/prepare/system-prompt.ts +8 -2
  166. package/lib/agents/proposals.test.ts +76 -0
  167. package/lib/agents/proposals.ts +13 -5
  168. package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
  169. package/lib/agents/run-thread.ts +24 -9
  170. package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
  171. package/lib/agents/warm-summary-background.ts +22 -2
  172. package/lib/agents/warm-topics.test.ts +155 -1
  173. package/lib/agents/warm-topics.ts +27 -7
  174. package/lib/bridges/dispatcher.ts +1 -0
  175. package/lib/bridges/message-role.test.ts +81 -0
  176. package/lib/bridges/message-role.ts +23 -10
  177. package/lib/bridges/types.ts +6 -0
  178. package/lib/bridges/whatsapp-link.test.ts +32 -0
  179. package/lib/bridges/whatsapp-link.ts +21 -0
  180. package/lib/bridges/whatsapp.inbound.test.ts +239 -0
  181. package/lib/bridges/whatsapp.sender.test.ts +80 -1
  182. package/lib/bridges/whatsapp.ts +76 -38
  183. package/lib/pricing/snapshot.test.ts +70 -0
  184. package/lib/pricing/snapshot.ts +10 -2
  185. package/lib/proxy/dispatcher.test.ts +119 -1
  186. package/lib/proxy/dispatcher.ts +22 -9
  187. package/lib/stores/agent-configs.ts +10 -5
  188. package/lib/stores/bridges.test.ts +81 -0
  189. package/lib/stores/bridges.ts +9 -8
  190. package/lib/stores/proxy-config.test.ts +120 -0
  191. package/lib/stores/proxy-config.ts +22 -4
  192. package/lib/stores/threads.ts +12 -8
  193. package/lib/tools/communications/calendar.test.ts +43 -2
  194. package/lib/tools/communications/calendar.ts +2 -0
  195. package/lib/tools/communications/gmail.test.ts +51 -1
  196. package/lib/tools/communications/gmail.ts +44 -2
  197. package/lib/tools/communications/outlook-calendar.ts +2 -0
  198. package/lib/tools/communications/outlook.test.ts +33 -2
  199. package/lib/tools/communications/outlook.ts +2 -0
  200. package/lib/tools/filesystem/edit-match.test.ts +133 -0
  201. package/lib/tools/filesystem/edit-match.ts +7 -2
  202. package/lib/tools/support/result-refs.test.ts +131 -0
  203. package/lib/tools/support/result-refs.ts +41 -9
  204. package/lib/utils/source-link.test.ts +51 -0
  205. package/lib/utils/source-link.ts +45 -0
  206. package/package.json +1 -1
  207. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_buildManifest.js +0 -0
  208. /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → hwujW4hR1b-r3k5WdMxZO}/_ssgManifest.js +0 -0
@@ -304,6 +304,55 @@
304
304
  "required_removed": []
305
305
  }
306
306
  ]
307
+ },
308
+ {
309
+ "version": "2.4.0",
310
+ "previous_version": "2.3.0",
311
+ "date": "2026-10-16",
312
+ "hints": [
313
+ "If an agent's instructions or skills tell it not to include links or URLs in replies, decide whether it should now cite sources with the provided `cite_as` and chat links.",
314
+ "If you listed `localhost` or `127.0.0.1` in the proxy bypass list only to reach local services, you can remove them; loopback is always direct now."
315
+ ],
316
+ "commits": [
317
+ "fix(core): keep proxy loopback direct, unicode fact keys, agent tuning",
318
+ "fix(agents): avoid repeated compaction from bridge rows and topic pins",
319
+ "fix(core): keep per-chat backlog, permalink refs and code fences",
320
+ "fix(core): fix edge cases in edits, paging, bridges and pricing",
321
+ "feat(integrations): add source permalinks to mail, calendar, chats"
322
+ ],
323
+ "prompts": [
324
+ {
325
+ "id": "static.agent.tool-usage-sop",
326
+ "source": "lib/agents/prepare/system-prompt.ts",
327
+ "change": "changed",
328
+ "added_lines": [
329
+ "10. Link the source. When your reply refers to a specific email, calendar event, or chat that a tool result or bridge message gave you, link it: paste the item's cite_as value verbatim when present, otherwise use its web_link, html_link, or chat_link URL as a markdown link. Use only links you were …"
330
+ ],
331
+ "removed_lines": [],
332
+ "omitted_lines": 0
333
+ },
334
+ {
335
+ "id": "system.Delivery channel",
336
+ "source": "lib/agents/prepare/system-prompt.ts",
337
+ "change": "changed",
338
+ "added_lines": [
339
+ "WhatsApp does not render markdown links: write any link as the bare URL, even where a cite_as value is given."
340
+ ],
341
+ "removed_lines": [],
342
+ "omitted_lines": 0
343
+ },
344
+ {
345
+ "id": "system.Tool usage SOP",
346
+ "source": "lib/agents/prepare/system-prompt.ts",
347
+ "change": "changed",
348
+ "added_lines": [
349
+ "10. Link the source. When your reply refers to a specific email, calendar event, or chat that a tool result or bridge message gave you, link it: paste the item's cite_as value verbatim when present, otherwise use its web_link, html_link, or chat_link URL as a markdown link. Use only links you were …"
350
+ ],
351
+ "removed_lines": [],
352
+ "omitted_lines": 0
353
+ }
354
+ ],
355
+ "tools": []
307
356
  }
308
357
  ]
309
358
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "2.3.0",
3
+ "version": "2.4.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",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - **Link answers back to the source: Gmail, Outlook mail and calendar, and Google Calendar results carry a web link and a ready-made `cite_as` markdown link, and WhatsApp 1:1 bridge messages carry a `wa.me` chat link, so agents can point you at the exact email, event or chat. Group chats and single WhatsApp messages have no public link. Replies delivered to WhatsApp use bare URLs.**
15
+
16
+ ### Fixed
17
+
18
+ - **Stop bridge-heavy threads from showing "Compacting" and running an empty compaction on every turn, and stop topic detection from re-running every turn after a hot-turn-limit pass.**
19
+ - **Always send localhost traffic (Ollama, the WhatsApp bridge, Jarela's own API) directly when an HTTP proxy is configured, accept IPv6 proxy hosts, and reject manual proxy hosts that include a scheme, port, path or credentials.**
20
+ - **Replay each WhatsApp chat's offline backlog separately so one active chat no longer causes others to lose messages, and keep known chat names when a contact sends a blank name.**
21
+ - **Keep tuned persona sliders when an approved proposal or tool edit updates an agent, apply `hot_turn_limit` from proposals, reject negative or fractional limits, and drop blank or duplicate tool names.**
22
+ - **Save facts about non-Latin subjects under their own memory keys instead of colliding, and stop replacing a fact from deleting an unrelated one.**
23
+ - **Keep distinct Outlook and Gmail links as separate citations, keep closing code fences and horizontal rules when deduplicating instruction lines, and fix fuzzy edits on CRLF files and paged reads that split multi-byte characters.**
24
+
25
+ ### Adoption
26
+
27
+ - If an agent's instructions or skills tell it not to include links or URLs in replies, decide whether it should now cite sources with the provided `cite_as` and chat links.
28
+ - If you listed `localhost` or `127.0.0.1` in the proxy bypass list only to reach local services, you can remove them; loopback is always direct now.
10
29
  ## [2.3.0] - 2026-10-16
11
30
 
12
31
  ### Added
@@ -189,7 +189,6 @@ export async function POST(req: NextRequest, { params }: Params) {
189
189
  _pinned_model_config_name: pinnedModelConfigName,
190
190
  _onCompacting: () => broadcast(active, { type: "status", data: { phase: "compacting", label: "Compacting earlier messages…" } }),
191
191
  _user_message_persisted: true,
192
- _message_count_before_user: admittedTurn.messageCountBefore,
193
192
  _idle_ms_before_user: admittedTurn.idleMsBefore,
194
193
  });
195
194
  prepDurationMs = Date.now() - prepStartedAt;
@@ -849,6 +849,18 @@ function BridgeMessageCard({ ctx, activity }: {
849
849
  {activity.occurrence_count > 1 ? ` · ${activity.occurrence_count} checks` : ""}
850
850
  </span>
851
851
  ) : null}
852
+ {ctx.chatLink ? (
853
+ <a
854
+ href={ctx.chatLink}
855
+ target="_blank"
856
+ rel="noopener noreferrer"
857
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-white/15 hover:bg-white/25 text-[9.5px] shrink-0"
858
+ title={`Open this chat (${ctx.chatLink})`}
859
+ >
860
+ <ExternalLink size={9} />
861
+ open chat
862
+ </a>
863
+ ) : null}
852
864
  </>
853
865
  );
854
866
  const sections: Array<{ label: string; content: ReactNode; defaultOpen?: boolean; hint?: string }> = [
@@ -243,12 +243,12 @@ export function NetworkSection() {
243
243
  {mode !== "off" && (
244
244
  <>
245
245
  <label className="block text-xs text-fg-subtle">
246
- No proxy (comma-separated, optional)
246
+ No proxy (comma-separated, optional; localhost is always direct)
247
247
  <input
248
248
  type="text"
249
249
  value={noProxy}
250
250
  onChange={(e) => setNoProxy(e.target.value)}
251
- placeholder="localhost,127.0.0.1,.internal"
251
+ placeholder=".internal,corp.example"
252
252
  className="mt-1 w-full px-2 py-1.5 text-sm rounded border border-border bg-surface-3 text-fg font-mono"
253
253
  />
254
254
  </label>
@@ -27,11 +27,27 @@ afterAll(() => {
27
27
  });
28
28
 
29
29
  describe("normalizeSource", () => {
30
- it("lowercases + strips query/fragment + trailing slash on URLs", () => {
31
- expect(normalizeSource("HTTPS://Example.com/Docs/?x=1#a")).toBe("https://example.com/docs");
30
+ it("lowercases + strips tracking params, page anchors and trailing slash on URLs", () => {
31
+ expect(normalizeSource("HTTPS://Example.com/Docs/?utm_source=x&fbclid=y#a")).toBe("https://example.com/docs");
32
32
  expect(normalizeSource("https://a.io/p/")).toBe("https://a.io/p");
33
33
  });
34
34
 
35
+ it("keeps the query and hash route that identify a message permalink", () => {
36
+ const a = normalizeSource("https://outlook.office365.com/owa/?ItemID=AAMk%2BA%3D&exvsurl=1&viewmodel=ReadMessageItem");
37
+ const b = normalizeSource("https://outlook.office365.com/owa/?ItemID=AAMk%2BB%3D&exvsurl=1&viewmodel=ReadMessageItem");
38
+ expect(a).not.toBe(b);
39
+ // Base64 item ids are case-sensitive; folding case would merge distinct messages.
40
+ expect(a).toContain("ItemID=AAMk%2BA%3D");
41
+ expect(normalizeSource("https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3d4e5"))
42
+ .toBe("https://mail.google.com/mail/u/0#all/18c2f0a1b2c3d4e5");
43
+ expect(normalizeSource("https://www.youtube.com/watch?v=dQw4w9WgXcQ&utm_medium=share"))
44
+ .toBe("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
45
+ });
46
+
47
+ it("treats the same permalink with reordered params as one source", () => {
48
+ expect(normalizeSource("https://x.test/r?b=2&a=1")).toBe(normalizeSource("https://x.test/r?a=1&b=2&utm_campaign=z"));
49
+ });
50
+
35
51
  it("normalises backslashes and trailing slashes on paths", () => {
36
52
  expect(normalizeSource("docs\\Adr\\README.md")).toBe("docs/adr/readme.md");
37
53
  expect(normalizeSource("lib/agents/")).toBe("lib/agents");
@@ -137,6 +153,18 @@ describe("parseCitationVerdict", () => {
137
153
  expect(out).toEqual([{ text: "a", marker: null, link: null, verified: false, reason: "no link", impact: "med" }]);
138
154
  });
139
155
 
156
+ it("finds the verdict when the model wraps it in prose that also contains braces", () => {
157
+ const verdict = '{"claims":[{"text":"rate is 5%","marker":1,"verified":true,"reason":"in [1]","impact":"high"}]}';
158
+ const expected = [{ text: "rate is 5%", marker: 1, link: null, verified: true, reason: "in [1]", impact: "high" }];
159
+ expect(parseCitationVerdict(`Checked the {draft} reply. ${verdict}`)).toEqual(expected);
160
+ expect(parseCitationVerdict(`${verdict}\nNote: ignored the {code} block.`)).toEqual(expected);
161
+ });
162
+
163
+ it("keeps braces that belong to a claim string", () => {
164
+ const out = parseCitationVerdict('{"claims":[{"text":"set {a: 1} in config","verified":false,"reason":"} unmatched"}]}');
165
+ expect(out?.[0].text).toBe("set {a: 1} in config");
166
+ expect(out?.[0].reason).toBe("} unmatched");
167
+ });
140
168
  it("returns null on malformed JSON", () => {
141
169
  expect(parseCitationVerdict("{not json")).toBeNull();
142
170
  });
@@ -299,6 +327,16 @@ describe("mergeDeclaredReferences", () => {
299
327
  ]);
300
328
  });
301
329
 
330
+ it("keeps two declared emails that differ only by their permalink id", () => {
331
+ const out = mergeDeclaredReferences([], [
332
+ { label: "Invoice mail", href: "https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3d4e5" },
333
+ { label: "Contract mail", href: "https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3ffff" },
334
+ { label: "Board invite", href: "https://outlook.office365.com/owa/?ItemID=AAMk%2BA%3D&exvsurl=1" },
335
+ { label: "Offsite invite", href: "https://outlook.office365.com/owa/?ItemID=AAMk%2BB%3D&exvsurl=1" },
336
+ ]);
337
+ expect(out.map((e) => e.label)).toEqual(["Invoice mail", "Contract mail", "Board invite", "Offsite invite"]);
338
+ });
339
+
302
340
  it("returns a copy of the manifest unchanged when there are no declared refs", () => {
303
341
  const manifest = [{ n: 1, label: "x", href: "x" }];
304
342
  expect(mergeDeclaredReferences(manifest, [])).toEqual(manifest);
@@ -85,17 +85,28 @@ const SOURCE_PRODUCING_TOOLS_RESULT = new Set([
85
85
  "web_search", "fetch_webpage", "web_fetch",
86
86
  ]);
87
87
 
88
- // Strip query/fragment for URLs and normalize backslashes for paths so a
89
- // citation that drops `?utm=...` or uses forward slashes on Windows still
90
- // matches the visited entry. Also fold `~/...` to the resolved home dir
91
- // so `~/Downloads/x` and `C:\Users\andre\Downloads\x` de-dupe.
88
+ const TRACKING_PARAM_RE = /^(?:utm(?:_.*)?|fbclid|gclid|dclid|msclkid|mc_cid|mc_eid|igshid|ref_src)$/i;
89
+
90
+ // Drop tracking params and in-page anchors for URLs and normalize
91
+ // backslashes for paths so a citation that adds `?utm_source=...` or uses
92
+ // forward slashes on Windows still matches the visited entry. Identifying
93
+ // query params (Outlook `?ItemID=`, `watch?v=`) and hash routes (Gmail
94
+ // `#all/<id>`) are kept verbatim: they are case-sensitive and are the only
95
+ // thing that distinguishes two message permalinks. Also fold `~/...` to the
96
+ // resolved home dir so `~/Downloads/x` and `C:\Users\andre\Downloads\x` de-dupe.
92
97
  export function normalizeSource(raw: string): string {
93
98
  const trimmed = raw.trim();
94
99
  if (!trimmed) return "";
95
100
  if (/^https?:\/\//i.test(trimmed)) {
96
101
  try {
97
102
  const u = new URL(trimmed);
98
- return `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "").toLowerCase();
103
+ const base = `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "").toLowerCase();
104
+ const params = [...u.searchParams]
105
+ .filter(([key]) => !TRACKING_PARAM_RE.test(key))
106
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
107
+ const query = params.length > 0 ? `?${new URLSearchParams(params).toString()}` : "";
108
+ const hashRoute = u.hash.includes("/") ? u.hash : "";
109
+ return base + query + hashRoute;
99
110
  } catch {
100
111
  return trimmed.toLowerCase();
101
112
  }
@@ -436,12 +447,9 @@ ${sent}`;
436
447
  export function parseCitationVerdict(text: string): CitationClaim[] | null {
437
448
  if (!text) return null;
438
449
  const stripped = text.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "").trim();
439
- const m = /\{[^]*"claims"[^]*\}/.exec(stripped);
440
- if (!m) return null;
441
- let parsed: unknown;
442
- try { parsed = JSON.parse(m[0]); } catch { return null; }
443
- if (!parsed || typeof parsed !== "object") return null;
444
- const claimsRaw = (parsed as { claims?: unknown }).claims;
450
+ const parsed = findClaimsObject(stripped);
451
+ if (!parsed) return null;
452
+ const claimsRaw = parsed.claims;
445
453
  if (!Array.isArray(claimsRaw)) return null;
446
454
  const claims: CitationClaim[] = [];
447
455
  for (const item of claimsRaw) {
@@ -464,6 +472,43 @@ export function parseCitationVerdict(text: string): CitationClaim[] | null {
464
472
  return claims;
465
473
  }
466
474
 
475
+ // Models often wrap the verdict in prose that itself contains braces, so a
476
+ // single greedy `{...}` match can start or end in the wrong place. Try each
477
+ // `{` as a start and parse the balanced object it opens (string-aware).
478
+ function findClaimsObject(text: string): { claims?: unknown } | null {
479
+ for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
480
+ const end = balancedObjectEnd(text, start);
481
+ if (end === -1) continue;
482
+ const candidate = text.slice(start, end + 1);
483
+ if (!candidate.includes('"claims"')) continue;
484
+ try {
485
+ const parsed: unknown = JSON.parse(candidate);
486
+ if (parsed && typeof parsed === "object" && "claims" in parsed) return parsed as { claims?: unknown };
487
+ } catch { /* try the next opening brace */ }
488
+ }
489
+ return null;
490
+ }
491
+
492
+ function balancedObjectEnd(text: string, start: number): number {
493
+ let depth = 0;
494
+ let inString = false;
495
+ for (let i = start; i < text.length; i++) {
496
+ const ch = text[i];
497
+ if (inString) {
498
+ if (ch === "\\") i++;
499
+ else if (ch === '"') inString = false;
500
+ } else if (ch === '"') {
501
+ inString = true;
502
+ } else if (ch === "{") {
503
+ depth++;
504
+ } else if (ch === "}") {
505
+ depth--;
506
+ if (depth === 0) return i;
507
+ }
508
+ }
509
+ return -1;
510
+ }
511
+
467
512
  function collectStringsFromPayload(payload: unknown, out: Set<string>): void {
468
513
  if (!payload) return;
469
514
  if (typeof payload === "string") {
@@ -3,12 +3,11 @@ import { kickBoundaryCompaction } from "@/lib/agents/warm-summary-background";
3
3
 
4
4
  // Every caller of moveThreadContextBoundary represents a user-initiated
5
5
  // boundary change (drag the line, the manual /compact command, or the agent's
6
- // compact_context tool acting on the user's say-so) — the
7
- // auto-detector's own commit (lib/agents/warm-summary-background.ts
8
- // commitBoundaryCompaction) calls setThreadContextPin directly instead, so
9
- // it never re-arms this lock. Suppresses the hot-turn-limit compaction in
10
- // lib/agents/run-thread.ts for the next N eligible turns so a manual
11
- // choice doesn't get silently re-moved on the very next turn.
6
+ // compact_context tool acting on the user's say-so). Suppresses the
7
+ // hot-turn-limit compaction in lib/agents/run-thread.ts for the next N
8
+ // eligible turns so a manual choice doesn't get silently re-moved on the very
9
+ // next turn. The auto-detector arms a shorter cooldown of its own
10
+ // (hotTurnCooldownUntil) so it doesn't re-run on every turn either.
12
11
  // Counted in messages (~2 per turn: one user + one assistant row) since
13
12
  // that's what's already on the thread row — no extra query or per-turn
14
13
  // write needed to check it.
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyInstructionEdits } from "./instruction-edits";
3
+
4
+ function edit(base: string, edits: unknown): string {
5
+ const out = applyInstructionEdits(base, edits);
6
+ if (!out.ok) throw new Error(out.error);
7
+ return out.text;
8
+ }
9
+
10
+ describe("applyInstructionEdits validation", () => {
11
+ it("rejects a non-array and reports the failing index", () => {
12
+ expect(applyInstructionEdits("x", { op: "append" })).toEqual({ ok: false, error: "instructions_edits must be an array" });
13
+ expect(applyInstructionEdits("x", [{ op: "append", text: "a" }, null])).toEqual({ ok: false, error: "instructions_edits[1] must be an object" });
14
+ expect(applyInstructionEdits("x", [{}])).toEqual({ ok: false, error: "instructions_edits[0].op is required" });
15
+ expect(applyInstructionEdits("x", [{ op: "rewrite" }])).toEqual({ ok: false, error: "instructions_edits[0].op is not supported" });
16
+ expect(applyInstructionEdits("x", [{ op: "replace", find: "x" }])).toEqual({ ok: false, error: "instructions_edits[0].replace must be a string" });
17
+ });
18
+
19
+ it("fails the whole batch instead of applying a prefix of it", () => {
20
+ const out = applyInstructionEdits("keep", [{ op: "append", text: " more" }, { op: "remove" }]);
21
+ expect(out.ok).toBe(false);
22
+ });
23
+ });
24
+
25
+ describe("append / prepend", () => {
26
+ it("skips an if_missing append when the text is already present, honoring ignore_case", () => {
27
+ const out = applyInstructionEdits("Always cite sources.", [
28
+ { op: "append", text: "always CITE sources.", if_missing: true, ignore_case: true },
29
+ { op: "prepend", text: "Be brief. ", if_missing: true },
30
+ ]);
31
+ expect(out).toEqual({
32
+ ok: true,
33
+ text: "Be brief. Always cite sources.",
34
+ summary: [
35
+ { index: 0, op: "append", applied: false },
36
+ { index: 1, op: "prepend", applied: true },
37
+ ],
38
+ });
39
+ });
40
+ });
41
+
42
+ describe("replace / remove", () => {
43
+ it("treats find text literally, including regex metacharacters and $ patterns", () => {
44
+ expect(edit("cost is $5 (approx.)", [{ op: "replace", find: "$5 (approx.)", replace: "$$ & $&", ignore_case: true }]))
45
+ .toBe("cost is $$ & $&");
46
+ expect(edit("a.b a.b", [{ op: "replace", find: "a.b", replace: "$'", all: true }])).toBe("$' $'");
47
+ });
48
+
49
+ it("replaces only the first match unless all is set, and counts replacements", () => {
50
+ const out = applyInstructionEdits("Foo foo FOO", [
51
+ { op: "replace", find: "foo", replace: "bar", ignore_case: true },
52
+ { op: "remove", text: "FOO", all: true },
53
+ ]);
54
+ expect(out).toMatchObject({ ok: true, text: "bar foo ", summary: [{ replacements: 1 }, { removals: 1 }] });
55
+ });
56
+
57
+ it("is a no-op for an empty find string rather than inserting between every character", () => {
58
+ expect(edit("abc", [{ op: "replace", find: "", replace: "-", all: true }])).toBe("abc");
59
+ });
60
+ });
61
+
62
+ describe("dedupe_lines", () => {
63
+ it("removes repeated rules case-insensitively but keeps blank lines", () => {
64
+ const out = applyInstructionEdits("- Cite sources\n\n- cite sources \n- Be brief\n\n", [{ op: "dedupe_lines" }]);
65
+ expect(out).toMatchObject({ ok: true, text: "- Cite sources\n\n- Be brief\n\n", summary: [{ removed: 1 }] });
66
+ });
67
+
68
+ it("keeps the last occurrence when asked", () => {
69
+ expect(edit("a\nb\nA", [{ op: "dedupe_lines", keep: "last" }])).toBe("b\nA");
70
+ });
71
+
72
+ it("never removes code fence markers, fenced content, or horizontal rules", () => {
73
+ const base = [
74
+ "Rule one",
75
+ "```json",
76
+ "{ \"a\": 1 }",
77
+ "```",
78
+ "---",
79
+ "Example two:",
80
+ "```json",
81
+ "{ \"a\": 1 }",
82
+ "```",
83
+ "---",
84
+ "Rule one",
85
+ ].join("\n");
86
+ const expected = base.split("\n").slice(0, -1).join("\n");
87
+ expect(edit(base, [{ op: "dedupe_lines" }])).toBe(expected);
88
+ });
89
+
90
+ it("does not treat a fence line with an info string as the closing fence", () => {
91
+ const base = "```text\n```json\nx\nx\n```\n";
92
+ expect(edit(base, [{ op: "dedupe_lines" }])).toBe(base);
93
+ });
94
+ it("preserves CRLF line endings", () => {
95
+ expect(edit("a\r\nb\r\na\r\nc", [{ op: "dedupe_lines" }])).toBe("a\r\nb\r\nc");
96
+ });
97
+ });
98
+
99
+ describe("dedupe_paragraphs", () => {
100
+ it("removes a repeated paragraph and leaves the rest of the text byte-identical", () => {
101
+ const base = "# Rules\n\n - indented item\n - second\n\n\nAlways cite.\n\nalways cite.\n";
102
+ expect(edit(base, [{ op: "dedupe_paragraphs" }])).toBe("# Rules\n\n - indented item\n - second\n\n\nAlways cite.\n");
103
+ });
104
+
105
+ it("does not touch whitespace when there is nothing to remove", () => {
106
+ const base = "\n\nIntro\n\n\n\n indented code\n";
107
+ const out = applyInstructionEdits(base, [{ op: "dedupe_paragraphs" }]);
108
+ expect(out).toMatchObject({ ok: true, text: base, summary: [{ removed: 0 }] });
109
+ });
110
+
111
+ it("respects trim: false by treating differently indented paragraphs as distinct", () => {
112
+ const base = "item\n\n item";
113
+ expect(edit(base, [{ op: "dedupe_paragraphs", trim: false }])).toBe(base);
114
+ expect(edit(base, [{ op: "dedupe_paragraphs" }])).toBe("item");
115
+ });
116
+
117
+ it("keeps the last occurrence and drops the separator that belonged to the removed paragraph", () => {
118
+ expect(edit("A\n\nB\n\na", [{ op: "dedupe_paragraphs", keep: "last" }])).toBe("B\n\na");
119
+ });
120
+ });
@@ -172,58 +172,81 @@ function containsText(source: string, needle: string, ignoreCase: boolean): bool
172
172
  return source.toLowerCase().includes(needle.toLowerCase());
173
173
  }
174
174
 
175
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
176
+ const HORIZONTAL_RULE_RE = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
177
+
178
+ /**
179
+ * Drop duplicate segments from `parts`, which alternates content (even
180
+ * indexes) and the original separator that follows it (odd indexes).
181
+ * Separators are preserved verbatim, so only removed segments change the
182
+ * text. `keyOf` returns null for a segment that must always be kept.
183
+ */
184
+ function dedupeSegments(
185
+ parts: readonly string[],
186
+ keyOf: (segmentIndex: number) => string | null,
187
+ keep: "first" | "last",
188
+ ): { text: string; removed: number } {
189
+ const contentIdx = parts.map((_, i) => i).filter((i) => i % 2 === 0);
190
+ const visit = keep === "first" ? contentIdx : [...contentIdx].reverse();
191
+ const seen = new Set<string>();
192
+ const dropped = new Set<number>();
193
+ for (const idx of visit) {
194
+ const key = keyOf(idx);
195
+ if (key === null) continue;
196
+ if (seen.has(key)) dropped.add(idx);
197
+ else seen.add(key);
198
+ }
199
+ if (dropped.size === 0) return { text: parts.join(""), removed: 0 };
200
+ let text = "";
201
+ let emitted = false;
202
+ for (const idx of contentIdx) {
203
+ if (dropped.has(idx)) continue;
204
+ if (emitted && idx > 0) text += parts[idx - 1];
205
+ text += parts[idx];
206
+ emitted = true;
207
+ }
208
+ return { text, removed: dropped.size };
209
+ }
210
+
175
211
  function dedupeLines(
176
212
  source: string,
177
213
  opts: { trim: boolean; ignoreCase: boolean; skipBlank: boolean; keep: "first" | "last" },
178
214
  ): { text: string; removed: number } {
179
- const lines = source.split(/\r?\n/);
180
- const keyed = lines.map((line) => {
181
- const normalized = opts.trim ? line.trim() : line;
182
- return opts.ignoreCase ? normalized.toLowerCase() : normalized;
183
- });
184
-
185
- const keep = new Set<number>();
186
- const visit = opts.keep === "first"
187
- ? [...keyed.keys()]
188
- : [...keyed.keys()].reverse();
189
- const seen = new Set<string>();
190
- for (const idx of visit) {
191
- const key = keyed[idx];
192
- if (opts.skipBlank && key.length === 0) {
193
- keep.add(idx);
194
- continue;
215
+ const parts = source.split(/(\r?\n)/);
216
+ // Fence markers, fenced code and horizontal rules repeat by design; a
217
+ // closing ``` is always a "duplicate" of the opening one.
218
+ const protectedIdx = new Set<number>();
219
+ let fence: string | null = null;
220
+ for (let i = 0; i < parts.length; i += 2) {
221
+ const marker = FENCE_RE.exec(parts[i])?.[1];
222
+ if (fence !== null) {
223
+ protectedIdx.add(i);
224
+ if (marker && marker[0] === fence[0] && marker.length >= fence.length && /^\s*[`~]+\s*$/.test(parts[i])) fence = null;
225
+ } else if (marker) {
226
+ protectedIdx.add(i);
227
+ fence = marker;
228
+ } else if (HORIZONTAL_RULE_RE.test(parts[i])) {
229
+ protectedIdx.add(i);
195
230
  }
196
- if (seen.has(key)) continue;
197
- seen.add(key);
198
- keep.add(idx);
199
231
  }
200
- const next = lines.filter((_, idx) => keep.has(idx));
201
- return { text: next.join("\n"), removed: lines.length - next.length };
232
+ return dedupeSegments(parts, (i) => {
233
+ if (protectedIdx.has(i)) return null;
234
+ const normalized = opts.trim ? parts[i].trim() : parts[i];
235
+ if (opts.skipBlank && normalized.length === 0) return null;
236
+ return opts.ignoreCase ? normalized.toLowerCase() : normalized;
237
+ }, opts.keep);
202
238
  }
203
239
 
204
240
  function dedupeParagraphs(
205
241
  source: string,
206
242
  opts: { trim: boolean; ignoreCase: boolean; keep: "first" | "last" },
207
243
  ): { text: string; removed: number } {
208
- const paragraphs = source
209
- .split(/\n\s*\n/)
210
- .map((p) => p.trim())
211
- .filter((p) => p.length > 0);
212
- const keys = paragraphs.map((p) => {
213
- const base = opts.trim ? p.trim() : p;
244
+ const tail = /\s*$/.exec(source)?.[0] ?? "";
245
+ const parts = source.slice(0, source.length - tail.length).split(/(\r?\n(?:[ \t]*\r?\n)+)/);
246
+ const out = dedupeSegments(parts, (i) => {
247
+ if (parts[i].trim().length === 0) return null;
248
+ const base = opts.trim ? parts[i].trim() : parts[i];
214
249
  return opts.ignoreCase ? base.toLowerCase() : base;
215
- });
216
- const keep = new Set<number>();
217
- const visit = opts.keep === "first"
218
- ? [...keys.keys()]
219
- : [...keys.keys()].reverse();
220
- const seen = new Set<string>();
221
- for (const idx of visit) {
222
- const key = keys[idx];
223
- if (seen.has(key)) continue;
224
- seen.add(key);
225
- keep.add(idx);
226
- }
227
- const next = paragraphs.filter((_, idx) => keep.has(idx));
228
- return { text: next.join("\n\n"), removed: paragraphs.length - next.length };
229
- }
250
+ }, opts.keep);
251
+ return { text: out.text + tail, removed: out.removed };
252
+ }
@@ -38,8 +38,6 @@ export interface ThreadRunRequest {
38
38
  _onCompacting?: () => void;
39
39
  /** Internal — the submission path already committed this user's transcript row. */
40
40
  _user_message_persisted?: boolean;
41
- /** Thread message count captured before an eagerly persisted user turn. */
42
- _message_count_before_user?: number;
43
41
  /** Conversation idle duration captured before an eagerly persisted user turn. */
44
42
  _idle_ms_before_user?: number | null;
45
43
  /**
@@ -78,6 +78,8 @@ describe("buildSystemPrompt delivery channel", () => {
78
78
  // The directive must reassure the agent it has access to the channel —
79
79
  // that's the whole point of this block.
80
80
  expect(prompt).toMatch(/DO have access to WhatsApp/);
81
+ // WhatsApp shows markdown links as raw text.
82
+ expect(prompt).toContain("WhatsApp does not render markdown links: write any link as the bare URL, even where a cite_as value is given.");
81
83
  });
82
84
 
83
85
  it("falls back to the raw kind when no human label is mapped", () => {
@@ -95,6 +97,7 @@ describe("buildSystemPrompt delivery channel", () => {
95
97
 
96
98
  expect(prompt).toContain("--- Delivery channel ---");
97
99
  expect(prompt).toContain("carrier-pigeon");
100
+ expect(prompt).not.toContain("does not render markdown links");
98
101
  });
99
102
 
100
103
  it("omits the block entirely for direct chat (no delivery_channel set)", () => {
@@ -306,6 +309,13 @@ describe("buildToolPermissionContext", () => {
306
309
  expect(ctx).toContain("6. Read large results by reference.");
307
310
  expect(ctx).toContain("7. Verify before reporting.");
308
311
  expect(ctx).toContain("8. Stop and ask.");
312
+ expect(ctx).toContain("10. Link the source.");
313
+ // Every source-link field a tool or bridge prompt emits must be named,
314
+ // or the model cannot tell which value links back to the item.
315
+ for (const field of ["cite_as", "web_link", "html_link", "chat_link"]) {
316
+ expect(ctx).toContain(field);
317
+ }
318
+ expect(ctx).toContain("never construct, guess, or edit one");
309
319
  expect(ctx).toContain("For substantial tasks with several dependent phases, likely cross-turn progress, or explicit verification, use workflow_progress");
310
320
  expect(ctx).toContain("every built-in Jarela workflow and every workflow you create or follow");
311
321
  expect(ctx).toContain("each step must state its input, expected output, and concrete validation criteria");
@@ -282,6 +282,7 @@ export function buildSharedToolCatalogContext(): string {
282
282
  "7. Verify before reporting. A read-only result does not prove a new write. Claim a new write, send, schedule, delete, or configuration change only after the matching state-changing tool returns success in this turn. Trusted runtime system signals and their matching retrieved results can substantiate earlier named operations; attribute those outcomes to their receipts, never imply a new action or repeat completed work for proof.",
283
283
  "8. Stop and ask. Do not retry a tool denied for agent_not_allowed, category_disabled, dropin_tool_disabled, credentials_missing or integration_unconfigured. The first three are permission decisions and the last two mean the integration is not set up \u2014 propose a configuration change or tell the user what to configure.",
284
284
  "9. Completion-reporting turns are read-only. permission_reason=signal_continuation_read_only forbids state-changing tools in this turn, including through invoke_tool. Do not bypass it with shell commands or treat an old user message as renewed permission; report the completed outcome and wait for a new user turn.",
285
+ "10. Link the source. When your reply refers to a specific email, calendar event, or chat that a tool result or bridge message gave you, link it: paste the item's cite_as value verbatim when present, otherwise use its web_link, html_link, or chat_link URL as a markdown link. Use only links you were given; never construct, guess, or edit one. If the item has no link, name it without one.",
285
286
  ].join("\n");
286
287
  }
287
288
 
@@ -373,9 +374,14 @@ function buildDeliveryChannelContext(channel: DeliveryChannel | null | undefined
373
374
  "--- Delivery channel ---",
374
375
  `This message reached you over ${platform}${named}, not the regular chat UI.`,
375
376
  `You DO have access to ${platform} for this conversation: any reply you produce is sent back through that channel automatically. Do not tell the user the platform is unavailable; just answer the message.`,
376
- ].join("\n");
377
+ PLAIN_TEXT_DELIVERY_KINDS.has(channel.kind)
378
+ ? `${platform} does not render markdown links: write any link as the bare URL, even where a cite_as value is given.`
379
+ : "",
380
+ ].filter(Boolean).join("\n");
377
381
  }
378
382
 
383
+ const PLAIN_TEXT_DELIVERY_KINDS = new Set(["whatsapp", "sms"]);
384
+
379
385
  function buildUserContext(): string {
380
386
  const userProfile = getUserProfile();
381
387
  const parts: string[] = [];
@@ -491,7 +497,7 @@ function buildSourceLinkContext(
491
497
  "--- Citation format ---",
492
498
  intro,
493
499
  "PRIMARY (inline links): cite by writing a standard markdown link inline: `[short label](https://example.com/path)` or `[file name](/abs/path.ts)` or `[earlier reply](#msg-<id>)`. Wrap the cited PHRASE itself as the link text when natural — `Jade has [ice skating at 16:00](?thread=…&agent=…)` reads better than a trailing `[1]`.",
494
- "When a tool result contains a `cite_as` field (delegate_to_agent does, and so do some integrations), paste that exact value somewhere in your reply — it's a pre-built markdown link to the source. Anything you summarize from a delegate or from a web/file tool result MUST carry at least one inline link back to where it came from; a summary with zero links reads as if you made the facts up.",
500
+ "When a tool result contains a `cite_as` field (delegate_to_agent does, and so do the email and calendar tools), paste that exact value somewhere in your reply — it's a pre-built markdown link to the source. Anything you summarize from a delegate or from a web/file tool result MUST carry at least one inline link back to where it came from; a summary with zero links reads as if you made the facts up.",
495
501
  "For web_search / fetch_webpage results, the URL is right there in the tool output — use it: `[headline or short label](https://...)`. For file_read results, link the absolute path: `[filename.ext](/abs/path/filename.ext)`.",
496
502
  "FALLBACK (declared refs block): if a source is awkward to cite inline (e.g. you summarized 4 articles in one paragraph), you MAY end your reply with a fenced block exactly like this:",
497
503
  "```jarela-references",