@desplega.ai/agent-swarm 1.92.0 → 1.92.2

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 (90) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +276 -3
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +416 -20
  6. package/src/be/memory/boot-reembed.ts +85 -0
  7. package/src/be/memory/constants.ts +44 -2
  8. package/src/be/memory/providers/openai-embedding.ts +15 -5
  9. package/src/be/memory/providers/sqlite-store.ts +325 -76
  10. package/src/be/memory/reranker.ts +35 -17
  11. package/src/be/memory/types.ts +43 -0
  12. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  13. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  14. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  15. package/src/be/migrations/087_skill_files.sql +19 -0
  16. package/src/be/modelsdev-cache.json +5622 -2543
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +465 -0
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  25. package/src/be/seed-scripts/index.ts +32 -4
  26. package/src/be/seed-skills/index.ts +0 -7
  27. package/src/be/skill-sync.ts +91 -7
  28. package/src/commands/runner.ts +6 -2
  29. package/src/heartbeat/templates.ts +20 -16
  30. package/src/http/index.ts +50 -7
  31. package/src/http/mcp-user.ts +23 -0
  32. package/src/http/mcp.ts +58 -0
  33. package/src/http/memory.ts +62 -0
  34. package/src/http/pages.ts +1 -1
  35. package/src/http/script-runs.ts +2 -0
  36. package/src/http/scripts.ts +39 -2
  37. package/src/http/skills.ts +225 -0
  38. package/src/providers/claude-adapter.ts +56 -24
  39. package/src/script-workflows/workflow-ctx.ts +7 -3
  40. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  42. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  44. package/src/server.ts +2 -0
  45. package/src/tasks/worker-follow-up.ts +12 -0
  46. package/src/tests/claude-adapter-binary.test.ts +135 -81
  47. package/src/tests/create-page-tool.test.ts +19 -2
  48. package/src/tests/heartbeat-checklist.test.ts +36 -0
  49. package/src/tests/mcp-transport-gc.test.ts +58 -0
  50. package/src/tests/memory-e2e.test.ts +6 -6
  51. package/src/tests/memory-health-endpoint.test.ts +78 -0
  52. package/src/tests/memory-rater-e2e.test.ts +4 -5
  53. package/src/tests/memory-reranker.test.ts +135 -124
  54. package/src/tests/memory-store.test.ts +221 -1
  55. package/src/tests/memory.test.ts +13 -12
  56. package/src/tests/pages-http.test.ts +20 -2
  57. package/src/tests/pages-storage.test.ts +26 -0
  58. package/src/tests/scripts-mcp-e2e.test.ts +53 -0
  59. package/src/tests/seed-scripts.test.ts +328 -3
  60. package/src/tests/skill-files-http.test.ts +171 -0
  61. package/src/tests/skill-files.test.ts +162 -0
  62. package/src/tests/skill-get-file-tool.test.ts +110 -0
  63. package/src/tests/skill-sync.test.ts +125 -6
  64. package/src/tests/task-cascade-fail.test.ts +304 -0
  65. package/src/tools/create-page.ts +2 -2
  66. package/src/tools/skills/index.ts +1 -0
  67. package/src/tools/skills/skill-get-file.ts +80 -0
  68. package/src/tools/tool-config.ts +2 -1
  69. package/src/types.ts +20 -0
  70. package/src/utils/internal-ai/complete-structured.ts +2 -2
  71. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  72. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  73. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  74. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  75. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  76. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  77. package/templates/skills/agentmail-sending/content.md +6 -7
  78. package/templates/skills/desloppify/content.md +8 -9
  79. package/templates/skills/jira-interaction/content.md +25 -33
  80. package/templates/skills/kapso-whatsapp/content.md +29 -30
  81. package/templates/skills/linear-interaction/content.md +8 -9
  82. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  83. package/templates/skills/sprite-cli/content.md +4 -5
  84. package/templates/skills/turso-interaction/content.md +14 -17
  85. package/templates/skills/workflow-iterate/content.md +38 -391
  86. package/templates/skills/x-api-interactions/content.md +4 -6
  87. package/templates/workflows/llm-safe-release-context/config.json +13 -0
  88. package/templates/workflows/llm-safe-release-context/content.md +69 -0
  89. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  90. package/templates/skills/scheduled-task-resilience/content.md +0 -95
