@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.
- package/dist/{acp-adapter-ma1js5n7.js → acp-adapter-8dn5tq56.js} +6 -6
- package/dist/{actions-1vhmtvg9.js → actions-3r6gw7dy.js} +18 -18
- package/dist/{amp-adapter-sxqqvmdk.js → amp-adapter-wjtq413p.js} +3 -3
- package/dist/{anthropic-messages-txs190dd.js → anthropic-messages-n4cby1rz.js} +4 -4
- package/dist/{app-mwx1d5a9.js → app-d53yhykk.js} +3 -3
- package/dist/{assistant-yx1p6g82.js → assistant-ksrawj3k.js} +20 -20
- package/dist/{boot-reembed-7h78vmvv.js → boot-reembed-fxfw17m2.js} +10 -3
- package/dist/{boot-reembed-5h37a2c2.js → boot-reembed-k5y5xbj0.js} +15 -8
- package/dist/{boot-scrub-sweep-8mawd4gw.js → boot-scrub-sweep-se41j9fd.js} +3 -3
- package/dist/{boot-seeds-ca84dhnn.js → boot-seeds-r3kwwv8b.js} +4 -4
- package/dist/{claude-adapter-wqt4hfkq.js → claude-adapter-ahmyf231.js} +7 -7
- package/dist/{claude-managed-adapter-6fjqtr2z.js → claude-managed-adapter-5knamegc.js} +3 -3
- package/dist/{claude-managed-setup-rqv98sea.js → claude-managed-setup-mdc7dy6b.js} +2 -2
- package/dist/{claude-sdk-session-f29f3b2x.js → claude-sdk-session-n5wen9zk.js} +8728 -8599
- package/dist/{cli-m5e802r7.js → cli-0dqtv2jg.js} +2 -2
- package/dist/{cli-e1595bgx.js → cli-0tww7dnz.js} +2 -2
- package/dist/{cli-2y7v133v.js → cli-0w4e4v58.js} +7 -7
- package/dist/{cli-d20qvpr2.js → cli-2sjm3t4r.js} +1 -1
- package/dist/{cli-gf2850q7.js → cli-3cywb44q.js} +18 -1
- package/dist/{cli-8wkb09q5.js → cli-3k9jej0c.js} +2 -2
- package/dist/{cli-twctnkng.js → cli-3v2cyqb8.js} +3 -3
- package/dist/{cli-he9gbdx0.js → cli-5qt9vzmq.js} +39 -20
- package/dist/{cli-a6ds2epe.js → cli-5rp1w4cr.js} +1 -1
- package/dist/{cli-6srv653c.js → cli-6vwjsetx.js} +4 -4
- package/dist/{cli-1hhbzkbb.js → cli-6w2yrrc0.js} +8 -8
- package/dist/{cli-f12mr20z.js → cli-75tdydgv.js} +1 -1
- package/dist/{cli-j3y0x2n4.js → cli-8feswf2v.js} +43 -33
- package/dist/{cli-07q03xm2.js → cli-8wd3mbyj.js} +1 -1
- package/dist/{cli-ymjpkxgn.js → cli-9csfthbw.js} +8 -6
- package/dist/{cli-f1e0ptjj.js → cli-b41p764a.js} +21 -2
- package/dist/{cli-kv7sbc2c.js → cli-c1t533hh.js} +3 -3
- package/dist/{cli-54sj64fc.js → cli-c28jxpcv.js} +1 -1
- package/dist/{cli-8mbc9za9.js → cli-cb50g9cd.js} +1 -1
- package/dist/{cli-c668vvcf.js → cli-d8gaphtr.js} +1 -1
- package/dist/{cli-86ehn4s5.js → cli-dgtfysne.js} +2 -2
- package/dist/{cli-sjctjr48.js → cli-dscnhn18.js} +1 -1
- package/dist/{cli-j0ppywdr.js → cli-dv11pgpv.js} +12 -12
- package/dist/{cli-1m9x8dr5.js → cli-ekptgk2m.js} +5 -5
- package/dist/{cli-9gsm2rzp.js → cli-f3mzy662.js} +17 -17
- package/dist/{cli-s0mgx214.js → cli-fk7k8s9x.js} +2 -2
- package/dist/{cli-vbhgkw9h.js → cli-h3e7bhvn.js} +3 -3
- package/dist/{cli-yd2nreqw.js → cli-hkxqdmnb.js} +2 -2
- package/dist/{cli-8wmj18cz.js → cli-hpkvgdad.js} +7 -4
- package/dist/{cli-wqr7cea4.js → cli-hq2v4rr5.js} +37 -9
- package/dist/{cli-sgqm2582.js → cli-k1zb7raw.js} +5 -5
- package/dist/{cli-4rvr7zfx.js → cli-k4awgvzm.js} +5 -0
- package/dist/{cli-ay40dnj3.js → cli-k7d3hvya.js} +2 -2
- package/dist/{cli-j9bpmykh.js → cli-kbtdftbw.js} +5 -5
- package/dist/{cli-x37r7qwf.js → cli-kj69b9tw.js} +25 -5
- package/dist/{cli-gsfk58nw.js → cli-kk9jy4nm.js} +1 -1
- package/dist/{cli-39aet655.js → cli-m52t4cta.js} +1 -1
- package/dist/{cli-h5jeg46p.js → cli-mdadewqp.js} +5 -5
- package/dist/{cli-a89zdqz9.js → cli-nf6k60sn.js} +1 -1
- package/dist/{cli-v6shg7na.js → cli-nzdf09js.js} +1 -1
- package/dist/{cli-rr7zx19j.js → cli-prcyzecg.js} +12 -12
- package/dist/{cli-6a9qfa8b.js → cli-pzafyqtk.js} +118 -48
- package/dist/{cli-48gxjmcz.js → cli-qvaa1gjp.js} +87 -24
- package/dist/{cli-qj442fws.js → cli-rg4b2g6j.js} +1 -1
- package/dist/{cli-aypd09nf.js → cli-rm66vqdz.js} +1 -1
- package/dist/{cli-9pshx6rd.js → cli-s504c3w7.js} +1 -1
- package/dist/{cli-ptfq202x.js → cli-swkx9jm2.js} +3 -3
- package/dist/{cli-m30k9777.js → cli-thsaq4pd.js} +16 -16
- package/dist/{cli-60sty27p.js → cli-twjnyvs8.js} +2 -2
- package/dist/{cli-rd5m70gj.js → cli-typdpe8g.js} +4 -4
- package/dist/{cli-yxbsmx56.js → cli-vg8pwmsd.js} +6 -6
- package/dist/{cli-s09j9vp5.js → cli-vhpckzvp.js} +1 -1
- package/dist/{cli-dv97kdzv.js → cli-wdxgsdz5.js} +2 -2
- package/dist/{cli-7a6p4e65.js → cli-wtjg6je3.js} +1 -1
- package/dist/{cli-b3yzyc57.js → cli-x3cngb1h.js} +44 -12
- package/dist/{cli-6mgdba42.js → cli-xbz6mcg4.js} +2 -2
- package/dist/{cli-2pvzmas1.js → cli-y4tdxsqb.js} +3 -3
- package/dist/{cli-f4dbh7a5.js → cli-yqrsp3ny.js} +7 -7
- package/dist/cli.js +27 -19
- package/dist/{codex-adapter-zk59x2zh.js → codex-adapter-7wepzq15.js} +5 -5
- package/dist/{codex-hook-xyjhvxwd.js → codex-hook-pbdh4xhx.js} +2 -2
- package/dist/{codex-session-runner-kh6s4j3n.js → codex-session-runner-02sb49e4.js} +5 -5
- package/dist/{commands-68et1qv2.js → commands-3rcspxct.js} +2 -2
- package/dist/{controller-fyrbnzg4.js → controller-8ar4avd6.js} +3 -3
- package/dist/{controllers-connect-nep9h2xv.js → controllers-connect-ajvh9h0v.js} +19 -19
- package/dist/{cursor-adapter-yss3pg7t.js → cursor-adapter-gd0sa7bh.js} +1 -1
- package/dist/{db-28gjyvxj.js → db-dv9j7ts5.js} +2 -2
- package/dist/{db-8ftwvzba.js → db-z26vgdvj.js} +3 -3
- package/dist/{dispatcher-hayexmkr.js → dispatcher-8m0htjq8.js} +17 -17
- package/dist/{dsh-adapter-46wawff9.js → dsh-adapter-y8gyk130.js} +1 -1
- package/dist/{e2b-fn3zacvt.js → e2b-74vg9b7e.js} +1 -1
- package/dist/{e2b-222hsajc.js → e2b-jyay8wzn.js} +1 -1
- package/dist/{easter-egg-3d-8d1e0h4m.js → easter-egg-3d-28f880xx.js} +1 -1
- package/dist/{execute-cjrfxfnm.js → execute-9seb79vh.js} +1 -1
- package/dist/{external-channel-xbmxcg09.js → external-channel-smz7jyth.js} +2 -2
- package/dist/{getMachineId-bsd-brn0xym3.js → getMachineId-bsd-whfre6gr.js} +3 -3
- package/dist/{getMachineId-darwin-t339sfxm.js → getMachineId-darwin-5zjynb19.js} +3 -3
- package/dist/{getMachineId-linux-5eq95d8b.js → getMachineId-linux-8mksrrsq.js} +3 -3
- package/dist/{getMachineId-unsupported-qavrtc7j.js → getMachineId-unsupported-fxtqsdt2.js} +3 -3
- package/dist/{getMachineId-win-q7fe1wx7.js → getMachineId-win-nzfmz857.js} +3 -3
- package/dist/{grok-adapter-ag4wks2a.js → grok-adapter-z0nnvtbn.js} +7 -7
- package/dist/handlers-pspryh6g.js +41 -0
- package/dist/{heartbeat-psfvcgwp.js → heartbeat-f928aqyp.js} +20 -20
- package/dist/{hook-cyq4mryn.js → hook-d7c605r4.js} +7 -7
- package/dist/{hook-8pz04697.js → hook-q9hv5xf9.js} +5 -5
- package/dist/{http-haj1nd6k.js → http-q6j4k989.js} +934 -180
- package/dist/{index-z5h22pes.js → index-5nb266jr.js} +17 -17
- package/dist/{index-ysd45kgc.js → index-8fxt5eah.js} +10 -10
- package/dist/{index-zgvqzjsw.js → index-cekzwhez.js} +5 -5
- package/dist/{index-zyq8ct52.js → index-ch0c1xff.js} +17 -17
- package/dist/{index-syvhnvtw.js → index-ebnjhxh1.js} +1 -1
- package/dist/{index-y1ajx04a.js → index-ed8dy8rn.js} +20 -20
- package/dist/{index-jwt2bj6f.js → index-ejqdkkqx.js} +11 -10
- package/dist/{index-dr94zbnh.js → index-farm1zsn.js} +32661 -29443
- package/dist/{index-q52f47pe.js → index-yn1yxg85.js} +1 -1
- package/dist/{keepalive-xdvsamvj.js → keepalive-zzc3hks1.js} +3 -3
- package/dist/{lead-hqg57kdp.js → lead-c0pjcaw4.js} +36 -36
- package/dist/{maintenance-4y2xzh3a.js → maintenance-kqsxzpzx.js} +1 -1
- package/dist/{oauth-refresh-sweep-jd4r7zbs.js → oauth-refresh-sweep-7qt0g55z.js} +2 -2
- package/dist/{onboard-18447q9q.js → onboard-k7x13fqg.js} +118 -5
- package/dist/{opencode-adapter-26b2frdf.js → opencode-adapter-tqvrtter.js} +5 -5
- package/dist/{otel-impl-f9tgr8yw.js → otel-impl-sqkf6k3b.js} +97 -97
- package/dist/{pi-mono-adapter-qn6qbga1.js → pi-mono-adapter-m2qnafxm.js} +63 -9
- package/dist/{pricing-refresh-dw2tgb53.js → pricing-refresh-wkpwhgvs.js} +3 -3
- package/dist/{rbac-roles-s4nwj2m3.js → rbac-roles-dpn0c0wj.js} +7 -4
- package/dist/{rbac-roles-aygm2760.js → rbac-roles-wk7xd6a6.js} +2 -2
- package/dist/{render-v2-yfvdfr0x.js → render-v2-ex2m4g2k.js} +3 -3
- package/dist/{resume-1bmvjbf3.js → resume-725g9b0b.js} +7 -7
- package/dist/{runtime-f7tpzedy.js → runtime-kajas15g.js} +1 -1
- package/dist/{session-access-r5garej6.js → session-access-0j2ezjje.js} +8 -4
- package/dist/{session-source-backfill-424wzv0r.js → session-source-backfill-62202awe.js} +2 -2
- package/dist/{setup-8tygvxkq.js → setup-9sj11ax6.js} +1 -1
- package/dist/{virtual-modules-xsw323b3.js → virtual-modules-c6fcq6zr.js} +2 -2
- package/dist/{work-objects-ggnghpgf.js → work-objects-0y07495y.js} +20 -20
- package/dist/{worker-sqjd1nqf.js → worker-d4q9sqbc.js} +36 -36
- package/openapi.json +1070 -9
- package/package.json +3 -3
- package/src/be/connector-codes.ts +65 -22
- package/src/be/memory/boot-reembed.ts +4 -1
- package/src/be/memory/constants.ts +6 -0
- package/src/be/memory/embedding-recovery.ts +48 -0
- package/src/be/memory/index-content.ts +5 -2
- package/src/be/memory/providers/openai-embedding.ts +65 -14
- package/src/be/memory/task-learning.ts +110 -8
- package/src/be/memory/types.ts +13 -2
- package/src/be/migrations/246_connector_code_kind.sql +11 -0
- package/src/be/onboarding.ts +125 -15
- package/src/be/rbac-roles.ts +4 -1
- package/src/be/scripts/embeddings.ts +2 -2
- package/src/be/seed/agent-fs-provision.ts +75 -14
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/cli.tsx +9 -0
- package/src/comb/config.ts +6 -1
- package/src/commands/onboard/agent-fs-version.ts +5 -0
- package/src/commands/onboard/compose-generator.ts +112 -1
- package/src/commands/onboard/env-generator.ts +9 -0
- package/src/commands/onboard/tei-image.ts +12 -0
- package/src/commands/onboard/types.ts +7 -0
- package/src/commands/onboard.tsx +11 -1
- package/src/commands/runner.ts +14 -1
- package/src/http/agentfs-proxy.ts +369 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/codex-oauth-device.ts +8 -1
- package/src/http/fs.ts +64 -10
- package/src/http/inbox-state.ts +27 -1
- package/src/http/index.ts +4 -1
- package/src/http/memory.ts +7 -4
- package/src/http/onboarding.ts +139 -11
- package/src/http/session-access.ts +24 -0
- package/src/http/sessions.ts +10 -6
- package/src/http/tasks.ts +85 -11
- package/src/http/telemetry-ui.ts +1 -1
- package/src/http/user-roles.ts +4 -1
- package/src/http/users.ts +272 -3
- package/src/http/utils.ts +22 -3
- package/src/prompts/base-prompt.ts +24 -9
- package/src/prompts/session-templates.ts +26 -2
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +6 -1
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +58 -3
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/tests/agent-fs-provision-seeder.test.ts +261 -92
- package/src/tests/agentfs-proxy.test.ts +1106 -0
- package/src/tests/base-prompt.test.ts +69 -2
- package/src/tests/codex-adapter.test.ts +46 -8
- package/src/tests/codex-oauth-device.test.ts +12 -1
- package/src/tests/comb-config-guard.test.ts +20 -0
- package/src/tests/comb-links.test.ts +79 -1
- package/src/tests/comb-status.test.ts +44 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/fixtures/onboard/baseline-states.ts +68 -0
- package/src/tests/fixtures/onboard/content.compose.yml.txt +190 -0
- package/src/tests/fixtures/onboard/content.env.txt +29 -0
- package/src/tests/fixtures/onboard/dev.compose.yml.txt +155 -0
- package/src/tests/fixtures/onboard/dev.env.txt +28 -0
- package/src/tests/fixtures/onboard/full-all.compose.yml.txt +582 -0
- package/src/tests/fixtures/onboard/full-all.env.txt +59 -0
- package/src/tests/fixtures/onboard/full.compose.yml.txt +435 -0
- package/src/tests/fixtures/onboard/full.env.txt +36 -0
- package/src/tests/fixtures/onboard/research.compose.yml.txt +155 -0
- package/src/tests/fixtures/onboard/research.env.txt +28 -0
- package/src/tests/fixtures/onboard/solo.compose.yml.txt +85 -0
- package/src/tests/fixtures/onboard/solo.env.txt +26 -0
- package/src/tests/http-log-scrubbing.test.ts +34 -1
- package/src/tests/http-task-write-ownership.test.ts +5 -1
- package/src/tests/invites-http.test.ts +464 -0
- package/src/tests/memory-embedding-input-cap.test.ts +122 -1
- package/src/tests/memory-embedding-recovery.test.ts +137 -0
- package/src/tests/onboard-compose.test.ts +142 -0
- package/src/tests/onboard-env.test.ts +24 -0
- package/src/tests/onboard-non-interactive.test.ts +29 -0
- package/src/tests/onboard-tei-image.test.ts +36 -0
- package/src/tests/onboarding.test.ts +451 -4
- package/src/tests/openai-embedding-provider.test.ts +105 -2
- package/src/tests/pi-mono-session-cost-on-abort.test.ts +270 -0
- package/src/tests/preload-env.test.ts +4 -0
- package/src/tests/preload.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +19 -3
- package/src/tests/rbac-admission-e2e.test.ts +22 -6
- package/src/tests/rbac-admission.test.ts +342 -4
- package/src/tests/rbac-engine.test.ts +1 -0
- package/src/tests/rbac-roles.test.ts +9 -1
- package/src/tests/send-task-requester-gate.test.ts +64 -3
- package/src/tests/sessions-move.test.ts +39 -1
- package/src/tests/store-progress-learning.test.ts +95 -2
- package/src/tests/whoami-route.test.ts +41 -0
- package/src/tools/inject-learning.ts +1 -1
- package/src/tools/memory-edit.ts +1 -1
- package/src/tools/memory-search.ts +1 -1
- package/src/tools/store-progress.ts +5 -1
- package/src/tools/utils.ts +12 -1
- package/src/utils/constants.ts +39 -0
- package/src/utils/task-attachment-links.ts +15 -3
- package/templates/skills/swarm-docs/content.md +1 -1
- package/dist/handlers-tzmevqb5.js +0 -41
- package/dist/{azure-openai-responses-9kfwhmn7.js → azure-openai-responses-43asa23s.js} +0 -0
- package/dist/{node-vxx896fa.js → node-t28cc56b.js} +0 -0
- package/dist/{openai-codex-responses-79s8xnbh.js → openai-codex-responses-jhr3sjtm.js} +3 -3
- package/dist/{openai-completions-tdbyts0x.js → openai-completions-gs6yn37k.js} +3 -3
- /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.
|
|
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.
|
|
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
|
|
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
|
|
2
|
+
* Single-use codes that a browser or connector trades once for an `aswt_` token.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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() +
|
|
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,
|
|
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 =
|
|
67
|
+
opts: { kind?: ConnectorCodeKind; now?: Date } = {},
|
|
49
68
|
): Promise<{ token: string; userId: string } | null> {
|
|
50
|
-
const
|
|
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 {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
175
|
-
//
|
|
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
|
|
114
|
-
|
|
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"}`;
|
package/src/be/memory/types.ts
CHANGED
|
@@ -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'));
|