@desplega.ai/agent-swarm 2.7.0 → 2.7.1

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 (236) hide show
  1. package/dist/{acp-adapter-ma1js5n7.js → acp-adapter-8dn5tq56.js} +6 -6
  2. package/dist/{actions-1vhmtvg9.js → actions-3r6gw7dy.js} +18 -18
  3. package/dist/{amp-adapter-sxqqvmdk.js → amp-adapter-wjtq413p.js} +3 -3
  4. package/dist/{anthropic-messages-txs190dd.js → anthropic-messages-n4cby1rz.js} +4 -4
  5. package/dist/{app-mwx1d5a9.js → app-d53yhykk.js} +3 -3
  6. package/dist/{assistant-yx1p6g82.js → assistant-ksrawj3k.js} +20 -20
  7. package/dist/{boot-reembed-7h78vmvv.js → boot-reembed-fxfw17m2.js} +10 -3
  8. package/dist/{boot-reembed-5h37a2c2.js → boot-reembed-k5y5xbj0.js} +15 -8
  9. package/dist/{boot-scrub-sweep-8mawd4gw.js → boot-scrub-sweep-se41j9fd.js} +3 -3
  10. package/dist/{boot-seeds-ca84dhnn.js → boot-seeds-r3kwwv8b.js} +4 -4
  11. package/dist/{claude-adapter-wqt4hfkq.js → claude-adapter-ahmyf231.js} +7 -7
  12. package/dist/{claude-managed-adapter-6fjqtr2z.js → claude-managed-adapter-5knamegc.js} +3 -3
  13. package/dist/{claude-managed-setup-rqv98sea.js → claude-managed-setup-mdc7dy6b.js} +2 -2
  14. package/dist/{claude-sdk-session-f29f3b2x.js → claude-sdk-session-n5wen9zk.js} +8728 -8599
  15. package/dist/{cli-m5e802r7.js → cli-0dqtv2jg.js} +2 -2
  16. package/dist/{cli-e1595bgx.js → cli-0tww7dnz.js} +2 -2
  17. package/dist/{cli-2y7v133v.js → cli-0w4e4v58.js} +7 -7
  18. package/dist/{cli-d20qvpr2.js → cli-2sjm3t4r.js} +1 -1
  19. package/dist/{cli-gf2850q7.js → cli-3cywb44q.js} +18 -1
  20. package/dist/{cli-8wkb09q5.js → cli-3k9jej0c.js} +2 -2
  21. package/dist/{cli-twctnkng.js → cli-3v2cyqb8.js} +3 -3
  22. package/dist/{cli-he9gbdx0.js → cli-5qt9vzmq.js} +39 -20
  23. package/dist/{cli-a6ds2epe.js → cli-5rp1w4cr.js} +1 -1
  24. package/dist/{cli-6srv653c.js → cli-6vwjsetx.js} +4 -4
  25. package/dist/{cli-1hhbzkbb.js → cli-6w2yrrc0.js} +8 -8
  26. package/dist/{cli-f12mr20z.js → cli-75tdydgv.js} +1 -1
  27. package/dist/{cli-j3y0x2n4.js → cli-8feswf2v.js} +43 -33
  28. package/dist/{cli-07q03xm2.js → cli-8wd3mbyj.js} +1 -1
  29. package/dist/{cli-ymjpkxgn.js → cli-9csfthbw.js} +8 -6
  30. package/dist/{cli-f1e0ptjj.js → cli-b41p764a.js} +21 -2
  31. package/dist/{cli-kv7sbc2c.js → cli-c1t533hh.js} +3 -3
  32. package/dist/{cli-54sj64fc.js → cli-c28jxpcv.js} +1 -1
  33. package/dist/{cli-8mbc9za9.js → cli-cb50g9cd.js} +1 -1
  34. package/dist/{cli-c668vvcf.js → cli-d8gaphtr.js} +1 -1
  35. package/dist/{cli-86ehn4s5.js → cli-dgtfysne.js} +2 -2
  36. package/dist/{cli-sjctjr48.js → cli-dscnhn18.js} +1 -1
  37. package/dist/{cli-j0ppywdr.js → cli-dv11pgpv.js} +12 -12
  38. package/dist/{cli-1m9x8dr5.js → cli-ekptgk2m.js} +5 -5
  39. package/dist/{cli-9gsm2rzp.js → cli-f3mzy662.js} +17 -17
  40. package/dist/{cli-s0mgx214.js → cli-fk7k8s9x.js} +2 -2
  41. package/dist/{cli-vbhgkw9h.js → cli-h3e7bhvn.js} +3 -3
  42. package/dist/{cli-yd2nreqw.js → cli-hkxqdmnb.js} +2 -2
  43. package/dist/{cli-8wmj18cz.js → cli-hpkvgdad.js} +7 -4
  44. package/dist/{cli-wqr7cea4.js → cli-hq2v4rr5.js} +37 -9
  45. package/dist/{cli-sgqm2582.js → cli-k1zb7raw.js} +5 -5
  46. package/dist/{cli-4rvr7zfx.js → cli-k4awgvzm.js} +5 -0
  47. package/dist/{cli-ay40dnj3.js → cli-k7d3hvya.js} +2 -2
  48. package/dist/{cli-j9bpmykh.js → cli-kbtdftbw.js} +5 -5
  49. package/dist/{cli-x37r7qwf.js → cli-kj69b9tw.js} +25 -5
  50. package/dist/{cli-gsfk58nw.js → cli-kk9jy4nm.js} +1 -1
  51. package/dist/{cli-39aet655.js → cli-m52t4cta.js} +1 -1
  52. package/dist/{cli-h5jeg46p.js → cli-mdadewqp.js} +5 -5
  53. package/dist/{cli-a89zdqz9.js → cli-nf6k60sn.js} +1 -1
  54. package/dist/{cli-v6shg7na.js → cli-nzdf09js.js} +1 -1
  55. package/dist/{cli-rr7zx19j.js → cli-prcyzecg.js} +12 -12
  56. package/dist/{cli-6a9qfa8b.js → cli-pzafyqtk.js} +118 -48
  57. package/dist/{cli-48gxjmcz.js → cli-qvaa1gjp.js} +87 -24
  58. package/dist/{cli-qj442fws.js → cli-rg4b2g6j.js} +1 -1
  59. package/dist/{cli-aypd09nf.js → cli-rm66vqdz.js} +1 -1
  60. package/dist/{cli-9pshx6rd.js → cli-s504c3w7.js} +1 -1
  61. package/dist/{cli-ptfq202x.js → cli-swkx9jm2.js} +3 -3
  62. package/dist/{cli-m30k9777.js → cli-thsaq4pd.js} +16 -16
  63. package/dist/{cli-60sty27p.js → cli-twjnyvs8.js} +2 -2
  64. package/dist/{cli-rd5m70gj.js → cli-typdpe8g.js} +4 -4
  65. package/dist/{cli-yxbsmx56.js → cli-vg8pwmsd.js} +6 -6
  66. package/dist/{cli-s09j9vp5.js → cli-vhpckzvp.js} +1 -1
  67. package/dist/{cli-dv97kdzv.js → cli-wdxgsdz5.js} +2 -2
  68. package/dist/{cli-7a6p4e65.js → cli-wtjg6je3.js} +1 -1
  69. package/dist/{cli-b3yzyc57.js → cli-x3cngb1h.js} +44 -12
  70. package/dist/{cli-6mgdba42.js → cli-xbz6mcg4.js} +2 -2
  71. package/dist/{cli-2pvzmas1.js → cli-y4tdxsqb.js} +3 -3
  72. package/dist/{cli-f4dbh7a5.js → cli-yqrsp3ny.js} +7 -7
  73. package/dist/cli.js +27 -19
  74. package/dist/{codex-adapter-zk59x2zh.js → codex-adapter-7wepzq15.js} +5 -5
  75. package/dist/{codex-hook-xyjhvxwd.js → codex-hook-pbdh4xhx.js} +2 -2
  76. package/dist/{codex-session-runner-kh6s4j3n.js → codex-session-runner-02sb49e4.js} +5 -5
  77. package/dist/{commands-68et1qv2.js → commands-3rcspxct.js} +2 -2
  78. package/dist/{controller-fyrbnzg4.js → controller-8ar4avd6.js} +3 -3
  79. package/dist/{controllers-connect-nep9h2xv.js → controllers-connect-ajvh9h0v.js} +19 -19
  80. package/dist/{cursor-adapter-yss3pg7t.js → cursor-adapter-gd0sa7bh.js} +1 -1
  81. package/dist/{db-28gjyvxj.js → db-dv9j7ts5.js} +2 -2
  82. package/dist/{db-8ftwvzba.js → db-z26vgdvj.js} +3 -3
  83. package/dist/{dispatcher-hayexmkr.js → dispatcher-8m0htjq8.js} +17 -17
  84. package/dist/{dsh-adapter-46wawff9.js → dsh-adapter-y8gyk130.js} +1 -1
  85. package/dist/{e2b-fn3zacvt.js → e2b-74vg9b7e.js} +1 -1
  86. package/dist/{e2b-222hsajc.js → e2b-jyay8wzn.js} +1 -1
  87. package/dist/{easter-egg-3d-8d1e0h4m.js → easter-egg-3d-28f880xx.js} +1 -1
  88. package/dist/{execute-cjrfxfnm.js → execute-9seb79vh.js} +1 -1
  89. package/dist/{external-channel-xbmxcg09.js → external-channel-smz7jyth.js} +2 -2
  90. package/dist/{getMachineId-bsd-brn0xym3.js → getMachineId-bsd-whfre6gr.js} +3 -3
  91. package/dist/{getMachineId-darwin-t339sfxm.js → getMachineId-darwin-5zjynb19.js} +3 -3
  92. package/dist/{getMachineId-linux-5eq95d8b.js → getMachineId-linux-8mksrrsq.js} +3 -3
  93. package/dist/{getMachineId-unsupported-qavrtc7j.js → getMachineId-unsupported-fxtqsdt2.js} +3 -3
  94. package/dist/{getMachineId-win-q7fe1wx7.js → getMachineId-win-nzfmz857.js} +3 -3
  95. package/dist/{grok-adapter-ag4wks2a.js → grok-adapter-z0nnvtbn.js} +7 -7
  96. package/dist/handlers-pspryh6g.js +41 -0
  97. package/dist/{heartbeat-psfvcgwp.js → heartbeat-f928aqyp.js} +20 -20
  98. package/dist/{hook-cyq4mryn.js → hook-d7c605r4.js} +7 -7
  99. package/dist/{hook-8pz04697.js → hook-q9hv5xf9.js} +5 -5
  100. package/dist/{http-haj1nd6k.js → http-q6j4k989.js} +934 -180
  101. package/dist/{index-z5h22pes.js → index-5nb266jr.js} +17 -17
  102. package/dist/{index-ysd45kgc.js → index-8fxt5eah.js} +10 -10
  103. package/dist/{index-zgvqzjsw.js → index-cekzwhez.js} +5 -5
  104. package/dist/{index-zyq8ct52.js → index-ch0c1xff.js} +17 -17
  105. package/dist/{index-syvhnvtw.js → index-ebnjhxh1.js} +1 -1
  106. package/dist/{index-y1ajx04a.js → index-ed8dy8rn.js} +20 -20
  107. package/dist/{index-jwt2bj6f.js → index-ejqdkkqx.js} +11 -10
  108. package/dist/{index-dr94zbnh.js → index-farm1zsn.js} +32661 -29443
  109. package/dist/{index-q52f47pe.js → index-yn1yxg85.js} +1 -1
  110. package/dist/{keepalive-xdvsamvj.js → keepalive-zzc3hks1.js} +3 -3
  111. package/dist/{lead-hqg57kdp.js → lead-c0pjcaw4.js} +36 -36
  112. package/dist/{maintenance-4y2xzh3a.js → maintenance-kqsxzpzx.js} +1 -1
  113. package/dist/{oauth-refresh-sweep-jd4r7zbs.js → oauth-refresh-sweep-7qt0g55z.js} +2 -2
  114. package/dist/{onboard-18447q9q.js → onboard-k7x13fqg.js} +118 -5
  115. package/dist/{opencode-adapter-26b2frdf.js → opencode-adapter-tqvrtter.js} +5 -5
  116. package/dist/{otel-impl-f9tgr8yw.js → otel-impl-sqkf6k3b.js} +97 -97
  117. package/dist/{pi-mono-adapter-qn6qbga1.js → pi-mono-adapter-m2qnafxm.js} +63 -9
  118. package/dist/{pricing-refresh-dw2tgb53.js → pricing-refresh-wkpwhgvs.js} +3 -3
  119. package/dist/{rbac-roles-s4nwj2m3.js → rbac-roles-dpn0c0wj.js} +7 -4
  120. package/dist/{rbac-roles-aygm2760.js → rbac-roles-wk7xd6a6.js} +2 -2
  121. package/dist/{render-v2-yfvdfr0x.js → render-v2-ex2m4g2k.js} +3 -3
  122. package/dist/{resume-1bmvjbf3.js → resume-725g9b0b.js} +7 -7
  123. package/dist/{runtime-f7tpzedy.js → runtime-kajas15g.js} +1 -1
  124. package/dist/{session-access-r5garej6.js → session-access-0j2ezjje.js} +8 -4
  125. package/dist/{session-source-backfill-424wzv0r.js → session-source-backfill-62202awe.js} +2 -2
  126. package/dist/{setup-8tygvxkq.js → setup-9sj11ax6.js} +1 -1
  127. package/dist/{virtual-modules-xsw323b3.js → virtual-modules-c6fcq6zr.js} +2 -2
  128. package/dist/{work-objects-ggnghpgf.js → work-objects-0y07495y.js} +20 -20
  129. package/dist/{worker-sqjd1nqf.js → worker-d4q9sqbc.js} +36 -36
  130. package/openapi.json +1070 -9
  131. package/package.json +3 -3
  132. package/src/be/connector-codes.ts +65 -22
  133. package/src/be/memory/boot-reembed.ts +4 -1
  134. package/src/be/memory/constants.ts +6 -0
  135. package/src/be/memory/embedding-recovery.ts +48 -0
  136. package/src/be/memory/index-content.ts +5 -2
  137. package/src/be/memory/providers/openai-embedding.ts +65 -14
  138. package/src/be/memory/task-learning.ts +110 -8
  139. package/src/be/memory/types.ts +13 -2
  140. package/src/be/migrations/246_connector_code_kind.sql +11 -0
  141. package/src/be/onboarding.ts +125 -15
  142. package/src/be/rbac-roles.ts +4 -1
  143. package/src/be/scripts/embeddings.ts +2 -2
  144. package/src/be/seed/agent-fs-provision.ts +75 -14
  145. package/src/be/swarm-config-guard.ts +3 -0
  146. package/src/cli.tsx +9 -0
  147. package/src/comb/config.ts +6 -1
  148. package/src/commands/onboard/agent-fs-version.ts +5 -0
  149. package/src/commands/onboard/compose-generator.ts +112 -1
  150. package/src/commands/onboard/env-generator.ts +9 -0
  151. package/src/commands/onboard/tei-image.ts +12 -0
  152. package/src/commands/onboard/types.ts +7 -0
  153. package/src/commands/onboard.tsx +11 -1
  154. package/src/commands/runner.ts +14 -1
  155. package/src/http/agentfs-proxy.ts +369 -0
  156. package/src/http/all-routes.ts +1 -0
  157. package/src/http/codex-oauth-device.ts +8 -1
  158. package/src/http/fs.ts +64 -10
  159. package/src/http/inbox-state.ts +27 -1
  160. package/src/http/index.ts +4 -1
  161. package/src/http/memory.ts +7 -4
  162. package/src/http/onboarding.ts +139 -11
  163. package/src/http/session-access.ts +24 -0
  164. package/src/http/sessions.ts +10 -6
  165. package/src/http/tasks.ts +85 -11
  166. package/src/http/telemetry-ui.ts +1 -1
  167. package/src/http/user-roles.ts +4 -1
  168. package/src/http/users.ts +272 -3
  169. package/src/http/utils.ts +22 -3
  170. package/src/prompts/base-prompt.ts +24 -9
  171. package/src/prompts/session-templates.ts +26 -2
  172. package/src/providers/amp-adapter.ts +1 -1
  173. package/src/providers/codex-adapter.ts +6 -1
  174. package/src/providers/cursor-adapter.ts +1 -1
  175. package/src/providers/pi-mono-adapter.ts +58 -3
  176. package/src/rbac/legacy-policy.ts +1 -0
  177. package/src/rbac/permissions.ts +4 -0
  178. package/src/tests/agent-fs-provision-seeder.test.ts +261 -92
  179. package/src/tests/agentfs-proxy.test.ts +1106 -0
  180. package/src/tests/base-prompt.test.ts +69 -2
  181. package/src/tests/codex-adapter.test.ts +46 -8
  182. package/src/tests/codex-oauth-device.test.ts +12 -1
  183. package/src/tests/comb-config-guard.test.ts +20 -0
  184. package/src/tests/comb-links.test.ts +79 -1
  185. package/src/tests/comb-status.test.ts +44 -0
  186. package/src/tests/docs-index-footprint.test.js +3 -3
  187. package/src/tests/fixtures/onboard/baseline-states.ts +68 -0
  188. package/src/tests/fixtures/onboard/content.compose.yml.txt +190 -0
  189. package/src/tests/fixtures/onboard/content.env.txt +29 -0
  190. package/src/tests/fixtures/onboard/dev.compose.yml.txt +155 -0
  191. package/src/tests/fixtures/onboard/dev.env.txt +28 -0
  192. package/src/tests/fixtures/onboard/full-all.compose.yml.txt +582 -0
  193. package/src/tests/fixtures/onboard/full-all.env.txt +59 -0
  194. package/src/tests/fixtures/onboard/full.compose.yml.txt +435 -0
  195. package/src/tests/fixtures/onboard/full.env.txt +36 -0
  196. package/src/tests/fixtures/onboard/research.compose.yml.txt +155 -0
  197. package/src/tests/fixtures/onboard/research.env.txt +28 -0
  198. package/src/tests/fixtures/onboard/solo.compose.yml.txt +85 -0
  199. package/src/tests/fixtures/onboard/solo.env.txt +26 -0
  200. package/src/tests/http-log-scrubbing.test.ts +34 -1
  201. package/src/tests/http-task-write-ownership.test.ts +5 -1
  202. package/src/tests/invites-http.test.ts +464 -0
  203. package/src/tests/memory-embedding-input-cap.test.ts +122 -1
  204. package/src/tests/memory-embedding-recovery.test.ts +137 -0
  205. package/src/tests/onboard-compose.test.ts +142 -0
  206. package/src/tests/onboard-env.test.ts +24 -0
  207. package/src/tests/onboard-non-interactive.test.ts +29 -0
  208. package/src/tests/onboard-tei-image.test.ts +36 -0
  209. package/src/tests/onboarding.test.ts +451 -4
  210. package/src/tests/openai-embedding-provider.test.ts +105 -2
  211. package/src/tests/pi-mono-session-cost-on-abort.test.ts +270 -0
  212. package/src/tests/preload-env.test.ts +4 -0
  213. package/src/tests/preload.ts +4 -0
  214. package/src/tests/prompt-template-session.test.ts +19 -3
  215. package/src/tests/rbac-admission-e2e.test.ts +22 -6
  216. package/src/tests/rbac-admission.test.ts +342 -4
  217. package/src/tests/rbac-engine.test.ts +1 -0
  218. package/src/tests/rbac-roles.test.ts +9 -1
  219. package/src/tests/send-task-requester-gate.test.ts +64 -3
  220. package/src/tests/sessions-move.test.ts +39 -1
  221. package/src/tests/store-progress-learning.test.ts +95 -2
  222. package/src/tests/whoami-route.test.ts +41 -0
  223. package/src/tools/inject-learning.ts +1 -1
  224. package/src/tools/memory-edit.ts +1 -1
  225. package/src/tools/memory-search.ts +1 -1
  226. package/src/tools/store-progress.ts +5 -1
  227. package/src/tools/utils.ts +12 -1
  228. package/src/utils/constants.ts +39 -0
  229. package/src/utils/task-attachment-links.ts +15 -3
  230. package/templates/skills/swarm-docs/content.md +1 -1
  231. package/dist/handlers-tzmevqb5.js +0 -41
  232. package/dist/{azure-openai-responses-9kfwhmn7.js → azure-openai-responses-43asa23s.js} +0 -0
  233. package/dist/{node-vxx896fa.js → node-t28cc56b.js} +0 -0
  234. package/dist/{openai-codex-responses-79s8xnbh.js → openai-codex-responses-jhr3sjtm.js} +3 -3
  235. package/dist/{openai-completions-tdbyts0x.js → openai-completions-gs6yn37k.js} +3 -3
  236. /package/dist/{openai-responses-dm3t5vqp.js → openai-responses-jqm9vwwk.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "agent-swarm.dev — an open-source operating system for AI work: a lead agent breaks goals into tasks, routes them to specialized workers such as Claude Code or Codex, runs each worker in an isolated container, and preserves shared memory, tools, schedules, and review gates so delegated work compounds across sessions.",
5
5
  "keywords": [
6
6
  "agent-swarm.dev",
@@ -203,7 +203,7 @@
203
203
  "dependencies": {
204
204
  "@agentclientprotocol/sdk": "2.0.2",
205
205
  "@ai-sdk/openai": "^3.0.41",
206
- "@anthropic-ai/claude-agent-sdk": "0.3.310",
206
+ "@anthropic-ai/claude-agent-sdk": "0.3.311",
207
207
  "@anthropic-ai/sdk": "^1.3.0",
208
208
  "@asteasolutions/zod-to-openapi": "^8.0.0",
209
209
  "@aws-sdk/client-bedrock": "3.1048.0",
@@ -211,7 +211,7 @@
211
211
  "@chat-adapter/slack": "4.41.1",
212
212
  "@chat-adapter/teams": "4.41.1",
213
213
  "@chat-adapter/telegram": "4.41.1",
214
- "@cursor/sdk": "1.0.40",
214
+ "@cursor/sdk": "1.1.0",
215
215
  "@desplega.ai/agent-fs-just-bash": "0.18.9",
216
216
  "@desplega.ai/business-use": "^0.4.2",
217
217
  "@earendil-works/pi-agent-core": "^1.4.0",
@@ -1,21 +1,34 @@
1
1
  /**
2
- * Single-use connect codes for the agent-swarm.dev ChatGPT connector.
2
+ * Single-use codes that a browser or connector trades once for an `aswt_` token.
3
3
  *
4
- * The dashboard asks for a code (`POST /api/users/:id/connector-codes`) and
5
- * opens the connector's connect page with it. The connector trades the code
6
- * once (`POST /api/connector/exchange`) for a user-bound `aswt_` token. The
7
- * token is minted only at exchange time, through the regular `mintToken`
8
- * path, so an unused code never leaves a token behind.
4
+ * Two kinds share the `connector_codes` table:
5
+ *
6
+ * - `connector`: the agent-swarm.dev ChatGPT connector. The dashboard asks for
7
+ * a code (`POST /api/users/:id/connector-codes`) and opens the connector's
8
+ * connect page with it. The connector trades the code once
9
+ * (`POST /api/connector/exchange`).
10
+ * - `invite`: a teammate invite (`POST /api/invites`). The teammate's browser
11
+ * trades it once (`POST /api/invites/exchange`). The exchange also flips the
12
+ * user from `invited` to `active`, because `aswt_` auth admits only active
13
+ * users.
14
+ *
15
+ * The token is minted only at exchange time, through the regular `mintToken`
16
+ * path, so an unused code never leaves a token behind. Each exchange redeems
17
+ * only its own kind.
9
18
  */
10
19
 
11
20
  import { createHash, randomBytes } from "node:crypto";
12
21
  import { getDbClient } from "./db";
13
- import { type IdentityActor, mintToken } from "./users";
22
+ import { type IdentityActor, mintToken, recordIdentityEvent } from "./users";
23
+
24
+ export type ConnectorCodeKind = "connector" | "invite";
14
25
 
15
26
  export const CONNECTOR_CODE_TTL_MS = 10 * 60 * 1000;
27
+ export const INVITE_CODE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
16
28
  /** Expired rows stay this long before the heartbeat sweep deletes them. */
17
29
  const CONNECTOR_CODE_RETENTION_MS = 60 * 60 * 1000;
18
30
  export const DEFAULT_CONNECTOR_CODE_LABEL = "ChatGPT connector";
31
+ export const DEFAULT_INVITE_CODE_LABEL = "Dashboard invite";
19
32
 
20
33
  function sha256Hex(input: string): string {
21
34
  return createHash("sha256").update(input).digest("hex");
@@ -27,27 +40,34 @@ export async function createConnectorCode(
27
40
  label: string,
28
41
  actor: IdentityActor,
29
42
  now = new Date(),
43
+ opts: { kind?: ConnectorCodeKind; ttlMs?: number } = {},
30
44
  ): Promise<{ code: string; expiresAt: string }> {
45
+ const kind = opts.kind ?? "connector";
46
+ const ttlMs = opts.ttlMs ?? CONNECTOR_CODE_TTL_MS;
31
47
  const code = randomBytes(32).toString("base64url");
32
- const expiresAt = new Date(now.getTime() + CONNECTOR_CODE_TTL_MS).toISOString();
48
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
33
49
  await getDbClient().run(
34
50
  `INSERT INTO connector_codes
35
- (code_hash, user_id, label, actor_kind, actor_id, created_at, expires_at)
36
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
37
- [sha256Hex(code), userId, label, actor.kind, actor.id, now.toISOString(), expiresAt],
51
+ (code_hash, user_id, label, actor_kind, actor_id, created_at, expires_at, kind)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
53
+ [sha256Hex(code), userId, label, actor.kind, actor.id, now.toISOString(), expiresAt, kind],
38
54
  );
39
55
  return { code, expiresAt };
40
56
  }
41
57
 
42
58
  /**
43
59
  * Consume a code and mint its token in one transaction. Returns null when the
44
- * code is unknown, expired, or already used (callers must not tell these apart).
60
+ * code is unknown, expired, already used, or of another kind (callers must not
61
+ * tell these apart). For an invite code, also returns null when the user is
62
+ * no longer `invited` (suspended or merged since): the code is spent and no
63
+ * token is minted.
45
64
  */
46
65
  export async function exchangeConnectorCode(
47
66
  code: string,
48
- now = new Date(),
67
+ opts: { kind?: ConnectorCodeKind; now?: Date } = {},
49
68
  ): Promise<{ token: string; userId: string } | null> {
50
- const nowIso = now.toISOString();
69
+ const kind = opts.kind ?? "connector";
70
+ const nowIso = (opts.now ?? new Date()).toISOString();
51
71
  return getDbClient().transaction(async (tx) => {
52
72
  const row = await tx.get<{
53
73
  user_id: string;
@@ -56,21 +76,44 @@ export async function exchangeConnectorCode(
56
76
  actor_id: string;
57
77
  }>(
58
78
  `UPDATE connector_codes SET used_at = ?
59
- WHERE code_hash = ? AND used_at IS NULL AND expires_at > ?
79
+ WHERE code_hash = ? AND used_at IS NULL AND expires_at > ? AND kind = ?
60
80
  RETURNING user_id, label, actor_kind, actor_id`,
61
- [nowIso, sha256Hex(code), nowIso],
81
+ [nowIso, sha256Hex(code), nowIso, kind],
62
82
  );
63
83
  if (!row) return null;
64
- const { plaintext } = await mintToken(
65
- row.user_id,
66
- row.label,
67
- { kind: row.actor_kind, id: row.actor_id },
68
- { source: "connector_exchange" },
69
- );
84
+ const actor: IdentityActor = { kind: row.actor_kind, id: row.actor_id };
85
+
86
+ if (kind === "invite") {
87
+ const activated = await tx.run(
88
+ "UPDATE users SET status = 'active', lastUpdatedAt = ? WHERE id = ? AND status = 'invited'",
89
+ [nowIso, row.user_id],
90
+ );
91
+ if (activated.changes === 0) return null;
92
+ await recordIdentityEvent(
93
+ row.user_id,
94
+ "status_changed",
95
+ actor,
96
+ { status: "invited" },
97
+ { status: "active", source: "invite_exchange" },
98
+ );
99
+ }
100
+
101
+ const { plaintext } = await mintToken(row.user_id, row.label, actor, {
102
+ source: kind === "invite" ? "invite_exchange" : "connector_exchange",
103
+ });
70
104
  return { token: plaintext, userId: row.user_id };
71
105
  });
72
106
  }
73
107
 
108
+ /** Delete a user's unused invite codes, so a re-invite leaves one live link. */
109
+ export async function deleteUnusedInviteCodes(userId: string): Promise<number> {
110
+ const result = await getDbClient().run(
111
+ "DELETE FROM connector_codes WHERE user_id = ? AND kind = 'invite' AND used_at IS NULL",
112
+ [userId],
113
+ );
114
+ return result.changes;
115
+ }
116
+
74
117
  /** Delete codes that expired more than 1h ago. Returns the number of deleted rows. */
75
118
  export async function deleteExpiredConnectorCodes(now = new Date()): Promise<number> {
76
119
  const cutoff = new Date(now.getTime() - CONNECTOR_CODE_RETENTION_MS).toISOString();
@@ -56,7 +56,10 @@ export async function runBootReembed(): Promise<void> {
56
56
  for (let i = 0; i < rows.length; i += BATCH_SIZE) {
57
57
  const batch = rows.slice(i, i + BATCH_SIZE);
58
58
  try {
59
- const embeddings = await provider.embedBatch(batch.map((m) => m.content));
59
+ const embeddings = await provider.embedBatch(
60
+ batch.map((m) => m.content),
61
+ { kind: "document" },
62
+ );
60
63
  for (let j = 0; j < embeddings.length; j++) {
61
64
  if (embeddings[j]) {
62
65
  await store.updateEmbedding(batch[j]!.id, embeddings[j]!, provider.name);
@@ -72,6 +72,12 @@ export function minSimilarity(): number {
72
72
  return numEnv("MEMORY_MIN_SIMILARITY", 0.1);
73
73
  }
74
74
 
75
+ // Raw cosine at or above which a store-progress learning is a near duplicate of
76
+ // an existing swarm memory and is not stored. Measured on production
77
+ // `/learnings` rows: a confirmed twin pair scored 0.877, the closest distinct
78
+ // pair 0.705.
79
+ export const LEARNING_DUPLICATE_MIN_SIMILARITY = 0.8;
80
+
75
81
  // Reranking parameters
76
82
  export function accessBoostMaxMultiplier(): number {
77
83
  return numEnv("MEMORY_ACCESS_BOOST_MAX", 1.5);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A memory or script written while the embeddings endpoint is down keeps no
3
+ * vector, and the backfills (`runBootReembed`, `runBootReembedScripts`) run
4
+ * only at boot or when a config reload turns embeddings on. When an embedding
5
+ * call works again after a failed one (for example, a local TEI restarted),
6
+ * run both backfills once more, so those rows do not wait for the next API
7
+ * restart.
8
+ */
9
+
10
+ let failedSinceBackfill = false;
11
+ let backfill: Promise<void> | null = null;
12
+
13
+ /** An embedding call failed (network, server error, rejected request). */
14
+ export function noteEmbeddingFailure(): void {
15
+ failedSinceBackfill = true;
16
+ }
17
+
18
+ /** An embedding call returned a vector. Starts the backfill after a failure. */
19
+ export function noteEmbeddingSuccess(): void {
20
+ if (!failedSinceBackfill || backfill) return;
21
+ failedSinceBackfill = false;
22
+ const report = (what: string) => (err: unknown) => {
23
+ console.error(
24
+ `[memory] ${what} backfill after embeddings recovered failed:`,
25
+ (err as Error).message,
26
+ );
27
+ };
28
+ backfill = Promise.all([
29
+ import("./boot-reembed").then(({ runBootReembed }) => runBootReembed()).catch(report("Memory")),
30
+ import("../scripts/boot-reembed")
31
+ .then(({ runBootReembedScripts }) => runBootReembedScripts())
32
+ .catch(report("Script")),
33
+ ])
34
+ .then(() => undefined)
35
+ .finally(() => {
36
+ backfill = null;
37
+ });
38
+ }
39
+
40
+ /** The backfill started by the last recovery, if it still runs. For tests. */
41
+ export function pendingRecoveryBackfill(): Promise<void> | null {
42
+ return backfill;
43
+ }
44
+
45
+ /** Forget a failure seen by an earlier test. */
46
+ export function resetEmbeddingRecoveryForTests(): void {
47
+ failedSinceBackfill = false;
48
+ }
@@ -104,7 +104,7 @@ export async function indexMemoryContent(
104
104
  intent: "re-index memory source path",
105
105
  changedByAgentId: agentId,
106
106
  });
107
- const embedding = await provider.embed(result.memory.content);
107
+ const embedding = await provider.embed(result.memory.content, { kind: "document" });
108
108
  if (embedding) await store.updateEmbedding(result.memory.id, embedding, provider.name);
109
109
  try {
110
110
  // Re-index of an existing memory: prune stale content-derived links.
@@ -162,7 +162,10 @@ export async function indexMemoryContent(
162
162
  // Async batch embed (fire and forget)
163
163
  (async () => {
164
164
  try {
165
- const embeddings = await provider.embedBatch(memories.map((m) => m.content));
165
+ const embeddings = await provider.embedBatch(
166
+ memories.map((m) => m.content),
167
+ { kind: "document" },
168
+ );
166
169
  for (let i = 0; i < embeddings.length; i++) {
167
170
  if (embeddings[i]) {
168
171
  await store.updateEmbedding(memories[i]!.id, embeddings[i]!, provider.name);
@@ -2,7 +2,8 @@ import OpenAI from "openai";
2
2
  import { selectCredential } from "../../../utils/credentials";
3
3
  import { getOpenRouterAttributionHeaders } from "../../../utils/openrouter-base-url";
4
4
  import { DEFAULT_EMBEDDING_MODEL, EMBEDDING_DIMENSIONS } from "../constants";
5
- import type { EmbeddingProvider } from "../types";
5
+ import { noteEmbeddingFailure, noteEmbeddingSuccess } from "../embedding-recovery";
6
+ import type { EmbeddingInputKind, EmbeddingProvider, EmbedOptions } from "../types";
6
7
 
7
8
  // text-embedding-3-* reject any input over 8,192 tokens with a 400, and one
8
9
  // such input fails every input in its request. There is no tokenizer here, and
@@ -18,13 +19,19 @@ interface OpenAIEmbeddingConfig {
18
19
  apiKey?: string;
19
20
  model?: string;
20
21
  dimensions?: number;
22
+ queryPrefix?: string;
23
+ documentPrefix?: string;
21
24
  }
22
25
 
23
- function cleanInput(text: string): string {
24
- return text
25
- .replace(/[\n\r]/g, " ")
26
- .trim()
27
- .slice(0, MAX_EMBEDDING_INPUT_CHARS);
26
+ /**
27
+ * Flatten and trim the input, then prepend the task prefix. The cleaned text
28
+ * is cut so that prefix plus text stays within MAX_EMBEDDING_INPUT_CHARS. An
29
+ * empty input stays empty, so it is skipped and never embeds a bare prefix.
30
+ */
31
+ function cleanInput(text: string, prefix = ""): string {
32
+ const cleaned = text.replace(/[\n\r]/g, " ").trim();
33
+ if (!cleaned) return "";
34
+ return prefix + cleaned.slice(0, Math.max(0, MAX_EMBEDDING_INPUT_CHARS - prefix.length));
28
35
  }
29
36
 
30
37
  /** A 400 is about the request's inputs, so the other inputs can still embed. */
@@ -32,12 +39,36 @@ function isInputRejection(err: unknown): boolean {
32
39
  return err instanceof OpenAI.APIError && err.status === 400;
33
40
  }
34
41
 
42
+ /**
43
+ * The server rejected the request for its size, not for an input: a 413, or a
44
+ * 422 about the batch size (TEI: "batch size 70 > maximum allowed batch size
45
+ * 32"). One request per input then fits. Any other 422 (TEI also answers 422
46
+ * for a bad `dimensions` value) stays a plain failure, so it is not repeated
47
+ * once per input.
48
+ */
49
+ function isBatchTooLarge(err: unknown): boolean {
50
+ if (!(err instanceof OpenAI.APIError)) return false;
51
+ if (err.status === 413) return true;
52
+ return err.status === 422 && /batch size/i.test(err.message ?? "");
53
+ }
54
+
35
55
  function isInputTooLong(err: unknown): boolean {
36
56
  return (
37
57
  isInputRejection(err) && /maximum (context|input) length/i.test((err as Error).message ?? "")
38
58
  );
39
59
  }
40
60
 
61
+ /**
62
+ * The endpoint was down or overloaded (no HTTP response, a 5xx or a 429), so the
63
+ * same input can embed later. Only these start a backfill once calls work again:
64
+ * an input the endpoint rejects would fail again on every backfill.
65
+ */
66
+ function isOutage(err: unknown): boolean {
67
+ if (!(err instanceof OpenAI.APIError)) return true;
68
+ const status = err.status;
69
+ return status === undefined || status === 429 || status >= 500;
70
+ }
71
+
41
72
  export class OpenAIEmbeddingProvider implements EmbeddingProvider {
42
73
  readonly name: string;
43
74
  readonly dimensions: number;
@@ -45,6 +76,8 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
45
76
  private client: OpenAI | null = null;
46
77
  private readonly model: string;
47
78
  private readonly apiKey: string | undefined;
79
+ private readonly queryPrefix: string;
80
+ private readonly documentPrefix: string;
48
81
 
49
82
  constructor(config?: OpenAIEmbeddingConfig) {
50
83
  const apiKey = config?.apiKey ?? process.env.EMBEDDING_API_KEY ?? process.env.OPENAI_API_KEY;
@@ -59,6 +92,17 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
59
92
  this.name = config?.model
60
93
  ? `openai/${config.model}`
61
94
  : (process.env.EMBEDDING_MODEL ?? DEFAULT_EMBEDDING_MODEL);
95
+
96
+ // Read once here: a config reload builds a new provider. Used verbatim,
97
+ // so a trailing space (nomic: "search_query: ") is kept.
98
+ this.queryPrefix = config?.queryPrefix ?? process.env.EMBEDDING_QUERY_PREFIX ?? "";
99
+ this.documentPrefix = config?.documentPrefix ?? process.env.EMBEDDING_DOCUMENT_PREFIX ?? "";
100
+ }
101
+
102
+ private prefixFor(kind: EmbeddingInputKind | undefined): string {
103
+ if (kind === "query") return this.queryPrefix;
104
+ if (kind === "document") return this.documentPrefix;
105
+ return "";
62
106
  }
63
107
 
64
108
  isConfigured(): boolean {
@@ -107,7 +151,9 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
107
151
  while (true) {
108
152
  try {
109
153
  const response = await this.request(client, input);
110
- return this.toVector(response.data[0]?.embedding, "Embedding");
154
+ const vector = this.toVector(response.data[0]?.embedding, "Embedding");
155
+ if (vector) noteEmbeddingSuccess();
156
+ return vector;
111
157
  } catch (err) {
112
158
  const message = (err as Error).message;
113
159
  if (isInputTooLong(err) && input.length > MIN_EMBEDDING_INPUT_CHARS) {
@@ -119,26 +165,28 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
119
165
  continue;
120
166
  }
121
167
  console.error("[memory] Embedding failed:", message);
168
+ if (isOutage(err)) noteEmbeddingFailure();
122
169
  return null;
123
170
  }
124
171
  }
125
172
  }
126
173
 
127
- async embed(text: string): Promise<Float32Array | null> {
174
+ async embed(text: string, opts?: EmbedOptions): Promise<Float32Array | null> {
128
175
  const client = this.getClient();
129
176
  if (!client) return null;
130
177
 
131
- const cleaned = cleanInput(text);
178
+ const cleaned = cleanInput(text, this.prefixFor(opts?.kind));
132
179
  if (!cleaned) return null;
133
180
  return this.embedOne(client, cleaned);
134
181
  }
135
182
 
136
- async embedBatch(texts: string[]): Promise<(Float32Array | null)[]> {
183
+ async embedBatch(texts: string[], opts?: EmbedOptions): Promise<(Float32Array | null)[]> {
137
184
  const results: (Float32Array | null)[] = texts.map(() => null);
138
185
  const client = this.getClient();
139
186
  if (!client) return results;
140
187
 
141
- const cleaned = texts.map(cleanInput);
188
+ const prefix = this.prefixFor(opts?.kind);
189
+ const cleaned = texts.map((text) => cleanInput(text, prefix));
142
190
  const groups: number[][] = [];
143
191
  let group: number[] = [];
144
192
  let groupChars = 0;
@@ -166,13 +214,16 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
166
214
  if (originalIndex === undefined) continue;
167
215
  results[originalIndex] = this.toVector(item.embedding, "Batch embedding");
168
216
  }
217
+ if (indices.some((i) => results[i])) noteEmbeddingSuccess();
169
218
  } catch (err) {
170
- if (!isInputRejection(err)) {
219
+ if (!isInputRejection(err) && !isBatchTooLarge(err)) {
171
220
  console.error("[memory] Batch embedding failed:", (err as Error).message);
221
+ if (isOutage(err)) noteEmbeddingFailure();
172
222
  continue;
173
223
  }
174
- // One rejected input fails the whole request: embed each input alone so
175
- // only that input is cut down, and the rest embed as they are.
224
+ // One rejected input fails the whole request, and a server with a low
225
+ // batch cap rejects the request for its size: embed each input alone so
226
+ // only a bad input is cut down, and the rest embed as they are.
176
227
  console.warn(
177
228
  `[memory] Batch embedding rejected, embedding ${indices.length} inputs one by one: ${(err as Error).message}`,
178
229
  );
@@ -5,9 +5,18 @@
5
5
  *
6
6
  * Source stays `manual` so nightly curation (memtax B1 reads only manual and
7
7
  * file_index rows) picks learnings up without a new source value.
8
+ *
9
+ * A learning close to an existing swarm memory (raw cosine at or above
10
+ * LEARNING_DUPLICATE_MIN_SIMILARITY) is not stored: the existing row gets a
11
+ * positive rating instead. Known gap: embeddings are written in the background,
12
+ * so two near-identical learnings seconds apart can both pass the check.
13
+ * Nightly curation catches those.
8
14
  */
9
15
  import { getDbClient } from "@/be/db";
16
+ import { getEmbeddingProvider, getMemoryStore } from "@/be/memory";
17
+ import { LEARNING_DUPLICATE_MIN_SIMILARITY } from "@/be/memory/constants";
10
18
  import { indexMemoryContent } from "@/be/memory/index-content";
19
+ import { applyRating } from "@/be/memory/raters/store";
11
20
  import { contentSha256 } from "@/commands/profile-sync";
12
21
  import type { AgentTask } from "@/types";
13
22
  import { scrubSecrets } from "@/utils/secret-scrubber";
@@ -18,12 +27,17 @@ export const LEARNING_MAX_TAGS = 5;
18
27
  const LEARNINGS_ROOT = "/learnings";
19
28
  const SEGMENT_MAX_CHARS = 60;
20
29
  const KEY_ATTEMPTS = 4;
30
+ /** Where a near duplicate can already live: any workstream's learnings and the workstream digests. */
31
+ const DUPLICATE_SEARCH_PREFIXES = [`${LEARNINGS_ROOT}/`, "/longterm/workstreams/"];
32
+ /** memory_rating.source for the positive rating a duplicate gives the existing row. */
33
+ export const LEARNING_DUPLICATE_RATING_SOURCE = "learning-duplicate";
21
34
 
22
35
  export type TaskLearningInput = string | { text: string; workstream?: string; tags?: string[] };
23
36
 
24
37
  export type TaskLearningResult =
25
38
  | { status: "stored"; memoryId: string; key: string; workstream: string }
26
39
  | { status: "replayed"; memoryId: string; key: string }
40
+ | { status: "duplicate"; memoryId: string; key: string; similarity: number }
27
41
  | { status: "failed"; error: string };
28
42
 
29
43
  /** Lowercase kebab slug: `Agent Swarm!` -> `agent-swarm`. Empty when nothing usable is left. */
@@ -86,6 +100,15 @@ export async function storeTaskLearning(params: {
86
100
  );
87
101
 
88
102
  try {
103
+ // Replay first: the same text on the same task is a retry, not a duplicate.
104
+ const replay = await findReplay(task.id, contentSha256(text));
105
+ if (replay) return replay;
106
+ // Outside the write transaction: the embedding call goes over the network.
107
+ const duplicate = await findNearDuplicate({ agentId, isLead, text });
108
+ if (duplicate) {
109
+ await rateDuplicate(duplicate, task.id);
110
+ return duplicate;
111
+ }
89
112
  // One write transaction for the replay check and the insert, so two
90
113
  // overlapping calls with the same learning store it once.
91
114
  return await getDbClient().transaction(() =>
@@ -99,6 +122,91 @@ export async function storeTaskLearning(params: {
99
122
  }
100
123
  }
101
124
 
125
+ async function findReplay(
126
+ taskId: string,
127
+ contentHash: string,
128
+ ): Promise<Extract<TaskLearningResult, { status: "replayed" }> | null> {
129
+ const replay = await getDbClient().get<{ id: string; key: string }>(
130
+ `SELECT id, key FROM agent_memory
131
+ WHERE sourceTaskId = ? AND contentHash = ? AND key LIKE '${LEARNINGS_ROOT}/%'
132
+ LIMIT 1`,
133
+ [taskId, contentHash],
134
+ );
135
+ return replay ? { status: "replayed", memoryId: replay.id, key: replay.key } : null;
136
+ }
137
+
138
+ /**
139
+ * The closest swarm memory under the duplicate prefixes when its raw cosine to
140
+ * the learning reaches the threshold. No `queryText`, so the store takes the
141
+ * vector or brute-force path and `similarity` is raw cosine, not an FTS rank or
142
+ * a fused score. Any failure (no embedding provider, a search error) returns
143
+ * null so the learning is stored as usual.
144
+ */
145
+ async function findNearDuplicate(params: {
146
+ agentId: string;
147
+ isLead: boolean;
148
+ text: string;
149
+ }): Promise<Extract<TaskLearningResult, { status: "duplicate" }> | null> {
150
+ try {
151
+ const embedding = await getEmbeddingProvider().embed(params.text);
152
+ if (!embedding) return null;
153
+ const store = getMemoryStore();
154
+ const results = await Promise.all(
155
+ DUPLICATE_SEARCH_PREFIXES.map((keyPrefix) =>
156
+ store.search(embedding, params.agentId, {
157
+ scope: "swarm",
158
+ isLead: params.isLead,
159
+ keyPrefix,
160
+ limit: 1,
161
+ }),
162
+ ),
163
+ );
164
+ const best = results
165
+ .flat()
166
+ .filter((candidate) => candidate.key)
167
+ .sort((a, b) => b.similarity - a.similarity)[0];
168
+ if (!best || best.similarity < LEARNING_DUPLICATE_MIN_SIMILARITY) return null;
169
+ return {
170
+ status: "duplicate",
171
+ memoryId: best.id,
172
+ key: best.key!,
173
+ similarity: Math.round(best.similarity * 1000) / 1000,
174
+ };
175
+ } catch (err) {
176
+ console.warn(
177
+ "[task-learning] near-duplicate check failed; storing the learning:",
178
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
179
+ );
180
+ return null;
181
+ }
182
+ }
183
+
184
+ /** A repeated lesson is evidence the existing row is useful. A failed rating never fails the call. */
185
+ async function rateDuplicate(
186
+ duplicate: Extract<TaskLearningResult, { status: "duplicate" }>,
187
+ taskId: string,
188
+ ): Promise<void> {
189
+ try {
190
+ await applyRating(
191
+ [
192
+ {
193
+ memoryId: duplicate.memoryId,
194
+ signal: 1,
195
+ weight: 1,
196
+ source: LEARNING_DUPLICATE_RATING_SOURCE,
197
+ reasoning: `store-progress learning matched at cosine ${duplicate.similarity}`,
198
+ },
199
+ ],
200
+ { taskId },
201
+ );
202
+ } catch (err) {
203
+ console.warn(
204
+ "[task-learning] duplicate rating failed:",
205
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
206
+ );
207
+ }
208
+ }
209
+
102
210
  async function insertLearning(params: {
103
211
  agentId: string;
104
212
  isLead: boolean;
@@ -110,14 +218,8 @@ async function insertLearning(params: {
110
218
  const { agentId, isLead, taskId, text, tags, workstream } = params;
111
219
  // indexMemoryContent stores text this short as one trimmed chunk, so this
112
220
  // hash is the row's contentHash: a replayed call finds its own earlier write.
113
- const contentHash = contentSha256(text);
114
- const replay = await getDbClient().get<{ id: string; key: string }>(
115
- `SELECT id, key FROM agent_memory
116
- WHERE sourceTaskId = ? AND contentHash = ? AND key LIKE '${LEARNINGS_ROOT}/%'
117
- LIMIT 1`,
118
- [taskId, contentHash],
119
- );
120
- if (replay) return { status: "replayed", memoryId: replay.id, key: replay.key };
221
+ const replay = await findReplay(taskId, contentSha256(text));
222
+ if (replay) return replay;
121
223
 
122
224
  const title = text.split(/\r?\n/, 1)[0]!.trim().slice(0, 200);
123
225
  const baseKey = `${LEARNINGS_ROOT}/${workstream}/${toKebab(title) || "learning"}`;
@@ -4,11 +4,22 @@ import type { AgentMemory, AgentMemoryScope, AgentMemorySource } from "@/types";
4
4
  // EmbeddingProvider — text to vector, swappable
5
5
  // ============================================================================
6
6
 
7
+ /**
8
+ * What an input is for. Some models (nomic-embed) need a task prefix on each
9
+ * input: one for search queries, another for stored documents. No kind means
10
+ * no prefix.
11
+ */
12
+ export type EmbeddingInputKind = "query" | "document";
13
+
14
+ export interface EmbedOptions {
15
+ kind?: EmbeddingInputKind;
16
+ }
17
+
7
18
  export interface EmbeddingProvider {
8
19
  readonly name: string;
9
20
  readonly dimensions: number;
10
- embed(text: string): Promise<Float32Array | null>;
11
- embedBatch(texts: string[]): Promise<(Float32Array | null)[]>;
21
+ embed(text: string, opts?: EmbedOptions): Promise<Float32Array | null>;
22
+ embedBatch(texts: string[], opts?: EmbedOptions): Promise<(Float32Array | null)[]>;
12
23
  /** True when the provider has a usable API key — no network call. */
13
24
  isConfigured(): boolean;
14
25
  }
@@ -0,0 +1,11 @@
1
+ -- Kind of single-use code in connector_codes.
2
+ --
3
+ -- `connector`: the agent-swarm.dev ChatGPT connector code (10 minute TTL).
4
+ -- `invite`: a teammate invite from `POST /api/invites` (7 day TTL). Its
5
+ -- exchange at `POST /api/invites/exchange` also flips the user from
6
+ -- `invited` to `active`.
7
+ --
8
+ -- Each exchange route redeems only its own kind. Existing rows are connector
9
+ -- codes, so they keep today's behavior.
10
+ ALTER TABLE connector_codes ADD COLUMN kind TEXT NOT NULL DEFAULT 'connector'
11
+ CHECK (kind IN ('connector', 'invite'));