@desplega.ai/agent-swarm 1.64.0 → 1.65.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.
- package/openapi.json +42 -1
- package/package.json +3 -1
- package/src/be/db.ts +18 -290
- package/src/be/embedding.ts +0 -38
- package/src/be/memory/constants.ts +26 -0
- package/src/be/memory/index.ts +22 -0
- package/src/be/memory/providers/openai-embedding.ts +94 -0
- package/src/be/memory/providers/sqlite-store.ts +530 -0
- package/src/be/memory/reranker.ts +59 -0
- package/src/be/memory/types.ts +83 -0
- package/src/be/migrations/036_memory_ttl_staleness.sql +8 -0
- package/src/http/memory.ts +99 -46
- package/src/server.ts +2 -0
- package/src/tests/artifact-sdk.test.ts +2 -1
- package/src/tests/memory-e2e.test.ts +453 -0
- package/src/tests/memory-reranker.test.ts +192 -0
- package/src/tests/memory-store.test.ts +330 -0
- package/src/tests/memory.test.ts +105 -121
- package/src/tests/package-publish.test.ts +47 -0
- package/src/tests/self-improvement.test.ts +18 -19
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tools/inject-learning.ts +7 -5
- package/src/tools/memory-delete.ts +89 -0
- package/src/tools/memory-get.ts +2 -2
- package/src/tools/memory-search.ts +13 -7
- package/src/tools/store-progress.ts +12 -11
- package/src/tools/tool-config.ts +1 -0
- package/src/types.ts +3 -0
- package/tsconfig.json +49 -0
package/src/http/memory.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { chunkContent } from "../be/chunking";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
getDb,
|
|
8
|
-
searchMemoriesByVector,
|
|
9
|
-
updateMemoryEmbedding,
|
|
10
|
-
} from "../be/db";
|
|
11
|
-
import { getEmbedding, serializeEmbedding } from "../be/embedding";
|
|
4
|
+
import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
|
|
5
|
+
import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
|
|
6
|
+
import { rerank } from "../be/memory/reranker";
|
|
12
7
|
import { AgentMemoryScopeSchema, AgentMemorySourceSchema } from "../types";
|
|
13
8
|
import { route } from "./route-def";
|
|
14
9
|
import { json, jsonError } from "./utils";
|
|
@@ -54,6 +49,26 @@ const searchMemory = route({
|
|
|
54
49
|
},
|
|
55
50
|
});
|
|
56
51
|
|
|
52
|
+
const reEmbedMemory = route({
|
|
53
|
+
method: "post",
|
|
54
|
+
path: "/api/memory/re-embed",
|
|
55
|
+
pattern: ["api", "memory", "re-embed"],
|
|
56
|
+
summary: "Re-embed all memories using the current embedding provider",
|
|
57
|
+
tags: ["Memory"],
|
|
58
|
+
auth: { apiKey: true },
|
|
59
|
+
body: z.object({
|
|
60
|
+
agentId: z
|
|
61
|
+
.string()
|
|
62
|
+
.uuid()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Re-embed only this agent's memories. Omit for all."),
|
|
65
|
+
batchSize: z.number().int().min(1).max(100).default(20).describe("Memories per batch"),
|
|
66
|
+
}),
|
|
67
|
+
responses: {
|
|
68
|
+
202: { description: "Re-embedding started" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
57
72
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
58
73
|
|
|
59
74
|
export async function handleMemory(
|
|
@@ -68,10 +83,9 @@ export async function handleMemory(
|
|
|
68
83
|
|
|
69
84
|
const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags } = parsed.body;
|
|
70
85
|
|
|
71
|
-
// Chunk content and create memories
|
|
86
|
+
// Chunk content and create memories
|
|
72
87
|
const contentChunks = chunkContent(content);
|
|
73
88
|
if (contentChunks.length === 0) {
|
|
74
|
-
// Content too small to chunk — create a single memory
|
|
75
89
|
contentChunks.push({
|
|
76
90
|
content: content.trim(),
|
|
77
91
|
chunkIndex: 0,
|
|
@@ -80,46 +94,45 @@ export async function handleMemory(
|
|
|
80
94
|
});
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
if (sourcePath && agentId) {
|
|
86
|
-
deleteMemoriesBySourcePath(sourcePath, agentId);
|
|
87
|
-
}
|
|
97
|
+
const store = getMemoryStore();
|
|
98
|
+
const provider = getEmbeddingProvider();
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
content: chunk.content,
|
|
94
|
-
name,
|
|
95
|
-
scope,
|
|
96
|
-
source,
|
|
97
|
-
sourcePath: sourcePath || null,
|
|
98
|
-
sourceTaskId: sourceTaskId || null,
|
|
99
|
-
chunkIndex: chunk.chunkIndex,
|
|
100
|
-
totalChunks: chunk.totalChunks,
|
|
101
|
-
tags: tags || [],
|
|
102
|
-
});
|
|
103
|
-
ids.push(memory.id);
|
|
104
|
-
}
|
|
105
|
-
return ids;
|
|
106
|
-
})();
|
|
100
|
+
// Dedup — delete old chunks for this source path
|
|
101
|
+
if (sourcePath && agentId) {
|
|
102
|
+
store.deleteBySourcePath(sourcePath, agentId);
|
|
103
|
+
}
|
|
107
104
|
|
|
108
|
-
//
|
|
105
|
+
// Atomic batch insert — all chunks or none
|
|
106
|
+
const memories = store.storeBatch(
|
|
107
|
+
contentChunks.map((chunk) => ({
|
|
108
|
+
agentId: agentId || null,
|
|
109
|
+
content: chunk.content,
|
|
110
|
+
name,
|
|
111
|
+
scope,
|
|
112
|
+
source,
|
|
113
|
+
sourcePath: sourcePath || null,
|
|
114
|
+
sourceTaskId: sourceTaskId || null,
|
|
115
|
+
chunkIndex: chunk.chunkIndex,
|
|
116
|
+
totalChunks: chunk.totalChunks,
|
|
117
|
+
tags: tags || [],
|
|
118
|
+
})),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Async batch embed (fire and forget)
|
|
109
122
|
(async () => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
123
|
+
try {
|
|
124
|
+
const embeddings = await provider.embedBatch(contentChunks.map((c) => c.content));
|
|
125
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
126
|
+
if (embeddings[i]) {
|
|
127
|
+
store.updateEmbedding(memories[i]!.id, embeddings[i]!, provider.name);
|
|
115
128
|
}
|
|
116
|
-
} catch (err) {
|
|
117
|
-
console.error(`[memory] Failed to embed chunk ${memoryIds[i]}:`, (err as Error).message);
|
|
118
129
|
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error("[memory] Batch embedding failed:", (err as Error).message);
|
|
119
132
|
}
|
|
120
133
|
})();
|
|
121
134
|
|
|
122
|
-
json(res, { queued: true, memoryIds }, 202);
|
|
135
|
+
json(res, { queued: true, memoryIds: memories.map((m) => m.id) }, 202);
|
|
123
136
|
return true;
|
|
124
137
|
}
|
|
125
138
|
|
|
@@ -135,20 +148,25 @@ export async function handleMemory(
|
|
|
135
148
|
const { query, limit } = parsed.body;
|
|
136
149
|
|
|
137
150
|
try {
|
|
138
|
-
const
|
|
151
|
+
const provider = getEmbeddingProvider();
|
|
152
|
+
const store = getMemoryStore();
|
|
153
|
+
const queryEmbedding = await provider.embed(query);
|
|
154
|
+
|
|
139
155
|
if (!queryEmbedding) {
|
|
140
156
|
json(res, { results: [] });
|
|
141
157
|
return true;
|
|
142
158
|
}
|
|
143
159
|
|
|
144
|
-
const
|
|
160
|
+
const candidateLimit = Math.min(limit, 20) * CANDIDATE_SET_MULTIPLIER;
|
|
161
|
+
const candidates = store.search(queryEmbedding, myAgentId, {
|
|
145
162
|
scope: "all",
|
|
146
|
-
limit:
|
|
163
|
+
limit: candidateLimit,
|
|
147
164
|
isLead: false,
|
|
148
165
|
});
|
|
166
|
+
const ranked = rerank(candidates, { limit: Math.min(limit, 20) });
|
|
149
167
|
|
|
150
168
|
json(res, {
|
|
151
|
-
results:
|
|
169
|
+
results: ranked.map((r) => ({
|
|
152
170
|
id: r.id,
|
|
153
171
|
name: r.name,
|
|
154
172
|
content: r.content,
|
|
@@ -164,5 +182,40 @@ export async function handleMemory(
|
|
|
164
182
|
return true;
|
|
165
183
|
}
|
|
166
184
|
|
|
185
|
+
if (reEmbedMemory.match(req.method, pathSegments)) {
|
|
186
|
+
const parsed = await reEmbedMemory.parse(req, res, pathSegments, new URLSearchParams());
|
|
187
|
+
if (!parsed) return true;
|
|
188
|
+
|
|
189
|
+
const { agentId, batchSize } = parsed.body;
|
|
190
|
+
const store = getMemoryStore();
|
|
191
|
+
const provider = getEmbeddingProvider();
|
|
192
|
+
const memories = store.listForReembedding(agentId ? { agentId } : undefined);
|
|
193
|
+
|
|
194
|
+
json(res, { started: true, totalMemories: memories.length }, 202);
|
|
195
|
+
|
|
196
|
+
// Async re-embed in batches
|
|
197
|
+
(async () => {
|
|
198
|
+
for (let i = 0; i < memories.length; i += batchSize) {
|
|
199
|
+
const batch = memories.slice(i, i + batchSize);
|
|
200
|
+
try {
|
|
201
|
+
const embeddings = await provider.embedBatch(batch.map((m) => m.content));
|
|
202
|
+
for (let j = 0; j < embeddings.length; j++) {
|
|
203
|
+
if (embeddings[j]) {
|
|
204
|
+
store.updateEmbedding(batch[j]!.id, embeddings[j]!, provider.name);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
console.log(
|
|
208
|
+
`[memory] Re-embedded batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(memories.length / batchSize)}`,
|
|
209
|
+
);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error("[memory] Re-embed batch failed:", (err as Error).message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
console.log(`[memory] Re-embedding complete: ${memories.length} memories`);
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
167
220
|
return false;
|
|
168
221
|
}
|
package/src/server.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
registerMcpServerUpdateTool,
|
|
28
28
|
} from "./tools/mcp-servers";
|
|
29
29
|
// Memory capability
|
|
30
|
+
import { registerMemoryDeleteTool } from "./tools/memory-delete";
|
|
30
31
|
import { registerMemoryGetTool } from "./tools/memory-get";
|
|
31
32
|
import { registerMemorySearchTool } from "./tools/memory-search";
|
|
32
33
|
import { registerMyAgentInfoTool } from "./tools/my-agent-info";
|
|
@@ -237,6 +238,7 @@ export function createServer() {
|
|
|
237
238
|
if (hasCapability("memory")) {
|
|
238
239
|
registerMemorySearchTool(server);
|
|
239
240
|
registerMemoryGetTool(server);
|
|
241
|
+
registerMemoryDeleteTool(server);
|
|
240
242
|
registerInjectLearningTool(server);
|
|
241
243
|
}
|
|
242
244
|
|
|
@@ -47,7 +47,8 @@ describe("getAvailablePort", () => {
|
|
|
47
47
|
for (let i = 0; i < 10; i++) {
|
|
48
48
|
ports.add(await getAvailablePort());
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
// Allow minor collisions — OS can recycle ports between bind/release cycles
|
|
51
|
+
expect(ports.size).toBeGreaterThanOrEqual(8);
|
|
51
52
|
});
|
|
52
53
|
});
|
|
53
54
|
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, createAgent, getDb, initDb } from "../be/db";
|
|
4
|
+
import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
|
|
5
|
+
import { rerank } from "../be/memory/reranker";
|
|
6
|
+
|
|
7
|
+
const TEST_DB_PATH = "./test-memory-e2e.sqlite";
|
|
8
|
+
|
|
9
|
+
describe("Memory E2E Lifecycle", () => {
|
|
10
|
+
const agentA = "aaaa0000-0000-4000-8000-000000000e01";
|
|
11
|
+
const agentB = "bbbb0000-0000-4000-8000-000000000e02";
|
|
12
|
+
let store: SqliteMemoryStore;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
16
|
+
try {
|
|
17
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
initDb(TEST_DB_PATH);
|
|
21
|
+
createAgent({ id: agentA, name: "E2E Agent A", isLead: false, status: "idle" });
|
|
22
|
+
createAgent({ id: agentB, name: "E2E Agent B", isLead: true, status: "idle" });
|
|
23
|
+
store = new SqliteMemoryStore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
closeDb();
|
|
28
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
29
|
+
try {
|
|
30
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ==========================================================================
|
|
36
|
+
// Full lifecycle: store → search → get → delete
|
|
37
|
+
// ==========================================================================
|
|
38
|
+
|
|
39
|
+
describe("store → search → get → delete lifecycle", () => {
|
|
40
|
+
let memoryId: string;
|
|
41
|
+
|
|
42
|
+
test("store creates a memory with correct fields", () => {
|
|
43
|
+
const memory = store.store({
|
|
44
|
+
agentId: agentA,
|
|
45
|
+
scope: "agent",
|
|
46
|
+
name: "deployment info",
|
|
47
|
+
content: "The deployment pipeline uses GitHub Actions with staging on Fly.io",
|
|
48
|
+
source: "manual",
|
|
49
|
+
tags: ["deployment", "ci"],
|
|
50
|
+
});
|
|
51
|
+
memoryId = memory.id;
|
|
52
|
+
expect(memory.id).toBeDefined();
|
|
53
|
+
expect(memory.agentId).toBe(agentA);
|
|
54
|
+
expect(memory.content).toContain("GitHub Actions");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("updateEmbedding stores vector and model name", () => {
|
|
58
|
+
const embedding = new Float32Array([0.8, 0.2, 0.1]);
|
|
59
|
+
store.updateEmbedding(memoryId, embedding, "test-model-v1");
|
|
60
|
+
|
|
61
|
+
// Verify via raw SQL
|
|
62
|
+
const row = getDb()
|
|
63
|
+
.prepare("SELECT embeddingModel FROM agent_memory WHERE id = ?")
|
|
64
|
+
.get(memoryId) as { embeddingModel: string | null };
|
|
65
|
+
expect(row.embeddingModel).toBe("test-model-v1");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("search returns the memory with similarity score", () => {
|
|
69
|
+
const query = new Float32Array([0.8, 0.2, 0.1]); // same as stored
|
|
70
|
+
const results = store.search(query, agentA, { limit: 5 });
|
|
71
|
+
expect(results.length).toBeGreaterThan(0);
|
|
72
|
+
|
|
73
|
+
const found = results.find((r) => r.id === memoryId);
|
|
74
|
+
expect(found).toBeDefined();
|
|
75
|
+
expect(found!.similarity).toBeCloseTo(1.0, 3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("get retrieves memory and increments accessCount", () => {
|
|
79
|
+
const mem1 = store.get(memoryId);
|
|
80
|
+
expect(mem1).not.toBeNull();
|
|
81
|
+
expect(mem1!.name).toBe("deployment info");
|
|
82
|
+
|
|
83
|
+
// Access count should increment on each get
|
|
84
|
+
const row1 = getDb()
|
|
85
|
+
.prepare("SELECT accessCount FROM agent_memory WHERE id = ?")
|
|
86
|
+
.get(memoryId) as { accessCount: number };
|
|
87
|
+
const count1 = row1.accessCount;
|
|
88
|
+
|
|
89
|
+
store.get(memoryId);
|
|
90
|
+
const row2 = getDb()
|
|
91
|
+
.prepare("SELECT accessCount FROM agent_memory WHERE id = ?")
|
|
92
|
+
.get(memoryId) as { accessCount: number };
|
|
93
|
+
expect(row2.accessCount).toBe(count1 + 1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("peek reads without incrementing accessCount", () => {
|
|
97
|
+
const rowBefore = getDb()
|
|
98
|
+
.prepare("SELECT accessCount FROM agent_memory WHERE id = ?")
|
|
99
|
+
.get(memoryId) as { accessCount: number };
|
|
100
|
+
|
|
101
|
+
const mem = store.peek(memoryId);
|
|
102
|
+
expect(mem).not.toBeNull();
|
|
103
|
+
expect(mem!.name).toBe("deployment info");
|
|
104
|
+
|
|
105
|
+
const rowAfter = getDb()
|
|
106
|
+
.prepare("SELECT accessCount FROM agent_memory WHERE id = ?")
|
|
107
|
+
.get(memoryId) as { accessCount: number };
|
|
108
|
+
expect(rowAfter.accessCount).toBe(rowBefore.accessCount);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("delete removes the memory", () => {
|
|
112
|
+
const deleted = store.delete(memoryId);
|
|
113
|
+
expect(deleted).toBe(true);
|
|
114
|
+
|
|
115
|
+
const found = store.get(memoryId);
|
|
116
|
+
expect(found).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ==========================================================================
|
|
121
|
+
// Reranking: newer memories rank higher with similar embeddings
|
|
122
|
+
// ==========================================================================
|
|
123
|
+
|
|
124
|
+
describe("reranking affects result order", () => {
|
|
125
|
+
test("newer memory with same embedding ranks higher", () => {
|
|
126
|
+
// Create old memory
|
|
127
|
+
const old = store.store({
|
|
128
|
+
agentId: agentA,
|
|
129
|
+
scope: "agent",
|
|
130
|
+
name: "old knowledge",
|
|
131
|
+
content: "Old deployment docs",
|
|
132
|
+
source: "manual",
|
|
133
|
+
});
|
|
134
|
+
store.updateEmbedding(old.id, new Float32Array([0.5, 0.5, 0.0]), "test-model");
|
|
135
|
+
|
|
136
|
+
// Backdate the old memory's createdAt
|
|
137
|
+
getDb()
|
|
138
|
+
.prepare("UPDATE agent_memory SET createdAt = datetime('now', '-30 days') WHERE id = ?")
|
|
139
|
+
.run(old.id);
|
|
140
|
+
|
|
141
|
+
// Create fresh memory with similar embedding
|
|
142
|
+
const fresh = store.store({
|
|
143
|
+
agentId: agentA,
|
|
144
|
+
scope: "agent",
|
|
145
|
+
name: "fresh knowledge",
|
|
146
|
+
content: "New deployment docs",
|
|
147
|
+
source: "manual",
|
|
148
|
+
});
|
|
149
|
+
store.updateEmbedding(fresh.id, new Float32Array([0.5, 0.5, 0.0]), "test-model");
|
|
150
|
+
|
|
151
|
+
// Search with matching query
|
|
152
|
+
const candidates = store.search(new Float32Array([0.5, 0.5, 0.0]), agentA, {
|
|
153
|
+
limit: 20,
|
|
154
|
+
});
|
|
155
|
+
const ranked = rerank(candidates, { limit: 10 });
|
|
156
|
+
|
|
157
|
+
const freshIdx = ranked.findIndex((r) => r.id === fresh.id);
|
|
158
|
+
const oldIdx = ranked.findIndex((r) => r.id === old.id);
|
|
159
|
+
|
|
160
|
+
expect(freshIdx).toBeGreaterThanOrEqual(0);
|
|
161
|
+
expect(oldIdx).toBeGreaterThanOrEqual(0);
|
|
162
|
+
expect(freshIdx).toBeLessThan(oldIdx); // fresh ranks higher
|
|
163
|
+
|
|
164
|
+
// Cleanup
|
|
165
|
+
store.delete(old.id);
|
|
166
|
+
store.delete(fresh.id);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ==========================================================================
|
|
171
|
+
// TTL expiry filtering
|
|
172
|
+
// ==========================================================================
|
|
173
|
+
|
|
174
|
+
describe("TTL expiry filtering", () => {
|
|
175
|
+
test("task_completion has ~7d TTL", () => {
|
|
176
|
+
const memory = store.store({
|
|
177
|
+
agentId: agentA,
|
|
178
|
+
scope: "agent",
|
|
179
|
+
name: "task result",
|
|
180
|
+
content: "Completed task output",
|
|
181
|
+
source: "task_completion",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const row = getDb()
|
|
185
|
+
.prepare("SELECT expiresAt FROM agent_memory WHERE id = ?")
|
|
186
|
+
.get(memory.id) as { expiresAt: string | null };
|
|
187
|
+
expect(row.expiresAt).not.toBeNull();
|
|
188
|
+
|
|
189
|
+
const expiresAt = new Date(row.expiresAt!).getTime();
|
|
190
|
+
const expectedMin = Date.now() + 6 * 24 * 60 * 60 * 1000; // ~6d
|
|
191
|
+
const expectedMax = Date.now() + 8 * 24 * 60 * 60 * 1000; // ~8d
|
|
192
|
+
expect(expiresAt).toBeGreaterThan(expectedMin);
|
|
193
|
+
expect(expiresAt).toBeLessThan(expectedMax);
|
|
194
|
+
|
|
195
|
+
store.delete(memory.id);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("manual source has no TTL (null expiresAt)", () => {
|
|
199
|
+
const memory = store.store({
|
|
200
|
+
agentId: agentA,
|
|
201
|
+
scope: "agent",
|
|
202
|
+
name: "permanent note",
|
|
203
|
+
content: "This should never expire",
|
|
204
|
+
source: "manual",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const row = getDb()
|
|
208
|
+
.prepare("SELECT expiresAt FROM agent_memory WHERE id = ?")
|
|
209
|
+
.get(memory.id) as { expiresAt: string | null };
|
|
210
|
+
expect(row.expiresAt).toBeNull();
|
|
211
|
+
|
|
212
|
+
store.delete(memory.id);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("expired memories are excluded from search", () => {
|
|
216
|
+
const memory = store.store({
|
|
217
|
+
agentId: agentA,
|
|
218
|
+
scope: "agent",
|
|
219
|
+
name: "expired item",
|
|
220
|
+
content: "This should be hidden from search",
|
|
221
|
+
source: "session_summary",
|
|
222
|
+
});
|
|
223
|
+
store.updateEmbedding(memory.id, new Float32Array([0.9, 0.1, 0.0]), "test-model");
|
|
224
|
+
|
|
225
|
+
// Force expiry by backdating expiresAt
|
|
226
|
+
getDb()
|
|
227
|
+
.prepare("UPDATE agent_memory SET expiresAt = datetime('now', '-1 day') WHERE id = ?")
|
|
228
|
+
.run(memory.id);
|
|
229
|
+
|
|
230
|
+
// Search should NOT return expired memory
|
|
231
|
+
const results = store.search(new Float32Array([0.9, 0.1, 0.0]), agentA, { limit: 20 });
|
|
232
|
+
const found = results.find((r) => r.id === memory.id);
|
|
233
|
+
expect(found).toBeUndefined();
|
|
234
|
+
|
|
235
|
+
// But get() still returns it (lazy expiry — no hard delete)
|
|
236
|
+
const direct = store.get(memory.id);
|
|
237
|
+
expect(direct).not.toBeNull();
|
|
238
|
+
expect(direct!.name).toBe("expired item");
|
|
239
|
+
|
|
240
|
+
// includeExpired: true should return it in search
|
|
241
|
+
const withExpired = store.search(new Float32Array([0.9, 0.1, 0.0]), agentA, {
|
|
242
|
+
limit: 20,
|
|
243
|
+
includeExpired: true,
|
|
244
|
+
});
|
|
245
|
+
const foundExpired = withExpired.find((r) => r.id === memory.id);
|
|
246
|
+
expect(foundExpired).toBeDefined();
|
|
247
|
+
|
|
248
|
+
store.delete(memory.id);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ==========================================================================
|
|
253
|
+
// Batch operations
|
|
254
|
+
// ==========================================================================
|
|
255
|
+
|
|
256
|
+
describe("storeBatch atomicity", () => {
|
|
257
|
+
test("stores multiple chunks atomically", () => {
|
|
258
|
+
const chunks = [
|
|
259
|
+
{ content: "Chunk 0 content", chunkIndex: 0, totalChunks: 3 },
|
|
260
|
+
{ content: "Chunk 1 content", chunkIndex: 1, totalChunks: 3 },
|
|
261
|
+
{ content: "Chunk 2 content", chunkIndex: 2, totalChunks: 3 },
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const memories = store.storeBatch(
|
|
265
|
+
chunks.map((c) => ({
|
|
266
|
+
agentId: agentA,
|
|
267
|
+
scope: "agent" as const,
|
|
268
|
+
name: "batch-test",
|
|
269
|
+
content: c.content,
|
|
270
|
+
source: "file_index" as const,
|
|
271
|
+
sourcePath: "/test/batch.md",
|
|
272
|
+
chunkIndex: c.chunkIndex,
|
|
273
|
+
totalChunks: c.totalChunks,
|
|
274
|
+
})),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
expect(memories.length).toBe(3);
|
|
278
|
+
for (let i = 0; i < 3; i++) {
|
|
279
|
+
expect(memories[i].chunkIndex).toBe(i);
|
|
280
|
+
expect(memories[i].totalChunks).toBe(3);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Cleanup
|
|
284
|
+
store.deleteBySourcePath("/test/batch.md", agentA);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ==========================================================================
|
|
289
|
+
// Scope visibility
|
|
290
|
+
// ==========================================================================
|
|
291
|
+
|
|
292
|
+
describe("scope visibility rules", () => {
|
|
293
|
+
let agentMemId: string;
|
|
294
|
+
let swarmMemId: string;
|
|
295
|
+
let otherAgentMemId: string;
|
|
296
|
+
|
|
297
|
+
beforeAll(() => {
|
|
298
|
+
const m1 = store.store({
|
|
299
|
+
agentId: agentA,
|
|
300
|
+
scope: "agent",
|
|
301
|
+
name: "A's private",
|
|
302
|
+
content: "Agent A only",
|
|
303
|
+
source: "manual",
|
|
304
|
+
});
|
|
305
|
+
store.updateEmbedding(m1.id, new Float32Array([1, 0, 0]), "test-model");
|
|
306
|
+
agentMemId = m1.id;
|
|
307
|
+
|
|
308
|
+
const m2 = store.store({
|
|
309
|
+
agentId: agentA,
|
|
310
|
+
scope: "swarm",
|
|
311
|
+
name: "shared knowledge",
|
|
312
|
+
content: "Visible to all",
|
|
313
|
+
source: "manual",
|
|
314
|
+
});
|
|
315
|
+
store.updateEmbedding(m2.id, new Float32Array([0, 1, 0]), "test-model");
|
|
316
|
+
swarmMemId = m2.id;
|
|
317
|
+
|
|
318
|
+
const m3 = store.store({
|
|
319
|
+
agentId: agentB,
|
|
320
|
+
scope: "agent",
|
|
321
|
+
name: "B's private",
|
|
322
|
+
content: "Agent B only",
|
|
323
|
+
source: "manual",
|
|
324
|
+
});
|
|
325
|
+
store.updateEmbedding(m3.id, new Float32Array([0, 0, 1]), "test-model");
|
|
326
|
+
otherAgentMemId = m3.id;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
afterAll(() => {
|
|
330
|
+
store.delete(agentMemId);
|
|
331
|
+
store.delete(swarmMemId);
|
|
332
|
+
store.delete(otherAgentMemId);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("worker sees own agent-scoped + swarm memories", () => {
|
|
336
|
+
const results = store.search(new Float32Array([1, 1, 1]), agentA, { isLead: false });
|
|
337
|
+
const names = results.map((r) => r.name);
|
|
338
|
+
expect(names).toContain("A's private");
|
|
339
|
+
expect(names).toContain("shared knowledge");
|
|
340
|
+
expect(names).not.toContain("B's private");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("lead sees all memories", () => {
|
|
344
|
+
const results = store.search(new Float32Array([1, 1, 1]), agentA, { isLead: true });
|
|
345
|
+
const names = results.map((r) => r.name);
|
|
346
|
+
expect(names).toContain("A's private");
|
|
347
|
+
expect(names).toContain("shared knowledge");
|
|
348
|
+
expect(names).toContain("B's private");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ==========================================================================
|
|
353
|
+
// Stats including expired count
|
|
354
|
+
// ==========================================================================
|
|
355
|
+
|
|
356
|
+
describe("getStats includes expired count", () => {
|
|
357
|
+
test("reports expired memories", () => {
|
|
358
|
+
const mem = store.store({
|
|
359
|
+
agentId: agentA,
|
|
360
|
+
scope: "agent",
|
|
361
|
+
name: "stats-expired",
|
|
362
|
+
content: "Will expire",
|
|
363
|
+
source: "session_summary",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Force expiry
|
|
367
|
+
getDb()
|
|
368
|
+
.prepare("UPDATE agent_memory SET expiresAt = datetime('now', '-1 hour') WHERE id = ?")
|
|
369
|
+
.run(mem.id);
|
|
370
|
+
|
|
371
|
+
const stats = store.getStats(agentA);
|
|
372
|
+
expect(stats.expired).toBeGreaterThanOrEqual(1);
|
|
373
|
+
|
|
374
|
+
store.delete(mem.id);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ==========================================================================
|
|
379
|
+
// Re-embed updates embeddingModel
|
|
380
|
+
// ==========================================================================
|
|
381
|
+
|
|
382
|
+
describe("updateEmbedding tracks model", () => {
|
|
383
|
+
test("re-embedding updates embeddingModel column", () => {
|
|
384
|
+
const mem = store.store({
|
|
385
|
+
agentId: agentA,
|
|
386
|
+
scope: "agent",
|
|
387
|
+
name: "model-track",
|
|
388
|
+
content: "Track model changes",
|
|
389
|
+
source: "manual",
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// First embed
|
|
393
|
+
store.updateEmbedding(mem.id, new Float32Array([0.1, 0.2, 0.3]), "model-v1");
|
|
394
|
+
let row = getDb()
|
|
395
|
+
.prepare("SELECT embeddingModel FROM agent_memory WHERE id = ?")
|
|
396
|
+
.get(mem.id) as { embeddingModel: string | null };
|
|
397
|
+
expect(row.embeddingModel).toBe("model-v1");
|
|
398
|
+
|
|
399
|
+
// Re-embed with new model
|
|
400
|
+
store.updateEmbedding(mem.id, new Float32Array([0.3, 0.2, 0.1]), "model-v2");
|
|
401
|
+
row = getDb().prepare("SELECT embeddingModel FROM agent_memory WHERE id = ?").get(mem.id) as {
|
|
402
|
+
embeddingModel: string | null;
|
|
403
|
+
};
|
|
404
|
+
expect(row.embeddingModel).toBe("model-v2");
|
|
405
|
+
|
|
406
|
+
store.delete(mem.id);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ==========================================================================
|
|
411
|
+
// listForReembedding
|
|
412
|
+
// ==========================================================================
|
|
413
|
+
|
|
414
|
+
describe("listForReembedding", () => {
|
|
415
|
+
test("returns id and content for all memories", () => {
|
|
416
|
+
const mem = store.store({
|
|
417
|
+
agentId: agentA,
|
|
418
|
+
scope: "agent",
|
|
419
|
+
name: "reembed-list",
|
|
420
|
+
content: "Content for re-embedding",
|
|
421
|
+
source: "manual",
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const list = store.listForReembedding();
|
|
425
|
+
expect(list.length).toBeGreaterThan(0);
|
|
426
|
+
|
|
427
|
+
const found = list.find((item) => item.id === mem.id);
|
|
428
|
+
expect(found).toBeDefined();
|
|
429
|
+
expect(found!.content).toBe("Content for re-embedding");
|
|
430
|
+
|
|
431
|
+
store.delete(mem.id);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("filters by agentId", () => {
|
|
435
|
+
const mem = store.store({
|
|
436
|
+
agentId: agentB,
|
|
437
|
+
scope: "agent",
|
|
438
|
+
name: "reembed-filtered",
|
|
439
|
+
content: "B's content",
|
|
440
|
+
source: "manual",
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const listAll = store.listForReembedding();
|
|
444
|
+
const listB = store.listForReembedding({ agentId: agentB });
|
|
445
|
+
expect(listB.length).toBeLessThanOrEqual(listAll.length);
|
|
446
|
+
|
|
447
|
+
const found = listB.find((item) => item.id === mem.id);
|
|
448
|
+
expect(found).toBeDefined();
|
|
449
|
+
|
|
450
|
+
store.delete(mem.id);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
});
|