@desplega.ai/agent-swarm 1.156.0 → 1.158.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 (263) hide show
  1. package/dist/{acp-adapter-c1msptrw.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-wd41qtdc.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-tz2qrnah.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-c3q1zj2t.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-0be1krkm.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-r00j03f3.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-9j4hd0vw.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-ncgxz1r2.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-112w28qc.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-k7pb5kfz.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-mx3bzbv8.js → cli-20cj37vt.js} +1968 -44
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-24w39x6n.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-90x9ewbz.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-08n8jxcw.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-7j2098n5.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-b05d7szr.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-9w4ex8vh.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-28wkjpdp.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-7pgcpcrg.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-jk26c1sz.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-865swy5p.js → cli-c07r9a2m.js} +3 -2
  29. package/dist/{cli-nyqza443.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-9pyah9c4.js → cli-ftfvggzx.js} +633 -166
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-y8bfxjtw.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-7vj52450.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-tp0d7vzb.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-s8hpxeg4.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-hc2fkhdv.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-t1cd3mhp.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-xa4j64zh.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-szcyv1mm.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +30 -16
  47. package/dist/{codex-adapter-g4tj1f2g.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-ad8ha7zt.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-z3gd8ppc.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-bezm97r9.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-a97hc33y.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-wtdy1x02.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-7edgmzng.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fvs58a5b.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-phnf93hp.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-7x131mmf.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-1fktvwxc.js → index-a3vn96n8.js} +31 -28
  59. package/dist/{index-yp8rasxk.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-50jxgw0d.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-7jwk7nsm.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-nnz07ebd.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-mg7qcgww.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-ywmnqrpx.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-c63sqfs1.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-2zj8sqkq.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-jgdfm26f.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-3whntb69.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-3zptcc3p.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-dbvpyysc.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-qwx0j8z9.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-sjbcmvep.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-xezqdkb7.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +3 -2
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/codex-agents-md.ts +140 -17
  149. package/src/providers/modelsdev-reasoning.json +551 -0
  150. package/src/rbac/legacy-policy.ts +16 -0
  151. package/src/rbac/permissions.ts +4 -0
  152. package/src/scheduler/deferred-task-waits.ts +28 -0
  153. package/src/scheduler/schedule-task.ts +3 -0
  154. package/src/scheduler/scheduler.ts +14 -0
  155. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  156. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  157. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  158. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  159. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  160. package/src/server.ts +12 -7
  161. package/src/slack/app.ts +35 -4
  162. package/src/slack/connection-state.ts +17 -0
  163. package/src/tasks/context-key.ts +1 -0
  164. package/src/tasks/worker-follow-up.ts +6 -2
  165. package/src/tests/agent-task-activity.test.ts +74 -0
  166. package/src/tests/agents-list-model-display.test.ts +6 -1
  167. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  168. package/src/tests/approval-requests.test.ts +395 -0
  169. package/src/tests/approval-sweeps-config.test.ts +47 -0
  170. package/src/tests/approval-sweeps.test.ts +508 -0
  171. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  172. package/src/tests/apps-spike.test.ts +56 -1
  173. package/src/tests/apps-sync-engine.test.ts +20 -9
  174. package/src/tests/boot-seeds.test.ts +170 -0
  175. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  176. package/src/tests/claude-managed-adapter.test.ts +6 -0
  177. package/src/tests/codex-adapter.test.ts +126 -1
  178. package/src/tests/config-key-filter.test.ts +80 -0
  179. package/src/tests/context-window.test.ts +1 -0
  180. package/src/tests/db-client-busy-retry.test.ts +96 -0
  181. package/src/tests/db-client.test.ts +60 -1
  182. package/src/tests/db-task-read-contract.test.ts +10 -0
  183. package/src/tests/db-task-write-contract.test.ts +9 -0
  184. package/src/tests/deferred-schedule-reaper.test.ts +193 -0
  185. package/src/tests/docs-index-footprint.test.js +3 -3
  186. package/src/tests/events-http.test.ts +103 -0
  187. package/src/tests/extensions-catalog.test.ts +22 -17
  188. package/src/tests/extensions-pre-task.test.ts +107 -0
  189. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  190. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  191. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  192. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  193. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  194. package/src/tests/heartbeat.test.ts +99 -3
  195. package/src/tests/hook-mcp-config.test.ts +29 -0
  196. package/src/tests/http-api-integration.test.ts +16 -2
  197. package/src/tests/kv-storage.test.ts +62 -0
  198. package/src/tests/linear-outbound-sync.test.ts +120 -5
  199. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  200. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  201. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  202. package/src/tests/memory-hybrid.test.ts +161 -1
  203. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  204. package/src/tests/memory.test.ts +22 -0
  205. package/src/tests/onboard-container-engine.test.ts +240 -0
  206. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  207. package/src/tests/pricing-routes.test.ts +14 -0
  208. package/src/tests/profile-sync-warning.test.ts +8 -4
  209. package/src/tests/promote-draft-task-route.test.ts +19 -0
  210. package/src/tests/provider-registration.test.ts +296 -42
  211. package/src/tests/rbac-engine.test.ts +30 -0
  212. package/src/tests/reasoning-effort.test.ts +8 -0
  213. package/src/tests/script-connections.test.ts +7 -0
  214. package/src/tests/sdk-allowlist.test.ts +39 -0
  215. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  216. package/src/tests/sessions-detail-batch.test.ts +250 -0
  217. package/src/tests/sessions.test.ts +118 -0
  218. package/src/tests/slack-config.test.ts +22 -0
  219. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  220. package/src/tests/status.test.ts +36 -1
  221. package/src/tests/swarm-diff.test.ts +316 -10
  222. package/src/tests/task-completion-idempotency.test.ts +2 -1
  223. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  224. package/src/tests/tool-annotations.test.ts +1 -1
  225. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  226. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  227. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  228. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  229. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  230. package/src/tests/workflow-http-v2.test.ts +99 -8
  231. package/src/tests/workflow-retry-validation.test.ts +56 -0
  232. package/src/tests/workflow-tla-races.test.ts +389 -0
  233. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  234. package/src/tools/cancel-approval-request.ts +57 -0
  235. package/src/tools/memory-search.ts +1 -0
  236. package/src/tools/request-human-input.ts +4 -2
  237. package/src/tools/schedules/list-schedules.ts +4 -2
  238. package/src/tools/send-task.ts +4 -0
  239. package/src/tools/templates.ts +19 -0
  240. package/src/tools/tool-config.ts +2 -1
  241. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  242. package/src/types.ts +38 -0
  243. package/src/utils/context-window.ts +1 -0
  244. package/src/workflows/approval-cancel.ts +152 -0
  245. package/src/workflows/approval-notifications.ts +84 -0
  246. package/src/workflows/checkpoint.ts +10 -5
  247. package/src/workflows/definition.ts +21 -0
  248. package/src/workflows/engine.ts +152 -21
  249. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  250. package/src/workflows/recovery.ts +100 -45
  251. package/src/workflows/resume.ts +49 -18
  252. package/src/workflows/retry-poller.ts +71 -21
  253. package/templates/extensions/README.md +1 -0
  254. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  255. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  256. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  257. package/templates/skills/apps/content.md +20 -1
  258. package/templates/skills/code-quality/content.md +26 -1
  259. package/templates/skills/pages/content.md +16 -4
  260. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  261. package/templates/skills/scheduling/content.md +1 -1
  262. package/dist/cli-2daksqps.js +0 -378
  263. package/dist/rbac-roles-3zw68x72.js +0 -37
@@ -0,0 +1,155 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { unlink } from "node:fs/promises";
4
+ import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
5
+ import { expandCandidatesWithGraph } from "../be/memory/graph-expansion";
6
+ import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
7
+ import { computeScore, rerank } from "../be/memory/reranker";
8
+
9
+ // PR #1689 review on 2e8c2f24: expansion replaced an organic duplicate with
10
+ // its graph route on the UNCAPPED graph score, then rerank() capped that route
11
+ // at a low-quality parent's composite. The neighbour's stronger organic match
12
+ // was gone. Fresh rows, default boosts, link strength 1, vector-only arm so
13
+ // each similarity is the fixture cosine:
14
+ // - task_completion parent: relevance 0.90, composite 0.90 x 0.7 = 0.63
15
+ // - manual neighbour, also a direct match: relevance 0.60, composite 0.90
16
+ // - unrelated manual row: relevance 0.54, composite 0.81
17
+
18
+ const TEST_DB_PATH = "./test-memory-graph-organic-route.sqlite";
19
+ const agentId = randomUUID();
20
+
21
+ /** Unit vector: `cos` on axis 2 (the query axis), the remainder on `axis`. */
22
+ function mix(cos: number, axis: number): Float32Array {
23
+ const embedding = new Float32Array(512);
24
+ embedding[2] = cos;
25
+ embedding[axis] = Math.sqrt(1 - cos * cos);
26
+ return embedding;
27
+ }
28
+
29
+ const queryEmbedding = mix(1, 3);
30
+
31
+ let parentId: string;
32
+ let neighborId: string;
33
+ let otherId: string;
34
+ let prevHybridFlag: string | undefined;
35
+ let prevGraphFlag: string | undefined;
36
+
37
+ async function searchAndExpand() {
38
+ const candidates = await new SqliteMemoryStore().search(queryEmbedding, agentId, {
39
+ scope: "all",
40
+ limit: 15,
41
+ });
42
+ const expanded = await expandCandidatesWithGraph(candidates, agentId, { scope: "all" });
43
+ return { candidates, expanded };
44
+ }
45
+
46
+ beforeAll(async () => {
47
+ prevHybridFlag = process.env.MEMORY_HYBRID_SEARCH;
48
+ prevGraphFlag = process.env.MEMORY_GRAPH_EXPANSION;
49
+ process.env.MEMORY_HYBRID_SEARCH = "0";
50
+ delete process.env.MEMORY_GRAPH_EXPANSION;
51
+ for (const suffix of ["", "-wal", "-shm"]) {
52
+ try {
53
+ await unlink(TEST_DB_PATH + suffix);
54
+ } catch {}
55
+ }
56
+ initDb(TEST_DB_PATH);
57
+ await createAgent({ id: agentId, name: "Organic Route Agent", isLead: false, status: "idle" });
58
+
59
+ const store = new SqliteMemoryStore();
60
+ const parent = await store.store({
61
+ agentId,
62
+ scope: "agent",
63
+ name: "completed task summary",
64
+ content: "What the task did.",
65
+ source: "task_completion",
66
+ });
67
+ parentId = parent.id;
68
+ await store.updateEmbedding(parentId, mix(0.9, 4), "test");
69
+
70
+ const neighbor = await store.store({
71
+ agentId,
72
+ scope: "agent",
73
+ name: "manual note linked from the summary",
74
+ content: "A manual note that also matches the query directly.",
75
+ source: "manual",
76
+ });
77
+ neighborId = neighbor.id;
78
+ await store.updateEmbedding(neighborId, mix(0.6, 5), "test");
79
+
80
+ const other = await store.store({
81
+ agentId,
82
+ scope: "agent",
83
+ name: "unrelated manual note",
84
+ content: "Another manual note.",
85
+ source: "manual",
86
+ });
87
+ otherId = other.id;
88
+ await store.updateEmbedding(otherId, mix(0.54, 6), "test");
89
+
90
+ const nowIso = new Date().toISOString();
91
+ await getDbClient().run(
92
+ `INSERT INTO memory_link
93
+ (id, from_memory_id, linkType, targetKind, targetId, strength, resolver, sourceText, metadata, createdAt, updatedAt)
94
+ VALUES (?, ?, 'wikilink', 'memory', ?, 1.0, 'wikilink', 'note', NULL, ?, ?)`,
95
+ [randomUUID(), parentId, neighborId, nowIso, nowIso],
96
+ );
97
+ });
98
+
99
+ afterAll(async () => {
100
+ if (prevHybridFlag === undefined) delete process.env.MEMORY_HYBRID_SEARCH;
101
+ else process.env.MEMORY_HYBRID_SEARCH = prevHybridFlag;
102
+ if (prevGraphFlag === undefined) delete process.env.MEMORY_GRAPH_EXPANSION;
103
+ else process.env.MEMORY_GRAPH_EXPANSION = prevGraphFlag;
104
+ closeDb();
105
+ for (const suffix of ["", "-wal", "-shm"]) {
106
+ try {
107
+ await unlink(TEST_DB_PATH + suffix);
108
+ } catch {}
109
+ }
110
+ });
111
+
112
+ describe("graph route of a neighbour that already matches organically", () => {
113
+ test("fixture reproduces the review: the uncapped graph route beats the organic one, the capped route does not", async () => {
114
+ const { candidates } = await searchAndExpand();
115
+ const now = new Date();
116
+ const organic = candidates.find((c) => c.id === neighborId)!;
117
+ const parent = candidates.find((c) => c.id === parentId)!;
118
+ expect(organic.retrievalSource).toBe("vec");
119
+ expect(parent.retrievalSource).toBe("vec");
120
+ expect(candidates.find((c) => c.id === otherId)?.retrievalSource).toBe("vec");
121
+
122
+ const graphRoute = { ...organic, similarity: 0.9 * 0.7, retrievalSource: "graph" as const };
123
+ const organicScore = computeScore(organic, now);
124
+ const parentScore = computeScore(parent, now);
125
+ expect(organicScore).toBeCloseTo(0.9, 2);
126
+ expect(parentScore).toBeCloseTo(0.63, 2);
127
+ expect(computeScore(graphRoute, now)).toBeGreaterThan(organicScore);
128
+ expect(Math.min(computeScore(graphRoute, now), parentScore)).toBeLessThan(organicScore);
129
+ });
130
+
131
+ test("a low-quality parent's link does not demote a neighbour's stronger organic match", async () => {
132
+ const { expanded } = await searchAndExpand();
133
+ const neighbor = expanded.filter((c) => c.id === neighborId);
134
+ expect(neighbor).toHaveLength(1);
135
+ expect(neighbor[0]!.retrievalSource).toBe("vec");
136
+ expect(neighbor[0]!.graphParentId).toBeUndefined();
137
+
138
+ const [top] = rerank(expanded, { limit: 1 });
139
+ expect(top!.id).toBe(neighborId);
140
+ expect(top!.retrievalSource).toBe("vec");
141
+ expect(top!.compositeScore!).toBeCloseTo(0.9, 2);
142
+ });
143
+
144
+ test("the result matches search without graph expansion", async () => {
145
+ process.env.MEMORY_GRAPH_EXPANSION = "0";
146
+ try {
147
+ const { expanded } = await searchAndExpand();
148
+ const [top] = rerank(expanded, { limit: 1 });
149
+ expect(top!.id).toBe(neighborId);
150
+ expect(top!.compositeScore!).toBeCloseTo(0.9, 2);
151
+ } finally {
152
+ delete process.env.MEMORY_GRAPH_EXPANSION;
153
+ }
154
+ });
155
+ });
@@ -5,7 +5,6 @@ import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import { Readable } from "node:stream";
6
6
  import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
7
7
  import * as realMemoryModule from "../be/memory";
8
- import { sourceQuality } from "../be/memory/reranker";
9
8
  import { SIMILARITY_THRESHOLD } from "../prompts/memories";
10
9
  import type { AgentMemory } from "../types";
11
10
 
@@ -111,7 +110,9 @@ mock.module("../be/memory", () => ({
111
110
  if (options.queryText === "prompt recall") {
112
111
  return [
113
112
  candidate(memory, 0.95),
114
- candidate(thresholdMemory, SIMILARITY_THRESHOLD / sourceQuality(thresholdMemory.source)),
113
+ // Relevance exactly at the threshold; the manual-source boost lifts
114
+ // the composite well above it. The gate must read relevance.
115
+ candidate(thresholdMemory, SIMILARITY_THRESHOLD),
115
116
  ];
116
117
  }
117
118
  return [candidate(memory, 0.95)];
@@ -281,16 +282,25 @@ describe("memory HTTP recall capture gating", () => {
281
282
  ]);
282
283
  });
283
284
 
284
- test("prompt recall excludes memories at the injection threshold", async () => {
285
+ test("prompt recall gates on relevance, not the boosted composite score", async () => {
285
286
  const response = await callMemoryRoute(
286
287
  "POST",
287
288
  "/api/memory/search",
288
289
  ["api", "memory", "search"],
289
290
  { query: "prompt recall", intent: "pre-task memory recall", limit: 5 },
290
- { "x-memory-consumption": "prompt" },
291
+ { "x-memory-consumption": "prompt", "x-source-task-id": sourceTaskId },
291
292
  );
292
293
 
293
294
  expect(response.statusCode).toBe(200);
295
+ const threshold = response.body.results.find((r: any) => r.id === thresholdMemoryId);
296
+ expect(threshold.similarity).toBeGreaterThan(SIMILARITY_THRESHOLD);
297
+ expect(threshold.rawSimilarity).toBe(SIMILARITY_THRESHOLD);
298
+ expect(
299
+ await getDbClient().get<{ similarity: number; relevance: number }>(
300
+ "SELECT similarity, relevance FROM memory_retrieval WHERE memoryId = ?",
301
+ [thresholdMemoryId],
302
+ ),
303
+ ).toEqual({ similarity: threshold.similarity, relevance: SIMILARITY_THRESHOLD });
294
304
  expect(response.body.results.map((result: any) => result.accessCount)).toEqual([1, 0]);
295
305
  expect(
296
306
  await getDbClient().get<{ accessCount: number }>(
@@ -1,7 +1,14 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
4
- import { computeRrfScore, SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
4
+ import { expandCandidatesWithGraph } from "../be/memory/graph-expansion";
5
+ import {
6
+ computeRrfScore,
7
+ fusedSimilarity,
8
+ SqliteMemoryStore,
9
+ } from "../be/memory/providers/sqlite-store";
10
+ import { rerank } from "../be/memory/reranker";
11
+ import { memoryRelevance, renderMemoriesPrompt, SIMILARITY_THRESHOLD } from "../prompts/memories";
5
12
 
6
13
  const TEST_DB_PATH = "./test-memory-hybrid.sqlite";
7
14
  const agentId = "aaaa0000-0000-4000-8000-000000000101";
@@ -213,3 +220,156 @@ describe("memory hybrid search", () => {
213
220
  expect(freshIndex).toBeLessThan(staleIndex);
214
221
  });
215
222
  });
223
+
224
+ describe("fusedSimilarity", () => {
225
+ test("keeps the vec arm's [0,1] cosine scale", () => {
226
+ const both = computeRrfScore(0, 1.0) * 2;
227
+ expect(fusedSimilarity(0.8, both)).toBeCloseTo(0.8, 6);
228
+ expect(fusedSimilarity(0.8, computeRrfScore(0, 1.0))).toBeCloseTo(0.6, 6);
229
+ expect(fusedSimilarity(0.8, computeRrfScore(99, 1.0))).toBeGreaterThan(0.4);
230
+ expect(fusedSimilarity(1, both)).toBeLessThanOrEqual(1);
231
+ });
232
+ });
233
+
234
+ // Score-scale regression: every arm must land on one [0,1] scale before
235
+ // rerank, and the injection gate must read that scale, not the boosted
236
+ // composite. Before the fix a hybrid hit scored ~0.033 (raw RRF) while a graph
237
+ // neighbour scored parentCosine × 0.7, so neighbours outranked the hit that
238
+ // surfaced them and an unrelated vec-only query cleared the 0.4 composite gate.
239
+ describe("memory search score scale", () => {
240
+ const scaleAgentId = "aaaa0000-0000-4000-8000-000000000102";
241
+ let store: SqliteMemoryStore;
242
+ let prevHybridFlag: string | undefined;
243
+ let exactId: string;
244
+ let siblingId: string;
245
+ let neighborId: string;
246
+
247
+ /** Unit vector: `cos` on axis 2 (the query axis), the remainder on `axis`. */
248
+ function mix(cos: number, axis: number): Float32Array {
249
+ const embedding = new Float32Array(512);
250
+ embedding[2] = cos;
251
+ embedding[axis] = Math.sqrt(1 - cos * cos);
252
+ return embedding;
253
+ }
254
+
255
+ const queryAxis = mix(1, 3);
256
+
257
+ async function searchRanked(queryEmbedding: Float32Array, queryText: string) {
258
+ const candidates = await store.search(queryEmbedding, scaleAgentId, {
259
+ scope: "agent",
260
+ limit: 15,
261
+ queryText,
262
+ });
263
+ const expanded = await expandCandidatesWithGraph(candidates, scaleAgentId, { scope: "agent" });
264
+ return rerank(expanded, { limit: 5 });
265
+ }
266
+
267
+ beforeAll(async () => {
268
+ prevHybridFlag = process.env.MEMORY_HYBRID_SEARCH;
269
+ process.env.MEMORY_HYBRID_SEARCH = "1";
270
+ await createAgent({
271
+ id: scaleAgentId,
272
+ name: "Scale Test Agent",
273
+ isLead: false,
274
+ status: "idle",
275
+ });
276
+ store = new SqliteMemoryStore();
277
+
278
+ const exact = await store.store({
279
+ agentId: scaleAgentId,
280
+ scope: "agent",
281
+ name: "blockerdigestexactname",
282
+ content: "Daily blocker digest for one specific day.",
283
+ source: "manual",
284
+ });
285
+ const sibling = await store.store({
286
+ agentId: scaleAgentId,
287
+ scope: "agent",
288
+ name: "digest for another day",
289
+ content: "Daily blocker digest for a different day.",
290
+ source: "manual",
291
+ });
292
+ const neighbor = await store.store({
293
+ agentId: scaleAgentId,
294
+ scope: "agent",
295
+ name: "linked follow-up",
296
+ content: "Follow-up linked from the digest.",
297
+ source: "manual",
298
+ });
299
+ exactId = exact.id;
300
+ siblingId = sibling.id;
301
+ neighborId = neighbor.id;
302
+ // The sibling is semantically CLOSER than the exact hit (as dated siblings
303
+ // are in production); only the keyword arm can put the exact name first.
304
+ await store.updateEmbedding(exactId, mix(0.7, 4), "test");
305
+ await store.updateEmbedding(siblingId, mix(0.72, 5), "test");
306
+ await store.updateEmbedding(neighborId, mix(0.2, 6), "test");
307
+ const now = new Date().toISOString();
308
+ await getDbClient().run(
309
+ `INSERT INTO memory_link
310
+ (id, from_memory_id, linkType, targetKind, targetId, strength, resolver, sourceText, metadata, createdAt, updatedAt)
311
+ VALUES (?, ?, 'wikilink', 'memory', ?, 1.0, 'wikilink', 'follow-up', NULL, ?, ?)`,
312
+ [crypto.randomUUID(), exactId, neighborId, now, now],
313
+ );
314
+ });
315
+
316
+ afterAll(() => {
317
+ if (prevHybridFlag === undefined) delete process.env.MEMORY_HYBRID_SEARCH;
318
+ else process.env.MEMORY_HYBRID_SEARCH = prevHybridFlag;
319
+ });
320
+
321
+ test("hybrid arm emits a [0,1] score instead of raw RRF", async () => {
322
+ const candidates = await store.search(queryAxis, scaleAgentId, {
323
+ scope: "agent",
324
+ limit: 10,
325
+ queryText: "blockerdigestexactname",
326
+ });
327
+ const exact = candidates.find((c) => c.id === exactId);
328
+ expect(exact?.retrievalSource).toBe("hybrid");
329
+ expect(exact!.similarity).toBeGreaterThan(0.6);
330
+ expect(exact!.similarity).toBeLessThanOrEqual(0.7);
331
+ expect(exact!.rawSimilarity).toBe(exact!.similarity);
332
+ expect(exact!.recencyDecayApplied).toBe(false);
333
+ });
334
+
335
+ test("exact-name direct hit outranks its graph neighbour and the closer sibling", async () => {
336
+ const ranked = await searchRanked(queryAxis, "blockerdigestexactname");
337
+ const ids = ranked.map((r) => r.id);
338
+ expect(ids[0]).toBe(exactId);
339
+ const neighbor = ranked.find((r) => r.id === neighborId);
340
+ expect(neighbor?.retrievalSource).toBe("graph");
341
+ expect(ids.indexOf(exactId)).toBeLessThan(ids.indexOf(neighborId));
342
+ expect(ids.indexOf(exactId)).toBeLessThan(ids.indexOf(siblingId));
343
+ });
344
+
345
+ test("a hybrid direct hit clears the injection threshold; its graph neighbour does not", async () => {
346
+ const ranked = await searchRanked(queryAxis, "blockerdigestexactname");
347
+ const exact = ranked.find((r) => r.id === exactId)!;
348
+ const neighbor = ranked.find((r) => r.id === neighborId)!;
349
+ expect(memoryRelevance(exact)).toBeGreaterThan(SIMILARITY_THRESHOLD);
350
+ expect(memoryRelevance(neighbor)).toBeLessThan(SIMILARITY_THRESHOLD);
351
+ const prompt = renderMemoriesPrompt(ranked);
352
+ expect(prompt).toContain(exactId);
353
+ expect(prompt).not.toContain(neighborId);
354
+ });
355
+
356
+ test("a vec-only direct hit (no keyword match) clears the injection threshold", async () => {
357
+ const ranked = await searchRanked(mix(0.99, 5), "paraphrasednokeywordmatch");
358
+ const sibling = ranked.find((r) => r.id === siblingId)!;
359
+ expect(sibling.retrievalSource).toBe("vec");
360
+ expect(memoryRelevance(sibling)).toBeGreaterThan(SIMILARITY_THRESHOLD);
361
+ });
362
+
363
+ test("negative control: a nonexistent-name query injects nothing", async () => {
364
+ // Best cosine ~0.33, the production level for an unrelated query. The old
365
+ // path returned it raw and rerank's 1.5x manual boost lifted it to ~0.5,
366
+ // over the old 0.4 composite gate.
367
+ const unrelated = new Float32Array(512);
368
+ unrelated[2] = 0.46;
369
+ unrelated[7] = Math.sqrt(1 - 0.46 ** 2);
370
+ const ranked = await searchRanked(unrelated, "blockerdigest-2031-01-01-nonexistent");
371
+ expect(ranked.length).toBeGreaterThan(0);
372
+ for (const r of ranked) expect(memoryRelevance(r)).toBeLessThan(SIMILARITY_THRESHOLD);
373
+ expect(renderMemoriesPrompt(ranked)).toBeNull();
374
+ });
375
+ });
@@ -0,0 +1,297 @@
1
+ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { unlink } from "node:fs/promises";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { Readable } from "node:stream";
6
+ import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
7
+ import * as realMemoryModule from "../be/memory";
8
+ import { expandCandidatesWithGraph } from "../be/memory/graph-expansion";
9
+ import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
10
+ import { rerank } from "../be/memory/reranker";
11
+ import { memoryRelevance, renderMemoriesPrompt, SIMILARITY_THRESHOLD } from "../prompts/memories";
12
+
13
+ // Crowd-out regression (PR #1689 review): graph neighbours get their own
14
+ // access and source boosts, so five boosted neighbours of one direct hit
15
+ // out-scored it on the composite, filled all five slots of the runner's
16
+ // `limit: 5` recall, and all failed the relevance gate. The rendered prompt
17
+ // was null while an eligible direct hit existed. Everything below the query
18
+ // embedding is real: SQLite store, hybrid fusion, graph expansion, reranker,
19
+ // the HTTP handler's selection, and the runner's prompt renderer.
20
+
21
+ // Capture the real exports BEFORE mock.module patches the registry entry.
22
+ const realMemoryExports = {
23
+ getEmbeddingProvider: realMemoryModule.getEmbeddingProvider,
24
+ getMemoryStore: realMemoryModule.getMemoryStore,
25
+ };
26
+
27
+ const TEST_DB_PATH = "./test-memory-prompt-recall-crowdout.sqlite";
28
+ const agentId = randomUUID();
29
+ const sourceTaskId = randomUUID();
30
+ const PARENT_NAME = "crowdoutparentexactname";
31
+ const NEIGHBOR_COUNT = 5;
32
+
33
+ /** Unit vector: `cos` on axis 2 (the query axis), the remainder on `axis`. */
34
+ function mix(cos: number, axis: number): Float32Array {
35
+ const embedding = new Float32Array(512);
36
+ embedding[2] = cos;
37
+ embedding[axis] = Math.sqrt(1 - cos * cos);
38
+ return embedding;
39
+ }
40
+
41
+ const queryEmbedding = mix(1, 3);
42
+
43
+ mock.module("../be/memory", () => ({
44
+ getEmbeddingProvider: () => ({
45
+ name: "test",
46
+ dimensions: 512,
47
+ embed: async () => queryEmbedding,
48
+ embedBatch: async (texts: string[]) => texts.map(() => queryEmbedding),
49
+ }),
50
+ getMemoryStore: () => new SqliteMemoryStore(),
51
+ }));
52
+
53
+ const { handleMemory } = await import("../http/memory");
54
+
55
+ let parentId: string;
56
+ const neighborIds: string[] = [];
57
+ let prevHybridFlag: string | undefined;
58
+
59
+ async function promptRecall(
60
+ callerAgentId: string = agentId,
61
+ query: string = PARENT_NAME,
62
+ ): Promise<{
63
+ results: Array<{
64
+ id: string;
65
+ name: string;
66
+ content: string;
67
+ similarity: number;
68
+ rawSimilarity?: number;
69
+ retrievalSource?: string;
70
+ }>;
71
+ }> {
72
+ // Byte-for-byte the runner's pre-task recall request (fetchRelevantMemories).
73
+ const body = { query, limit: 5, intent: "pre-task memory recall" };
74
+ const req = Readable.from([Buffer.from(JSON.stringify(body))]) as IncomingMessage;
75
+ req.method = "POST";
76
+ req.url = "/api/memory/search";
77
+ req.headers = {
78
+ "x-source-task-id": sourceTaskId,
79
+ "x-context-key": "task:crowdout",
80
+ "x-memory-consumption": "prompt",
81
+ };
82
+ let payload: any = null;
83
+ const res = {
84
+ writeHead() {
85
+ return this;
86
+ },
87
+ end(chunk?: unknown) {
88
+ payload = typeof chunk === "string" ? JSON.parse(chunk) : chunk;
89
+ return this;
90
+ },
91
+ } as unknown as ServerResponse;
92
+ const handled = await handleMemory(req, res, ["api", "memory", "search"], callerAgentId);
93
+ expect(handled).toBe(true);
94
+ return payload;
95
+ }
96
+
97
+ beforeAll(async () => {
98
+ prevHybridFlag = process.env.MEMORY_HYBRID_SEARCH;
99
+ process.env.MEMORY_HYBRID_SEARCH = "1";
100
+ for (const suffix of ["", "-wal", "-shm"]) {
101
+ try {
102
+ await unlink(TEST_DB_PATH + suffix);
103
+ } catch {}
104
+ }
105
+ initDb(TEST_DB_PATH);
106
+ await createAgent({ id: agentId, name: "Crowd-out Agent", isLead: false, status: "idle" });
107
+ const nowIso = new Date().toISOString();
108
+ await getDbClient().run(
109
+ `INSERT INTO agent_tasks (id, agentId, task, status, source, createdAt, lastUpdatedAt)
110
+ VALUES (?, ?, ?, 'in_progress', 'mcp', ?, ?)`,
111
+ [sourceTaskId, agentId, "crowd-out recall task", nowIso, nowIso],
112
+ );
113
+
114
+ const store = new SqliteMemoryStore();
115
+ // Fresh manual parent, cosine 0.7, matched by both arms, never accessed.
116
+ const parent = await store.store({
117
+ agentId,
118
+ scope: "agent",
119
+ name: PARENT_NAME,
120
+ content: "The direct hit this recall is looking for.",
121
+ source: "manual",
122
+ });
123
+ parentId = parent.id;
124
+ await store.updateEmbedding(parentId, mix(0.7, 4), "test");
125
+
126
+ // Five fresh manual neighbours, each linked at strength 1 and independently
127
+ // boosted by five recent accesses. Their own cosine is low; only the graph
128
+ // arm can surface them.
129
+ for (let i = 0; i < NEIGHBOR_COUNT; i++) {
130
+ const neighbor = await store.store({
131
+ agentId,
132
+ scope: "agent",
133
+ name: `linked note ${i}`,
134
+ content: `Linked follow-up note number ${i}.`,
135
+ source: "manual",
136
+ });
137
+ neighborIds.push(neighbor.id);
138
+ await store.updateEmbedding(neighbor.id, mix(0.1, 10 + i), "test");
139
+ await getDbClient().run(
140
+ `INSERT INTO memory_link
141
+ (id, from_memory_id, linkType, targetKind, targetId, strength, resolver, sourceText, metadata, createdAt, updatedAt)
142
+ VALUES (?, ?, 'wikilink', 'memory', ?, 1.0, 'wikilink', ?, NULL, ?, ?)`,
143
+ [randomUUID(), parentId, neighbor.id, `note ${i}`, nowIso, nowIso],
144
+ );
145
+ }
146
+ });
147
+
148
+ afterAll(async () => {
149
+ // bun's mock.module is process-global and never auto-restored.
150
+ mock.module("../be/memory", () => realMemoryExports);
151
+ if (prevHybridFlag === undefined) delete process.env.MEMORY_HYBRID_SEARCH;
152
+ else process.env.MEMORY_HYBRID_SEARCH = prevHybridFlag;
153
+ closeDb();
154
+ for (const suffix of ["", "-wal", "-shm"]) {
155
+ try {
156
+ await unlink(TEST_DB_PATH + suffix);
157
+ } catch {}
158
+ }
159
+ });
160
+
161
+ describe("pre-task recall with independently boosted graph neighbours", () => {
162
+ // Set boosts before each test: prompt recall records accesses on the rows it
163
+ // injects, so a previous test would otherwise shift the fixture.
164
+ async function boostFixture(): Promise<void> {
165
+ const nowIso = new Date().toISOString();
166
+ await getDbClient().run(
167
+ "UPDATE agent_memory SET accessCount = 0, accessedAt = ? WHERE id = ?",
168
+ [nowIso, parentId],
169
+ );
170
+ for (const id of neighborIds) {
171
+ await getDbClient().run(
172
+ "UPDATE agent_memory SET accessCount = 5, accessedAt = ? WHERE id = ?",
173
+ [nowIso, id],
174
+ );
175
+ }
176
+ }
177
+
178
+ test("fixture reproduces the review: the neighbours alone out-score the eligible parent", async () => {
179
+ await boostFixture();
180
+ const candidates = await new SqliteMemoryStore().search(queryEmbedding, agentId, {
181
+ scope: "all",
182
+ limit: 15,
183
+ queryText: PARENT_NAME,
184
+ });
185
+ const expanded = await expandCandidatesWithGraph(candidates, agentId, { scope: "all" });
186
+ const scored = expanded.map((c) => ({ c, score: rerank([c], { limit: 1 })[0]! }));
187
+ const parent = scored.find((s) => s.c.id === parentId)!;
188
+ const neighbors = scored.filter((s) => neighborIds.includes(s.c.id));
189
+
190
+ expect(parent.c.retrievalSource).toBe("hybrid");
191
+ expect(memoryRelevance(parent.score)).toBeGreaterThan(SIMILARITY_THRESHOLD);
192
+ expect(neighbors).toHaveLength(NEIGHBOR_COUNT);
193
+ for (const n of neighbors) {
194
+ expect(n.c.retrievalSource).toBe("graph");
195
+ expect(memoryRelevance(n.score)).toBeLessThan(SIMILARITY_THRESHOLD);
196
+ // Scored in isolation, each neighbour's boosted composite beats the parent's.
197
+ expect(n.score.compositeScore!).toBeGreaterThan(parent.score.compositeScore!);
198
+ }
199
+ });
200
+
201
+ test("a parent outranks its own boosted graph neighbours in the reranked top five", async () => {
202
+ await boostFixture();
203
+ const candidates = await new SqliteMemoryStore().search(queryEmbedding, agentId, {
204
+ scope: "all",
205
+ limit: 15,
206
+ queryText: PARENT_NAME,
207
+ });
208
+ const expanded = await expandCandidatesWithGraph(candidates, agentId, { scope: "all" });
209
+ const ranked = rerank(expanded, { limit: 5 });
210
+ expect(ranked[0]!.id).toBe(parentId);
211
+ const parentScore = ranked[0]!.compositeScore!;
212
+ for (const r of ranked.slice(1)) {
213
+ if (neighborIds.includes(r.id)) expect(r.compositeScore!).toBeLessThanOrEqual(parentScore);
214
+ }
215
+ });
216
+
217
+ test("the runner's five-slot prompt recall injects the parent instead of rendering null", async () => {
218
+ await boostFixture();
219
+ const { results } = await promptRecall();
220
+ expect(results.length).toBeGreaterThan(0);
221
+ expect(results.length).toBeLessThanOrEqual(5);
222
+ expect(results[0]!.id).toBe(parentId);
223
+
224
+ const prompt = renderMemoriesPrompt(results);
225
+ expect(prompt).not.toBeNull();
226
+ expect(prompt).toContain(parentId);
227
+ for (const id of neighborIds) expect(prompt).not.toContain(id);
228
+
229
+ const recorded = await getDbClient().query<{ memoryId: string; relevance: number | null }>(
230
+ "SELECT memoryId, relevance FROM memory_retrieval WHERE taskId = ?",
231
+ [sourceTaskId],
232
+ );
233
+ expect(recorded.map((r) => r.memoryId)).toContain(parentId);
234
+ const access = await getDbClient().get<{ accessCount: number }>(
235
+ "SELECT accessCount FROM agent_memory WHERE id = ?",
236
+ [parentId],
237
+ );
238
+ expect(access!.accessCount).toBe(1);
239
+ });
240
+
241
+ test("boosted low-relevance direct rows do not take the slots of an eligible hit", async () => {
242
+ // No graph here: an eligible task_completion hit (0.7x source quality)
243
+ // against five manual rows that match only on the vector arm and carry
244
+ // five recent accesses each. The composite puts all five above the hit;
245
+ // the prompt selection must still give the hit a slot.
246
+ const otherAgentId = randomUUID();
247
+ const hitName = "crowdoutsecondexactname";
248
+ await createAgent({
249
+ id: otherAgentId,
250
+ name: "Crowd-out Agent 2",
251
+ isLead: false,
252
+ status: "idle",
253
+ });
254
+ const store = new SqliteMemoryStore();
255
+ const hit = await store.store({
256
+ agentId: otherAgentId,
257
+ scope: "agent",
258
+ name: hitName,
259
+ content: "An eligible hit from a completed task.",
260
+ source: "task_completion",
261
+ });
262
+ await store.updateEmbedding(hit.id, mix(0.75, 4), "test");
263
+ const boostedIds: string[] = [];
264
+ const nowIso = new Date().toISOString();
265
+ for (let i = 0; i < NEIGHBOR_COUNT; i++) {
266
+ const row = await store.store({
267
+ agentId: otherAgentId,
268
+ scope: "agent",
269
+ name: `unrelated manual ${i}`,
270
+ content: `Loosely related manual note ${i}.`,
271
+ source: "manual",
272
+ });
273
+ boostedIds.push(row.id);
274
+ await store.updateEmbedding(row.id, mix(0.5, 20 + i), "test");
275
+ await getDbClient().run(
276
+ "UPDATE agent_memory SET accessCount = 5, accessedAt = ? WHERE id = ?",
277
+ [nowIso, row.id],
278
+ );
279
+ }
280
+
281
+ const candidates = await store.search(queryEmbedding, otherAgentId, {
282
+ scope: "all",
283
+ limit: 15,
284
+ queryText: hitName,
285
+ });
286
+ const byComposite = rerank(candidates, { limit: 5 });
287
+ expect(byComposite.map((r) => r.id)).not.toContain(hit.id);
288
+
289
+ const { results } = await promptRecall(otherAgentId, hitName);
290
+ expect(results).toHaveLength(5);
291
+ expect(results[0]!.id).toBe(hit.id);
292
+ expect(memoryRelevance(results[0]!)).toBeGreaterThan(SIMILARITY_THRESHOLD);
293
+ const prompt = renderMemoriesPrompt(results);
294
+ expect(prompt).toContain(hit.id);
295
+ for (const id of boostedIds) expect(prompt).not.toContain(id);
296
+ });
297
+ });