@@ -123,13 +123,13 @@ describe("Memory E2E Lifecycle", () => {
123
123
 
124
124
  describe("reranking affects result order", () => {
125
125
  test("newer memory with same embedding ranks higher", () => {
126
- // Create old memory
126
+ // Use task_completion source so recency decay applies (manual has no decay)
127
127
  const old = store.store({
128
128
  agentId: agentA,
129
129
  scope: "agent",
130
130
  name: "old knowledge",
131
131
  content: "Old deployment docs",
132
- source: "manual",
132
+ source: "task_completion",
133
133
  });
134
134
  store.updateEmbedding(old.id, new Float32Array([0.5, 0.5, 0.0]), "test-model");
135
135
 
@@ -144,7 +144,7 @@ describe("Memory E2E Lifecycle", () => {
144
144
  scope: "agent",
145
145
  name: "fresh knowledge",
146
146
  content: "New deployment docs",
147
- source: "manual",
147
+ source: "task_completion",
148
148
  });
149
149
  store.updateEmbedding(fresh.id, new Float32Array([0.5, 0.5, 0.0]), "test-model");
150
150
 
@@ -302,7 +302,7 @@ describe("Memory E2E Lifecycle", () => {
302
302
  content: "Agent A only",
303
303
  source: "manual",
304
304
  });
305
- store.updateEmbedding(m1.id, new Float32Array([1, 0, 0]), "test-model");
305
+ store.updateEmbedding(m1.id, new Float32Array([1, 0.3, 0.3]), "test-model");
306
306
  agentMemId = m1.id;
307
307
 
308
308
  const m2 = store.store({
@@ -312,7 +312,7 @@ describe("Memory E2E Lifecycle", () => {
312
312
  content: "Visible to all",
313
313
  source: "manual",
314
314
  });
315
- store.updateEmbedding(m2.id, new Float32Array([0, 1, 0]), "test-model");
315
+ store.updateEmbedding(m2.id, new Float32Array([0.3, 1, 0.3]), "test-model");
316
316
  swarmMemId = m2.id;
317
317
 
318
318
  const m3 = store.store({
@@ -322,7 +322,7 @@ describe("Memory E2E Lifecycle", () => {
322
322
  content: "Agent B only",
323
323
  source: "manual",
324
324
  });
325
- store.updateEmbedding(m3.id, new Float32Array([0, 0, 1]), "test-model");
325
+ store.updateEmbedding(m3.id, new Float32Array([0.3, 0.3, 1]), "test-model");
326
326
  otherAgentMemId = m3.id;
327
327
  });
328
328
 
@@ -0,0 +1,78 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { closeDb, initDb } from "../be/db";
5
+ import { EMBEDDING_DIMENSIONS } from "../be/memory/constants";
6
+ import { handleMemory } from "../http/memory";
7
+ import { getPathSegments } from "../http/utils";
8
+
9
+ const TEST_DB_PATH = "./test-memory-health-endpoint.sqlite";
10
+
11
+ function fakeReqRes(path: string) {
12
+ const req = {
13
+ method: "GET",
14
+ url: path,
15
+ headers: {},
16
+ } as unknown as IncomingMessage;
17
+
18
+ const captured = { status: 0, body: "" };
19
+ const res = {
20
+ writeHead(status: number) {
21
+ captured.status = status;
22
+ return this;
23
+ },
24
+ end(chunk?: string) {
25
+ if (chunk) captured.body = chunk;
26
+ return this;
27
+ },
28
+ } as unknown as ServerResponse;
29
+
30
+ return { req, res, captured };
31
+ }
32
+
33
+ describe("GET /api/memory/health", () => {
34
+ beforeAll(async () => {
35
+ for (const suffix of ["", "-wal", "-shm"]) {
36
+ try {
37
+ await unlink(TEST_DB_PATH + suffix);
38
+ } catch {}
39
+ }
40
+ initDb(TEST_DB_PATH);
41
+ });
42
+
43
+ afterAll(async () => {
44
+ closeDb();
45
+ for (const suffix of ["", "-wal", "-shm"]) {
46
+ try {
47
+ await unlink(TEST_DB_PATH + suffix);
48
+ } catch {}
49
+ }
50
+ });
51
+
52
+ test("returns vector index health JSON", async () => {
53
+ const { req, res, captured } = fakeReqRes("/api/memory/health");
54
+ const handled = await handleMemory(req, res, getPathSegments(req.url || ""), undefined);
55
+
56
+ expect(handled).toBe(true);
57
+ expect(captured.status).toBe(200);
58
+
59
+ const body = JSON.parse(captured.body);
60
+ expect(body).toMatchObject({
61
+ sqliteVec: {
62
+ extensionLoaded: expect.any(Boolean),
63
+ tableExists: expect.any(Boolean),
64
+ initialized: expect.any(Boolean),
65
+ vectorDimensions: EMBEDDING_DIMENSIONS,
66
+ distanceMetric: "cosine",
67
+ },
68
+ counts: {
69
+ total: expect.any(Number),
70
+ withEmbedding: expect.any(Number),
71
+ searchable: expect.any(Number),
72
+ memoryVec: expect.any(Number),
73
+ },
74
+ retrievalMode: expect.any(String),
75
+ reasons: expect.any(Array),
76
+ });
77
+ });
78
+ });
@@ -558,13 +558,12 @@ describe("memory-rater v1.5 — cross-cutting e2e", () => {
558
558
  );
559
559
 
560
560
  // The usefulness factor at Beta(1,1) is exactly 1.0; a memory with no
561
- // ratings should score within numerical noise of similarity * recency *
562
- // access (the original pre-v1.5 formula).
561
+ // ratings should score = similarity * recency * access * sourceQuality * usefulness.
562
+ // For source=manual: sourceQuality=1.5, recency=1.0 (no decay for manual),
563
+ // access=1.0, usefulness=1.0. So score = 0.5 * 1.5 = 0.75.
563
564
  const fresh = buildCandidate(0.5);
564
565
  const score = rerank([fresh], { limit: 1 })[0]!.similarity;
565
- // recency at age = 0 is exactly 1; access_boost at count=0 is exactly 1;
566
- // usefulness at (1,1) is exactly 1. So score === 0.5 to machine precision.
567
- expect(score).toBeCloseTo(0.5, 10);
566
+ expect(score).toBeCloseTo(0.75, 10);
568
567
  });
569
568
  });
570
569
 
@@ -1,5 +1,12 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { accessBoost, computeScore, recencyDecay, rerank, usefulness } from "../be/memory/reranker";
2
+ import {
3
+ accessBoost,
4
+ computeScore,
5
+ recencyDecay,
6
+ rerank,
7
+ sourceQuality,
8
+ usefulness,
9
+ } from "../be/memory/reranker";
3
10
  import type { MemoryCandidate } from "../be/memory/types";
4
11
 
5
12
  function makeCandidate(
@@ -37,21 +44,33 @@ describe("recencyDecay", () => {
37
44
  expect(decay).toBeCloseTo(1.0, 5);
38
45
  });
39
46
 
40
- test("memory at half-life (14d) → ~0.5", () => {
47
+ test("task_completion at half-life (14d) → ~0.5", () => {
41
48
  const created = new Date(now.getTime() - 14 * 86400000).toISOString();
42
- const decay = recencyDecay(created, now);
49
+ const decay = recencyDecay(created, now, "task_completion");
43
50
  expect(decay).toBeCloseTo(0.5, 2);
44
51
  });
45
52
 
46
- test("memory at half-life (28d) → ~0.25", () => {
47
- const created = new Date(now.getTime() - 28 * 86400000).toISOString();
48
- const decay = recencyDecay(created, now);
49
- expect(decay).toBeCloseTo(0.25, 2);
53
+ test("session_summary at 7d → ~0.5 (7d half-life)", () => {
54
+ const created = new Date(now.getTime() - 7 * 86400000).toISOString();
55
+ const decay = recencyDecay(created, now, "session_summary");
56
+ expect(decay).toBeCloseTo(0.5, 2);
57
+ });
58
+
59
+ test("file_index at 180d → ~0.5 (180d half-life)", () => {
60
+ const created = new Date(now.getTime() - 180 * 86400000).toISOString();
61
+ const decay = recencyDecay(created, now, "file_index");
62
+ expect(decay).toBeCloseTo(0.5, 2);
50
63
  });
51
64
 
52
- test("very old memory (365d)near 0", () => {
65
+ test("manual memory at any age 1.0 (no decay)", () => {
53
66
  const created = new Date(now.getTime() - 365 * 86400000).toISOString();
54
- const decay = recencyDecay(created, now);
67
+ const decay = recencyDecay(created, now, "manual");
68
+ expect(decay).toBe(1.0);
69
+ });
70
+
71
+ test("very old task_completion (365d) → near 0", () => {
72
+ const created = new Date(now.getTime() - 365 * 86400000).toISOString();
73
+ const decay = recencyDecay(created, now, "task_completion");
55
74
  expect(decay).toBeLessThan(0.001);
56
75
  });
57
76
 
@@ -60,6 +79,12 @@ describe("recencyDecay", () => {
60
79
  const decay = recencyDecay(created, now);
61
80
  expect(decay).toBe(1.0);
62
81
  });
82
+
83
+ test("no source provided → falls back to task_completion half-life", () => {
84
+ const created = new Date(now.getTime() - 14 * 86400000).toISOString();
85
+ const decay = recencyDecay(created, now);
86
+ expect(decay).toBeCloseTo(0.5, 2);
87
+ });
63
88
  });
64
89
 
65
90
  describe("accessBoost", () => {
@@ -93,31 +118,71 @@ describe("accessBoost", () => {
93
118
  });
94
119
  });
95
120
 
121
+ describe("sourceQuality", () => {
122
+ test("manual → 1.5", () => {
123
+ expect(sourceQuality("manual")).toBe(1.5);
124
+ });
125
+
126
+ test("file_index → 1.0", () => {
127
+ expect(sourceQuality("file_index")).toBe(1.0);
128
+ });
129
+
130
+ test("task_completion → 0.7", () => {
131
+ expect(sourceQuality("task_completion")).toBe(0.7);
132
+ });
133
+
134
+ test("session_summary → 0.5", () => {
135
+ expect(sourceQuality("session_summary")).toBe(0.5);
136
+ });
137
+ });
138
+
96
139
  describe("computeScore", () => {
97
140
  const now = new Date("2026-04-12T12:00:00Z");
98
141
 
99
- test("multiplies similarity × decay × boost", () => {
142
+ test("manual: similarity × 1.0 (no decay) × source(1.5) × boost × usefulness", () => {
100
143
  const candidate = makeCandidate({
101
144
  similarity: 0.8,
145
+ source: "manual",
102
146
  createdAt: now.toISOString(),
103
147
  accessedAt: now.toISOString(),
104
148
  accessCount: 0,
105
149
  });
106
150
  const score = computeScore(candidate, now);
107
- // 0.8 * 1.0 * 1.0 = 0.8
108
- expect(score).toBeCloseTo(0.8, 5);
151
+ // 0.8 * 1.0 (no decay for manual) * 1.0 (no boost) * 1.5 (source) * 1.0 (usefulness) = 1.2
152
+ expect(score).toBeCloseTo(1.2, 5);
109
153
  });
110
154
 
111
- test("old memory with no access gets penalized", () => {
155
+ test("task_completion at 14d penalized by decay AND source multiplier", () => {
112
156
  const candidate = makeCandidate({
113
157
  similarity: 0.8,
158
+ source: "task_completion",
114
159
  createdAt: new Date(now.getTime() - 14 * 86400000).toISOString(),
115
160
  accessedAt: new Date(now.getTime() - 14 * 86400000).toISOString(),
116
161
  accessCount: 0,
117
162
  });
118
163
  const score = computeScore(candidate, now);
119
- // 0.8 * 0.5 * 1.0 = 0.4
120
- expect(score).toBeCloseTo(0.4, 2);
164
+ // 0.8 * 0.5 (14d decay) * 1.0 (no boost) * 0.7 (source) * 1.0 (usefulness) = 0.28
165
+ expect(score).toBeCloseTo(0.28, 2);
166
+ });
167
+
168
+ test("old manual vs fresh task_completion: manual wins on relevance", () => {
169
+ const oldManual = makeCandidate({
170
+ similarity: 0.8,
171
+ source: "manual",
172
+ createdAt: new Date(now.getTime() - 76 * 86400000).toISOString(),
173
+ accessedAt: new Date(now.getTime() - 76 * 86400000).toISOString(),
174
+ accessCount: 0,
175
+ });
176
+ const freshTC = makeCandidate({
177
+ similarity: 0.05,
178
+ source: "task_completion",
179
+ createdAt: new Date(now.getTime() - 1 * 86400000).toISOString(),
180
+ accessedAt: new Date(now.getTime() - 1 * 86400000).toISOString(),
181
+ accessCount: 0,
182
+ });
183
+ // This is THE bug we're fixing: with the old flat 14d decay, the old manual
184
+ // memory scored lower than fresh noise. Now manual has no decay.
185
+ expect(computeScore(oldManual, now)).toBeGreaterThan(computeScore(freshTC, now));
121
186
  });
122
187
  });
123
188
 
@@ -166,36 +231,51 @@ describe("rerank", () => {
166
231
  expect(result[0]!.similarity).toBeGreaterThan(result[1]!.similarity);
167
232
  });
168
233
 
169
- test("recency boosts newer memory over older with same raw similarity", () => {
234
+ test("recency boosts newer task_completion over older with same raw similarity", () => {
170
235
  const candidates = [
171
236
  makeCandidate({
172
237
  similarity: 0.8,
173
- createdAt: new Date(now.getTime() - 14 * 86400000).toISOString(), // 14d old
238
+ source: "task_completion",
239
+ createdAt: new Date(now.getTime() - 14 * 86400000).toISOString(),
174
240
  }),
175
241
  makeCandidate({
176
242
  similarity: 0.8,
177
- createdAt: now.toISOString(), // fresh
243
+ source: "task_completion",
244
+ createdAt: now.toISOString(),
178
245
  }),
179
246
  ];
180
247
  const result = rerank(candidates, { limit: 2, now });
181
- // Fresh memory should rank higher due to recency decay
182
248
  expect(result[0]!.createdAt).toBe(now.toISOString());
183
249
  });
184
250
 
185
251
  test("now parameter enables deterministic testing", () => {
186
252
  const candidate = makeCandidate({
187
253
  similarity: 0.8,
254
+ source: "task_completion",
188
255
  createdAt: new Date(now.getTime() - 7 * 86400000).toISOString(),
189
256
  });
190
257
  const result1 = rerank([candidate], { limit: 1, now });
191
258
  const result2 = rerank([candidate], { limit: 1, now });
192
259
  expect(result1[0]!.similarity).toBe(result2[0]!.similarity);
193
260
  });
261
+
262
+ test("preserves rawSimilarity and compositeScore", () => {
263
+ const candidate = makeCandidate({
264
+ similarity: 0.8,
265
+ source: "manual",
266
+ createdAt: now.toISOString(),
267
+ });
268
+ const result = rerank([candidate], { limit: 1, now });
269
+ expect(result[0]!.rawSimilarity).toBe(0.8);
270
+ expect(result[0]!.compositeScore).toBeDefined();
271
+ // For a fresh manual memory: 0.8 * 1.0 (no decay) * 1.0 (no boost) * 1.5 (source) * 1.0 (usefulness)
272
+ expect(result[0]!.compositeScore).toBeCloseTo(1.2, 5);
273
+ // similarity field = compositeScore
274
+ expect(result[0]!.similarity).toBe(result[0]!.compositeScore);
275
+ });
194
276
  });
195
277
 
196
278
  describe("usefulness", () => {
197
- // The default-floor cases assume MEMORY_DEMOTION_FLOOR is unset/empty.
198
- // The override case sets and restores the env var.
199
279
  let originalFloor: string | undefined;
200
280
  beforeEach(() => {
201
281
  originalFloor = process.env.MEMORY_DEMOTION_FLOOR;
@@ -224,10 +304,6 @@ describe("usefulness", () => {
224
304
  });
225
305
 
226
306
  test("Beta(50,1) → 2 * 50/51 ≈ 1.961 (approaches ceiling, never above 2.0)", () => {
227
- // NB: the clamp `Math.min(2.0, 2 * mean)` is a defensive ceiling — the
228
- // formula 2 * α/(α+β) is bounded above by 2 for any finite β > 0, so the
229
- // clamp only fires on degenerate inputs (β = 0). The plan's "===2.0"
230
- // expectation was a numerical slip; the asymptote is what we ship.
231
307
  expect(usefulness(50, 1)).toBeCloseTo((2 * 50) / 51, 10);
232
308
  expect(usefulness(50, 1)).toBeLessThan(2.0);
233
309
  });
@@ -242,110 +318,45 @@ describe("usefulness", () => {
242
318
  });
243
319
  });
244
320
 
245
- describe("backward-compat: MEMORY_RATERS unset reranker is a no-op", () => {
246
- // Litmus for step-1: with default Beta(1,1) priors and the default
247
- // MEMORY_DEMOTION_FLOOR=1.0, computeScore must return EXACTLY the same value
248
- // as a pre-rater build (similarity * recencyDecay * accessBoost).
249
- const now = new Date("2026-04-12T12:00:00Z");
250
-
251
- let originalFloor: string | undefined;
252
- beforeEach(() => {
253
- originalFloor = process.env.MEMORY_DEMOTION_FLOOR;
254
- delete process.env.MEMORY_DEMOTION_FLOOR;
255
- });
256
- afterEach(() => {
257
- if (originalFloor === undefined) {
258
- delete process.env.MEMORY_DEMOTION_FLOOR;
259
- } else {
260
- process.env.MEMORY_DEMOTION_FLOOR = originalFloor;
261
- }
262
- });
263
-
264
- test("computeScore equals similarity * recencyDecay * accessBoost (no usefulness drift)", () => {
265
- const cases: MemoryCandidate[] = [
266
- makeCandidate({
267
- similarity: 0.8,
268
- createdAt: now.toISOString(),
269
- accessedAt: now.toISOString(),
270
- accessCount: 0,
271
- }),
272
- makeCandidate({
273
- similarity: 0.5,
274
- createdAt: new Date(now.getTime() - 14 * 86400000).toISOString(),
275
- accessedAt: new Date(now.getTime() - 24 * 3600000).toISOString(),
276
- accessCount: 5,
277
- }),
278
- makeCandidate({
279
- similarity: 0.99,
280
- createdAt: new Date(now.getTime() - 28 * 86400000).toISOString(),
281
- accessedAt: new Date(now.getTime() - 72 * 3600000).toISOString(),
282
- accessCount: 12,
283
- }),
284
- ];
285
-
286
- for (const c of cases) {
287
- const expected =
288
- c.similarity *
289
- recencyDecay(c.createdAt, now) *
290
- accessBoost(c.accessedAt, c.accessCount, now);
291
- expect(computeScore(c, now)).toBe(expected);
292
- }
293
- });
321
+ describe("source-aware scoring: manual memories survive age penalty", () => {
322
+ const now = new Date("2026-06-08T12:00:00Z");
294
323
 
295
- test("snapshot order + scores match a hard-coded pre-rater baseline", () => {
296
- // Baseline computed from main (pre-step-1): similarity * recencyDecay * accessBoost.
297
- // With alpha=beta=1 + default floor, the new code must produce identical numbers.
298
- const candidates = [
299
- makeCandidate({
300
- similarity: 0.9,
301
- createdAt: now.toISOString(),
302
- accessedAt: now.toISOString(),
303
- accessCount: 0,
304
- }),
305
- makeCandidate({
306
- similarity: 0.6,
307
- createdAt: new Date(now.getTime() - 7 * 86400000).toISOString(),
308
- accessedAt: now.toISOString(),
309
- accessCount: 0,
310
- }),
311
- makeCandidate({
312
- similarity: 0.3,
313
- createdAt: new Date(now.getTime() - 28 * 86400000).toISOString(),
314
- accessedAt: now.toISOString(),
315
- accessCount: 0,
316
- }),
317
- ];
318
- const result = rerank(candidates, { limit: 3, now });
324
+ test("76-day-old manual memory scores higher than 1-day-old noise task_completion", () => {
325
+ // The root-cause scenario from Taras's report: a 76-day-old manual memory
326
+ // with raw similarity 0.8 was being outscored by a 1-day-old noise result
327
+ // with raw similarity 0.05. The old reranker gave the noise result a HIGHER
328
+ // composite score because the flat 14d half-life crushed the old manual
329
+ // memory by 2^(-76/14) = 0.023. Now manual has no decay.
330
+ const oldManual = makeCandidate({
331
+ similarity: 0.8,
332
+ source: "manual",
333
+ createdAt: new Date(now.getTime() - 76 * 86400000).toISOString(),
334
+ accessedAt: new Date(now.getTime() - 76 * 86400000).toISOString(),
335
+ accessCount: 0,
336
+ });
337
+ const freshNoise = makeCandidate({
338
+ similarity: 0.05,
339
+ source: "task_completion",
340
+ createdAt: new Date(now.getTime() - 1 * 86400000).toISOString(),
341
+ accessedAt: new Date(now.getTime() - 1 * 86400000).toISOString(),
342
+ accessCount: 0,
343
+ });
319
344
 
320
- // Expected scores: similarity * 2^(-ageDays/14) (no access boost, alpha=beta=1).
321
- // 0.9 * 1.0 = 0.9
322
- // 0.6 * 2^(-0.5) ≈ 0.4242640687
323
- // 0.3 * 2^(-2) = 0.075
324
- expect(result[0]!.similarity).toBeCloseTo(0.9, 10);
325
- expect(result[1]!.similarity).toBeCloseTo(0.6 * 2 ** -0.5, 10);
326
- expect(result[2]!.similarity).toBeCloseTo(0.075, 10);
345
+ const ranked = rerank([freshNoise, oldManual], { limit: 2, now });
346
+ expect(ranked[0]!.source).toBe("manual");
347
+ expect(ranked[0]!.rawSimilarity).toBe(0.8);
327
348
  });
328
349
 
329
- test("usefulness multiplies into score when posteriors move", () => {
330
- // Sanity: a memory with α=10, β=1 should score ~1.818× higher than the same
331
- // memory at α=β=1, holding everything else constant. Other rows unchanged.
332
- const proven = makeCandidate({
333
- similarity: 0.5,
334
- createdAt: now.toISOString(),
335
- accessedAt: now.toISOString(),
336
- accessCount: 0,
337
- alpha: 10,
338
- beta: 1,
339
- });
340
- const baseline = makeCandidate({
341
- similarity: 0.5,
342
- createdAt: now.toISOString(),
343
- accessedAt: now.toISOString(),
350
+ test("session_summary decays fast (7d half-life)", () => {
351
+ const oldSummary = makeCandidate({
352
+ similarity: 0.8,
353
+ source: "session_summary",
354
+ createdAt: new Date(now.getTime() - 14 * 86400000).toISOString(),
355
+ accessedAt: new Date(now.getTime() - 14 * 86400000).toISOString(),
344
356
  accessCount: 0,
345
357
  });
346
- expect(computeScore(proven, now) / computeScore(baseline, now)).toBeCloseTo(
347
- usefulness(10, 1),
348
- 10,
349
- );
358
+ // At 14d with 7d half-life: decay = 2^(-14/7) = 0.25
359
+ // Score: 0.8 * 0.25 * 0.5 (source) = 0.1
360
+ expect(computeScore(oldSummary, now)).toBeCloseTo(0.1, 2);
350
361
  });
351
362
  });