@vellumai/assistant 0.5.2 → 0.5.4
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/ARCHITECTURE.md +109 -0
- package/docs/architecture/memory.md +105 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +707 -0
- package/src/__tests__/memory-reducer.test.ts +704 -0
- package/src/__tests__/memory-regressions.test.ts +30 -8
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +3 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +52 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +162 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +455 -101
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +16 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +8 -0
- package/src/memory/jobs-worker.ts +20 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +106 -0
- package/src/memory/reducer.ts +467 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +94 -2
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +3 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +28 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
mock,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
16
|
+
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "memory-archive-migration-"));
|
|
18
|
+
const dbPath = join(testDir, "test.db");
|
|
19
|
+
const originalBunTest = process.env.BUN_TEST;
|
|
20
|
+
|
|
21
|
+
mock.module("../util/platform.js", () => ({
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === "darwin",
|
|
24
|
+
isLinux: () => process.platform === "linux",
|
|
25
|
+
isWindows: () => process.platform === "win32",
|
|
26
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
27
|
+
getDbPath: () => dbPath,
|
|
28
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
29
|
+
ensureDataDir: () => {},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
mock.module("../util/logger.js", () => ({
|
|
33
|
+
getLogger: () =>
|
|
34
|
+
new Proxy({} as Record<string, unknown>, {
|
|
35
|
+
get: () => () => {},
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
40
|
+
import { getSqliteFrom } from "../memory/db-connection.js";
|
|
41
|
+
import { migrateMemoryArchiveTables } from "../memory/migrations/186-memory-archive.js";
|
|
42
|
+
import * as schema from "../memory/schema.js";
|
|
43
|
+
|
|
44
|
+
const ARCHIVE_TABLES = [
|
|
45
|
+
"memory_observations",
|
|
46
|
+
"memory_chunks",
|
|
47
|
+
"memory_episodes",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
function createTestDb() {
|
|
51
|
+
const sqlite = new Database(":memory:");
|
|
52
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
53
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
54
|
+
return drizzle(sqlite, { schema });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tableExists(raw: Database, tableName: string): boolean {
|
|
58
|
+
const row = raw
|
|
59
|
+
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
60
|
+
.get(tableName);
|
|
61
|
+
return row != null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasIndex(raw: Database, indexName: string): boolean {
|
|
65
|
+
const row = raw
|
|
66
|
+
.query(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
67
|
+
.get(indexName);
|
|
68
|
+
return row != null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getColumnNames(raw: Database, tableName: string): string[] {
|
|
72
|
+
return (
|
|
73
|
+
raw.query(`PRAGMA table_info(${tableName})`).all() as Array<{
|
|
74
|
+
name: string;
|
|
75
|
+
}>
|
|
76
|
+
).map((column) => column.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Bootstrap the minimal prerequisite tables that the archive tables reference. */
|
|
80
|
+
function bootstrapPrerequisiteTables(raw: Database): void {
|
|
81
|
+
raw.exec(/*sql*/ `
|
|
82
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
83
|
+
id TEXT PRIMARY KEY,
|
|
84
|
+
created_at INTEGER NOT NULL,
|
|
85
|
+
updated_at INTEGER NOT NULL
|
|
86
|
+
)
|
|
87
|
+
`);
|
|
88
|
+
raw.exec(/*sql*/ `
|
|
89
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
92
|
+
created_at INTEGER NOT NULL
|
|
93
|
+
)
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function removeTestDbFiles(): void {
|
|
98
|
+
rmSync(dbPath, { force: true });
|
|
99
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
100
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe("memory archive migration (186)", () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
process.env.BUN_TEST = "0";
|
|
106
|
+
resetDb();
|
|
107
|
+
removeTestDbFiles();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
resetDb();
|
|
112
|
+
removeTestDbFiles();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
if (originalBunTest === undefined) {
|
|
117
|
+
delete process.env.BUN_TEST;
|
|
118
|
+
} else {
|
|
119
|
+
process.env.BUN_TEST = originalBunTest;
|
|
120
|
+
}
|
|
121
|
+
resetDb();
|
|
122
|
+
removeTestDbFiles();
|
|
123
|
+
try {
|
|
124
|
+
rmSync(testDir, { recursive: true });
|
|
125
|
+
} catch {
|
|
126
|
+
/* best effort */
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---------- Fresh init ----------
|
|
131
|
+
|
|
132
|
+
test("fresh DB initialization creates all three archive tables", () => {
|
|
133
|
+
initializeDb();
|
|
134
|
+
|
|
135
|
+
const raw = new Database(dbPath);
|
|
136
|
+
for (const table of ARCHIVE_TABLES) {
|
|
137
|
+
expect(tableExists(raw, table)).toBe(true);
|
|
138
|
+
}
|
|
139
|
+
raw.close();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("fresh DB initialization creates prefetch indexes on archive tables", () => {
|
|
143
|
+
initializeDb();
|
|
144
|
+
|
|
145
|
+
const raw = new Database(dbPath);
|
|
146
|
+
|
|
147
|
+
// memory_observations indexes
|
|
148
|
+
expect(hasIndex(raw, "idx_memory_observations_scope_id")).toBe(true);
|
|
149
|
+
expect(hasIndex(raw, "idx_memory_observations_conversation_id")).toBe(true);
|
|
150
|
+
expect(hasIndex(raw, "idx_memory_observations_created_at")).toBe(true);
|
|
151
|
+
|
|
152
|
+
// memory_chunks indexes
|
|
153
|
+
expect(hasIndex(raw, "idx_memory_chunks_scope_id")).toBe(true);
|
|
154
|
+
expect(hasIndex(raw, "idx_memory_chunks_observation_id")).toBe(true);
|
|
155
|
+
expect(hasIndex(raw, "idx_memory_chunks_content_hash")).toBe(true);
|
|
156
|
+
expect(hasIndex(raw, "idx_memory_chunks_created_at")).toBe(true);
|
|
157
|
+
|
|
158
|
+
// memory_episodes indexes
|
|
159
|
+
expect(hasIndex(raw, "idx_memory_episodes_scope_id")).toBe(true);
|
|
160
|
+
expect(hasIndex(raw, "idx_memory_episodes_conversation_id")).toBe(true);
|
|
161
|
+
expect(hasIndex(raw, "idx_memory_episodes_created_at")).toBe(true);
|
|
162
|
+
|
|
163
|
+
raw.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("fresh DB initialization includes correct columns on memory_observations", () => {
|
|
167
|
+
initializeDb();
|
|
168
|
+
|
|
169
|
+
const raw = new Database(dbPath);
|
|
170
|
+
const columns = getColumnNames(raw, "memory_observations");
|
|
171
|
+
|
|
172
|
+
expect(columns).toContain("id");
|
|
173
|
+
expect(columns).toContain("scope_id");
|
|
174
|
+
expect(columns).toContain("conversation_id");
|
|
175
|
+
expect(columns).toContain("message_id");
|
|
176
|
+
expect(columns).toContain("role");
|
|
177
|
+
expect(columns).toContain("content");
|
|
178
|
+
expect(columns).toContain("modality");
|
|
179
|
+
expect(columns).toContain("source");
|
|
180
|
+
expect(columns).toContain("created_at");
|
|
181
|
+
|
|
182
|
+
raw.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("fresh DB initialization includes contentHash on memory_chunks", () => {
|
|
186
|
+
initializeDb();
|
|
187
|
+
|
|
188
|
+
const raw = new Database(dbPath);
|
|
189
|
+
const columns = getColumnNames(raw, "memory_chunks");
|
|
190
|
+
|
|
191
|
+
expect(columns).toContain("content_hash");
|
|
192
|
+
expect(columns).toContain("observation_id");
|
|
193
|
+
expect(columns).toContain("token_estimate");
|
|
194
|
+
|
|
195
|
+
raw.close();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("fresh DB initialization includes source-link metadata on memory_episodes", () => {
|
|
199
|
+
initializeDb();
|
|
200
|
+
|
|
201
|
+
const raw = new Database(dbPath);
|
|
202
|
+
const columns = getColumnNames(raw, "memory_episodes");
|
|
203
|
+
|
|
204
|
+
expect(columns).toContain("source");
|
|
205
|
+
expect(columns).toContain("title");
|
|
206
|
+
expect(columns).toContain("summary");
|
|
207
|
+
expect(columns).toContain("start_at");
|
|
208
|
+
expect(columns).toContain("end_at");
|
|
209
|
+
|
|
210
|
+
raw.close();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ---------- Upgrade (migration on a pre-archive DB) ----------
|
|
214
|
+
|
|
215
|
+
test("migration creates archive tables on a database that has no archive tables", () => {
|
|
216
|
+
const db = createTestDb();
|
|
217
|
+
const raw = getSqliteFrom(db);
|
|
218
|
+
|
|
219
|
+
bootstrapPrerequisiteTables(raw);
|
|
220
|
+
|
|
221
|
+
for (const table of ARCHIVE_TABLES) {
|
|
222
|
+
expect(tableExists(raw, table)).toBe(false);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
migrateMemoryArchiveTables(db);
|
|
226
|
+
|
|
227
|
+
for (const table of ARCHIVE_TABLES) {
|
|
228
|
+
expect(tableExists(raw, table)).toBe(true);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
raw.close();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---------- Re-run safety ----------
|
|
235
|
+
|
|
236
|
+
test("re-running the migration is safe and preserves existing data", () => {
|
|
237
|
+
const db = createTestDb();
|
|
238
|
+
const raw = getSqliteFrom(db);
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
|
|
241
|
+
bootstrapPrerequisiteTables(raw);
|
|
242
|
+
migrateMemoryArchiveTables(db);
|
|
243
|
+
|
|
244
|
+
// Insert a conversation and an observation
|
|
245
|
+
raw.exec(/*sql*/ `
|
|
246
|
+
INSERT INTO conversations (id, created_at, updated_at)
|
|
247
|
+
VALUES ('conv-1', ${now}, ${now})
|
|
248
|
+
`);
|
|
249
|
+
raw.exec(/*sql*/ `
|
|
250
|
+
INSERT INTO memory_observations (id, scope_id, conversation_id, role, content, modality, created_at)
|
|
251
|
+
VALUES ('obs-1', 'default', 'conv-1', 'user', 'The sky is blue', 'text', ${now})
|
|
252
|
+
`);
|
|
253
|
+
raw.exec(/*sql*/ `
|
|
254
|
+
INSERT INTO memory_chunks (id, scope_id, observation_id, content, token_estimate, content_hash, created_at)
|
|
255
|
+
VALUES ('chunk-1', 'default', 'obs-1', 'The sky is blue', 5, 'abc123', ${now})
|
|
256
|
+
`);
|
|
257
|
+
raw.exec(/*sql*/ `
|
|
258
|
+
INSERT INTO memory_episodes (id, scope_id, conversation_id, title, summary, token_estimate, source, start_at, end_at, created_at, updated_at)
|
|
259
|
+
VALUES ('ep-1', 'default', 'conv-1', 'Sky color', 'User mentioned sky is blue', 8, 'vellum', ${now}, ${now}, ${now}, ${now})
|
|
260
|
+
`);
|
|
261
|
+
|
|
262
|
+
// Re-run should not throw
|
|
263
|
+
expect(() => migrateMemoryArchiveTables(db)).not.toThrow();
|
|
264
|
+
|
|
265
|
+
// Verify data is preserved
|
|
266
|
+
const obs = raw
|
|
267
|
+
.query(`SELECT id, content FROM memory_observations WHERE id = 'obs-1'`)
|
|
268
|
+
.get() as { id: string; content: string } | null;
|
|
269
|
+
expect(obs).toEqual({ id: "obs-1", content: "The sky is blue" });
|
|
270
|
+
|
|
271
|
+
const chunk = raw
|
|
272
|
+
.query(`SELECT id, content_hash FROM memory_chunks WHERE id = 'chunk-1'`)
|
|
273
|
+
.get() as { id: string; content_hash: string } | null;
|
|
274
|
+
expect(chunk).toEqual({ id: "chunk-1", content_hash: "abc123" });
|
|
275
|
+
|
|
276
|
+
const ep = raw
|
|
277
|
+
.query(`SELECT id, title FROM memory_episodes WHERE id = 'ep-1'`)
|
|
278
|
+
.get() as { id: string; title: string } | null;
|
|
279
|
+
expect(ep).toEqual({ id: "ep-1", title: "Sky color" });
|
|
280
|
+
|
|
281
|
+
raw.close();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ---------- Legacy table isolation ----------
|
|
285
|
+
|
|
286
|
+
test("migration does not modify legacy memory tables", () => {
|
|
287
|
+
const db = createTestDb();
|
|
288
|
+
const raw = getSqliteFrom(db);
|
|
289
|
+
|
|
290
|
+
bootstrapPrerequisiteTables(raw);
|
|
291
|
+
|
|
292
|
+
// Create legacy memory tables
|
|
293
|
+
raw.exec(/*sql*/ `
|
|
294
|
+
CREATE TABLE memory_segments (
|
|
295
|
+
id TEXT PRIMARY KEY,
|
|
296
|
+
message_id TEXT NOT NULL,
|
|
297
|
+
conversation_id TEXT NOT NULL,
|
|
298
|
+
role TEXT NOT NULL,
|
|
299
|
+
segment_index INTEGER NOT NULL,
|
|
300
|
+
text TEXT NOT NULL,
|
|
301
|
+
token_estimate INTEGER NOT NULL,
|
|
302
|
+
scope_id TEXT NOT NULL DEFAULT 'default',
|
|
303
|
+
content_hash TEXT,
|
|
304
|
+
created_at INTEGER NOT NULL,
|
|
305
|
+
updated_at INTEGER NOT NULL
|
|
306
|
+
)
|
|
307
|
+
`);
|
|
308
|
+
raw.exec(/*sql*/ `
|
|
309
|
+
CREATE TABLE memory_items (
|
|
310
|
+
id TEXT PRIMARY KEY,
|
|
311
|
+
kind TEXT NOT NULL,
|
|
312
|
+
subject TEXT NOT NULL,
|
|
313
|
+
statement TEXT NOT NULL,
|
|
314
|
+
status TEXT NOT NULL,
|
|
315
|
+
confidence REAL NOT NULL,
|
|
316
|
+
fingerprint TEXT NOT NULL,
|
|
317
|
+
scope_id TEXT NOT NULL DEFAULT 'default',
|
|
318
|
+
first_seen_at INTEGER NOT NULL,
|
|
319
|
+
last_seen_at INTEGER NOT NULL
|
|
320
|
+
)
|
|
321
|
+
`);
|
|
322
|
+
|
|
323
|
+
// Capture pre-migration column sets
|
|
324
|
+
const segmentColumnsBefore = getColumnNames(raw, "memory_segments");
|
|
325
|
+
const itemColumnsBefore = getColumnNames(raw, "memory_items");
|
|
326
|
+
|
|
327
|
+
migrateMemoryArchiveTables(db);
|
|
328
|
+
|
|
329
|
+
// Legacy tables should be completely untouched
|
|
330
|
+
const segmentColumnsAfter = getColumnNames(raw, "memory_segments");
|
|
331
|
+
const itemColumnsAfter = getColumnNames(raw, "memory_items");
|
|
332
|
+
|
|
333
|
+
expect(segmentColumnsAfter).toEqual(segmentColumnsBefore);
|
|
334
|
+
expect(itemColumnsAfter).toEqual(itemColumnsBefore);
|
|
335
|
+
|
|
336
|
+
raw.close();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---------- Unique constraint on content_hash ----------
|
|
340
|
+
|
|
341
|
+
test("memory_chunks content_hash unique index prevents duplicate inserts within same scope", () => {
|
|
342
|
+
const db = createTestDb();
|
|
343
|
+
const raw = getSqliteFrom(db);
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
|
|
346
|
+
bootstrapPrerequisiteTables(raw);
|
|
347
|
+
migrateMemoryArchiveTables(db);
|
|
348
|
+
|
|
349
|
+
raw.exec(/*sql*/ `
|
|
350
|
+
INSERT INTO conversations (id, created_at, updated_at)
|
|
351
|
+
VALUES ('conv-dup', ${now}, ${now})
|
|
352
|
+
`);
|
|
353
|
+
raw.exec(/*sql*/ `
|
|
354
|
+
INSERT INTO memory_observations (id, scope_id, conversation_id, role, content, modality, created_at)
|
|
355
|
+
VALUES ('obs-dup', 'default', 'conv-dup', 'user', 'Duplicate test', 'text', ${now})
|
|
356
|
+
`);
|
|
357
|
+
raw.exec(/*sql*/ `
|
|
358
|
+
INSERT INTO memory_chunks (id, scope_id, observation_id, content, token_estimate, content_hash, created_at)
|
|
359
|
+
VALUES ('chunk-dup-1', 'default', 'obs-dup', 'Duplicate test', 3, 'hash-dup', ${now})
|
|
360
|
+
`);
|
|
361
|
+
|
|
362
|
+
// Same scope + content_hash should fail
|
|
363
|
+
expect(() => {
|
|
364
|
+
raw.exec(/*sql*/ `
|
|
365
|
+
INSERT INTO memory_chunks (id, scope_id, observation_id, content, token_estimate, content_hash, created_at)
|
|
366
|
+
VALUES ('chunk-dup-2', 'default', 'obs-dup', 'Duplicate test', 3, 'hash-dup', ${now})
|
|
367
|
+
`);
|
|
368
|
+
}).toThrow();
|
|
369
|
+
|
|
370
|
+
raw.close();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
mock,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
16
|
+
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "memory-brief-state-"));
|
|
18
|
+
const dbPath = join(testDir, "test.db");
|
|
19
|
+
const originalBunTest = process.env.BUN_TEST;
|
|
20
|
+
|
|
21
|
+
mock.module("../util/platform.js", () => ({
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === "darwin",
|
|
24
|
+
isLinux: () => process.platform === "linux",
|
|
25
|
+
isWindows: () => process.platform === "win32",
|
|
26
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
27
|
+
getDbPath: () => dbPath,
|
|
28
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
29
|
+
ensureDataDir: () => {},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
mock.module("../util/logger.js", () => ({
|
|
33
|
+
getLogger: () =>
|
|
34
|
+
new Proxy({} as Record<string, unknown>, {
|
|
35
|
+
get: () => () => {},
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
40
|
+
import { getSqliteFrom } from "../memory/db-connection.js";
|
|
41
|
+
import { migrateMemoryBriefState } from "../memory/migrations/185-memory-brief-state.js";
|
|
42
|
+
import * as schema from "../memory/schema.js";
|
|
43
|
+
|
|
44
|
+
function createTestDb() {
|
|
45
|
+
const sqlite = new Database(":memory:");
|
|
46
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
47
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
48
|
+
return drizzle(sqlite, { schema });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasTable(raw: Database, tableName: string): boolean {
|
|
52
|
+
const row = raw
|
|
53
|
+
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
54
|
+
.get(tableName);
|
|
55
|
+
return row != null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasIndex(raw: Database, indexName: string): boolean {
|
|
59
|
+
const row = raw
|
|
60
|
+
.query(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
61
|
+
.get(indexName);
|
|
62
|
+
return row != null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getColumnNames(raw: Database, tableName: string): string[] {
|
|
66
|
+
return (
|
|
67
|
+
raw.query(`PRAGMA table_info(${tableName})`).all() as Array<{
|
|
68
|
+
name: string;
|
|
69
|
+
}>
|
|
70
|
+
).map((column) => column.name);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeTestDbFiles(): void {
|
|
74
|
+
rmSync(dbPath, { force: true });
|
|
75
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
76
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("memory brief state migration", () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
process.env.BUN_TEST = "0";
|
|
82
|
+
resetDb();
|
|
83
|
+
removeTestDbFiles();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
resetDb();
|
|
88
|
+
removeTestDbFiles();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(() => {
|
|
92
|
+
if (originalBunTest === undefined) {
|
|
93
|
+
delete process.env.BUN_TEST;
|
|
94
|
+
} else {
|
|
95
|
+
process.env.BUN_TEST = originalBunTest;
|
|
96
|
+
}
|
|
97
|
+
resetDb();
|
|
98
|
+
removeTestDbFiles();
|
|
99
|
+
try {
|
|
100
|
+
rmSync(testDir, { recursive: true });
|
|
101
|
+
} catch {
|
|
102
|
+
/* best effort */
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("fresh DB initialization creates both tables and their indexes", () => {
|
|
107
|
+
initializeDb();
|
|
108
|
+
|
|
109
|
+
const raw = new Database(dbPath);
|
|
110
|
+
|
|
111
|
+
// time_contexts table
|
|
112
|
+
expect(hasTable(raw, "time_contexts")).toBe(true);
|
|
113
|
+
expect(getColumnNames(raw, "time_contexts")).toEqual([
|
|
114
|
+
"id",
|
|
115
|
+
"scope_id",
|
|
116
|
+
"summary",
|
|
117
|
+
"source",
|
|
118
|
+
"active_from",
|
|
119
|
+
"active_until",
|
|
120
|
+
"created_at",
|
|
121
|
+
"updated_at",
|
|
122
|
+
]);
|
|
123
|
+
expect(hasIndex(raw, "idx_time_contexts_scope_active_until")).toBe(true);
|
|
124
|
+
|
|
125
|
+
// open_loops table
|
|
126
|
+
expect(hasTable(raw, "open_loops")).toBe(true);
|
|
127
|
+
expect(getColumnNames(raw, "open_loops")).toEqual([
|
|
128
|
+
"id",
|
|
129
|
+
"scope_id",
|
|
130
|
+
"summary",
|
|
131
|
+
"status",
|
|
132
|
+
"source",
|
|
133
|
+
"due_at",
|
|
134
|
+
"surfaced_at",
|
|
135
|
+
"created_at",
|
|
136
|
+
"updated_at",
|
|
137
|
+
]);
|
|
138
|
+
expect(hasIndex(raw, "idx_open_loops_scope_status_due")).toBe(true);
|
|
139
|
+
|
|
140
|
+
raw.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("migration on an empty DB creates tables and indexes", () => {
|
|
144
|
+
const db = createTestDb();
|
|
145
|
+
const raw = getSqliteFrom(db);
|
|
146
|
+
|
|
147
|
+
migrateMemoryBriefState(db);
|
|
148
|
+
|
|
149
|
+
expect(hasTable(raw, "time_contexts")).toBe(true);
|
|
150
|
+
expect(hasTable(raw, "open_loops")).toBe(true);
|
|
151
|
+
expect(hasIndex(raw, "idx_time_contexts_scope_active_until")).toBe(true);
|
|
152
|
+
expect(hasIndex(raw, "idx_open_loops_scope_status_due")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("re-running the migration preserves existing rows and does not throw", () => {
|
|
156
|
+
const db = createTestDb();
|
|
157
|
+
const raw = getSqliteFrom(db);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
|
|
160
|
+
migrateMemoryBriefState(db);
|
|
161
|
+
|
|
162
|
+
// Insert rows into both tables
|
|
163
|
+
raw.exec(/*sql*/ `
|
|
164
|
+
INSERT INTO time_contexts (
|
|
165
|
+
id, scope_id, summary, source, active_from, active_until, created_at, updated_at
|
|
166
|
+
) VALUES (
|
|
167
|
+
'tc-1', 'default', 'User traveling next week', 'conversation', ${now}, ${now + 604800000}, ${now}, ${now}
|
|
168
|
+
)
|
|
169
|
+
`);
|
|
170
|
+
|
|
171
|
+
raw.exec(/*sql*/ `
|
|
172
|
+
INSERT INTO open_loops (
|
|
173
|
+
id, scope_id, summary, status, source, due_at, surfaced_at, created_at, updated_at
|
|
174
|
+
) VALUES (
|
|
175
|
+
'ol-1', 'default', 'Waiting for Bob reply', 'open', 'conversation', ${now + 86400000}, ${now}, ${now}, ${now}
|
|
176
|
+
)
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
// Re-run migration — should not throw
|
|
180
|
+
expect(() => migrateMemoryBriefState(db)).not.toThrow();
|
|
181
|
+
|
|
182
|
+
// Verify rows are intact
|
|
183
|
+
const tcRow = raw
|
|
184
|
+
.query(
|
|
185
|
+
`SELECT id, scope_id, summary FROM time_contexts WHERE id = 'tc-1'`,
|
|
186
|
+
)
|
|
187
|
+
.get() as { id: string; scope_id: string; summary: string } | null;
|
|
188
|
+
|
|
189
|
+
expect(tcRow).toEqual({
|
|
190
|
+
id: "tc-1",
|
|
191
|
+
scope_id: "default",
|
|
192
|
+
summary: "User traveling next week",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const olRow = raw
|
|
196
|
+
.query(
|
|
197
|
+
`SELECT id, scope_id, summary, status FROM open_loops WHERE id = 'ol-1'`,
|
|
198
|
+
)
|
|
199
|
+
.get() as {
|
|
200
|
+
id: string;
|
|
201
|
+
scope_id: string;
|
|
202
|
+
summary: string;
|
|
203
|
+
status: string;
|
|
204
|
+
} | null;
|
|
205
|
+
|
|
206
|
+
expect(olRow).toEqual({
|
|
207
|
+
id: "ol-1",
|
|
208
|
+
scope_id: "default",
|
|
209
|
+
summary: "Waiting for Bob reply",
|
|
210
|
+
status: "open",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|