@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.
Files changed (144) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/architecture/memory.md +105 -0
  3. package/docs/skills.md +100 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/archive-recall.test.ts +560 -0
  6. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  7. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  8. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  9. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  10. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  11. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  12. package/src/__tests__/conversation-wipe.test.ts +226 -0
  13. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  14. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  15. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  16. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  17. package/src/__tests__/inline-command-runner.test.ts +311 -0
  18. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  19. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  20. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  21. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  22. package/src/__tests__/memory-brief-time.test.ts +285 -0
  23. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  24. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  25. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  26. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  27. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  28. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  29. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  30. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  31. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  32. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  33. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  34. package/src/__tests__/memory-reducer-types.test.ts +707 -0
  35. package/src/__tests__/memory-reducer.test.ts +704 -0
  36. package/src/__tests__/memory-regressions.test.ts +30 -8
  37. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  38. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  39. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  40. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  41. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  42. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  43. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  44. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  45. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  46. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  47. package/src/cli/commands/conversations.ts +18 -0
  48. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  49. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  50. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  51. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  52. package/src/config/feature-flag-registry.json +16 -0
  53. package/src/config/raw-config-utils.ts +28 -0
  54. package/src/config/schema.ts +12 -0
  55. package/src/config/schemas/memory-simplified.ts +101 -0
  56. package/src/config/schemas/memory.ts +4 -0
  57. package/src/config/skills.ts +50 -4
  58. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  59. package/src/daemon/conversation-agent-loop.ts +71 -1
  60. package/src/daemon/conversation-lifecycle.ts +11 -1
  61. package/src/daemon/conversation-memory.ts +117 -0
  62. package/src/daemon/conversation-runtime-assembly.ts +3 -1
  63. package/src/daemon/conversation-surfaces.ts +31 -8
  64. package/src/daemon/conversation.ts +40 -23
  65. package/src/daemon/handlers/config-embeddings.ts +10 -2
  66. package/src/daemon/handlers/config-model.ts +0 -9
  67. package/src/daemon/handlers/conversations.ts +11 -0
  68. package/src/daemon/handlers/identity.ts +12 -1
  69. package/src/daemon/lifecycle.ts +52 -1
  70. package/src/daemon/message-types/conversations.ts +0 -1
  71. package/src/daemon/server.ts +1 -1
  72. package/src/followups/followup-store.ts +47 -1
  73. package/src/memory/archive-recall.ts +516 -0
  74. package/src/memory/archive-store.ts +400 -0
  75. package/src/memory/brief-formatting.ts +33 -0
  76. package/src/memory/brief-open-loops.ts +266 -0
  77. package/src/memory/brief-time.ts +162 -0
  78. package/src/memory/brief.ts +75 -0
  79. package/src/memory/conversation-crud.ts +455 -101
  80. package/src/memory/conversation-key-store.ts +33 -4
  81. package/src/memory/db-init.ts +16 -0
  82. package/src/memory/indexer.ts +106 -15
  83. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  84. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  85. package/src/memory/job-handlers/embedding.test.ts +1 -0
  86. package/src/memory/job-handlers/embedding.ts +83 -0
  87. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  88. package/src/memory/job-utils.ts +1 -1
  89. package/src/memory/jobs-store.ts +8 -0
  90. package/src/memory/jobs-worker.ts +20 -0
  91. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  92. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  93. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  94. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  95. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  96. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  97. package/src/memory/migrations/186-memory-archive.ts +109 -0
  98. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  99. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  100. package/src/memory/migrations/index.ts +4 -0
  101. package/src/memory/qdrant-client.ts +23 -4
  102. package/src/memory/reducer-scheduler.ts +242 -0
  103. package/src/memory/reducer-store.ts +271 -0
  104. package/src/memory/reducer-types.ts +106 -0
  105. package/src/memory/reducer.ts +467 -0
  106. package/src/memory/schema/conversations.ts +3 -0
  107. package/src/memory/schema/index.ts +2 -0
  108. package/src/memory/schema/infrastructure.ts +1 -0
  109. package/src/memory/schema/memory-archive.ts +121 -0
  110. package/src/memory/schema/memory-brief.ts +55 -0
  111. package/src/memory/search/semantic.ts +17 -4
  112. package/src/oauth/oauth-store.ts +3 -1
  113. package/src/permissions/checker.ts +89 -6
  114. package/src/permissions/defaults.ts +14 -0
  115. package/src/runtime/auth/route-policy.ts +10 -1
  116. package/src/runtime/routes/conversation-management-routes.ts +94 -2
  117. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  118. package/src/runtime/routes/conversation-routes.ts +52 -5
  119. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  120. package/src/runtime/routes/identity-routes.ts +2 -35
  121. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  122. package/src/runtime/routes/memory-item-routes.ts +90 -5
  123. package/src/runtime/routes/secret-routes.ts +3 -0
  124. package/src/runtime/routes/surface-action-routes.ts +68 -1
  125. package/src/schedule/schedule-store.ts +28 -0
  126. package/src/schedule/scheduler.ts +6 -2
  127. package/src/skills/inline-command-expansions.ts +204 -0
  128. package/src/skills/inline-command-render.ts +127 -0
  129. package/src/skills/inline-command-runner.ts +242 -0
  130. package/src/skills/transitive-version-hash.ts +88 -0
  131. package/src/tasks/task-store.ts +43 -1
  132. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  133. package/src/tools/filesystem/edit.ts +6 -1
  134. package/src/tools/filesystem/read.ts +6 -1
  135. package/src/tools/filesystem/write.ts +6 -1
  136. package/src/tools/memory/handlers.ts +129 -1
  137. package/src/tools/permission-checker.ts +8 -1
  138. package/src/tools/schedule/create.ts +3 -0
  139. package/src/tools/schedule/list.ts +5 -1
  140. package/src/tools/schedule/update.ts +6 -0
  141. package/src/tools/skills/load.ts +140 -6
  142. package/src/util/platform.ts +18 -0
  143. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  144. 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
+ });