@vellumai/assistant 0.5.3 → 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 (57) hide show
  1. package/docs/architecture/memory.md +105 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/archive-recall.test.ts +560 -0
  4. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  5. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  6. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  7. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  8. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  9. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  10. package/src/__tests__/memory-reducer.test.ts +7 -1
  11. package/src/__tests__/memory-regressions.test.ts +24 -4
  12. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  13. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  14. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  15. package/src/cli/commands/conversations.ts +18 -0
  16. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  17. package/src/config/loader.ts +0 -1
  18. package/src/config/schemas/memory-simplified.ts +1 -1
  19. package/src/daemon/conversation-memory.ts +117 -0
  20. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  21. package/src/daemon/handlers/conversations.ts +11 -0
  22. package/src/daemon/lifecycle.ts +44 -1
  23. package/src/memory/archive-recall.ts +516 -0
  24. package/src/memory/brief-time.ts +5 -4
  25. package/src/memory/conversation-crud.ts +210 -0
  26. package/src/memory/conversation-key-store.ts +33 -4
  27. package/src/memory/db-init.ts +4 -0
  28. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  29. package/src/memory/job-handlers/conversation-starters.ts +9 -3
  30. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  31. package/src/memory/jobs-store.ts +2 -0
  32. package/src/memory/jobs-worker.ts +8 -0
  33. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  34. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  35. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  36. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  37. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  38. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  39. package/src/memory/migrations/index.ts +1 -0
  40. package/src/memory/reducer-scheduler.ts +242 -0
  41. package/src/memory/reducer-types.ts +9 -2
  42. package/src/memory/reducer.ts +25 -11
  43. package/src/memory/schema/infrastructure.ts +1 -0
  44. package/src/runtime/auth/route-policy.ts +10 -1
  45. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  46. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  47. package/src/runtime/routes/secret-routes.ts +1 -0
  48. package/src/schedule/schedule-store.ts +7 -0
  49. package/src/schedule/scheduler.ts +6 -2
  50. package/src/telemetry/usage-telemetry-reporter.ts +1 -1
  51. package/src/tools/filesystem/edit.ts +6 -1
  52. package/src/tools/filesystem/read.ts +6 -1
  53. package/src/tools/filesystem/write.ts +6 -1
  54. package/src/tools/memory/handlers.ts +129 -1
  55. package/src/tools/schedule/create.ts +3 -0
  56. package/src/tools/schedule/list.ts +5 -1
  57. package/src/tools/schedule/update.ts +6 -0
@@ -0,0 +1,474 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ // ── Test directory & platform mocks ───────────────────────────────
7
+
8
+ const testDir = mkdtempSync(
9
+ join(tmpdir(), "conversation-switch-memory-reduction-test-"),
10
+ );
11
+
12
+ mock.module("../util/platform.js", () => ({
13
+ getDataDir: () => testDir,
14
+ getRootDir: () => testDir,
15
+ getWorkspaceDir: () => join(testDir, "workspace"),
16
+ getConversationsDir: () => join(testDir, "workspace", "conversations"),
17
+ isMacOS: () => process.platform === "darwin",
18
+ isLinux: () => process.platform === "linux",
19
+ isWindows: () => process.platform === "win32",
20
+ getPidPath: () => join(testDir, "test.pid"),
21
+ getDbPath: () => join(testDir, "test.db"),
22
+ getLogPath: () => join(testDir, "test.log"),
23
+ ensureDataDir: () => {},
24
+ }));
25
+
26
+ mock.module("../util/logger.js", () => ({
27
+ getLogger: () =>
28
+ new Proxy({} as Record<string, unknown>, {
29
+ get: () => () => {},
30
+ }),
31
+ }));
32
+
33
+ // ── Config mock ───────────────────────────────────────────────────
34
+
35
+ mock.module("../config/loader.js", () => ({
36
+ getConfig: () => ({
37
+ memory: {
38
+ simplified: {
39
+ reducer: {
40
+ idleDelayMs: 30_000,
41
+ switchWaitMs: 5_000,
42
+ },
43
+ },
44
+ },
45
+ }),
46
+ loadConfig: () => ({
47
+ memory: {
48
+ simplified: {
49
+ reducer: {
50
+ idleDelayMs: 30_000,
51
+ switchWaitMs: 5_000,
52
+ },
53
+ },
54
+ },
55
+ }),
56
+ }));
57
+
58
+ // ── Suppress disk-view side effects ──────────────────────────────
59
+
60
+ mock.module("../memory/conversation-disk-view.js", () => ({
61
+ initConversationDir: () => {},
62
+ removeConversationDir: () => {},
63
+ syncMessageToDisk: () => {},
64
+ updateMetaFile: () => {},
65
+ }));
66
+
67
+ // ── Suppress indexer side effects ────────────────────────────────
68
+
69
+ mock.module("../memory/indexer.js", () => ({
70
+ indexMessageNow: async () => {},
71
+ }));
72
+
73
+ // ── Suppress attention side effects ──────────────────────────────
74
+
75
+ mock.module("../memory/conversation-attention-store.js", () => ({
76
+ projectAssistantMessage: () => {},
77
+ seedForkedConversationAttention: () => {},
78
+ }));
79
+
80
+ // ── Mock the reducer ──────────────────────────────────────────────
81
+
82
+ import type { ReducerPromptInput } from "../memory/reducer.js";
83
+ import type { ReducerResult } from "../memory/reducer-types.js";
84
+ import { EMPTY_REDUCER_RESULT } from "../memory/reducer-types.js";
85
+
86
+ let mockReducerResult: ReducerResult = EMPTY_REDUCER_RESULT;
87
+ let lastReducerInput: ReducerPromptInput | null = null;
88
+ let reducerCallCount = 0;
89
+
90
+ mock.module("../memory/reducer.js", () => ({
91
+ runReducer: async (input: ReducerPromptInput) => {
92
+ lastReducerInput = input;
93
+ reducerCallCount++;
94
+ return mockReducerResult;
95
+ },
96
+ }));
97
+
98
+ // ── Imports (after mocks) ─────────────────────────────────────────
99
+
100
+ import { initializeDb, resetDb } from "../memory/db.js";
101
+ import { getSqlite } from "../memory/db-connection.js";
102
+ import { resetTestTables } from "../memory/raw-query.js";
103
+ import {
104
+ findMostRecentDirtyConversation,
105
+ reduceBeforeSwitch,
106
+ } from "../memory/reducer-scheduler.js";
107
+
108
+ initializeDb();
109
+
110
+ // ── Helpers ───────────────────────────────────────────────────────
111
+
112
+ const NOW = 1_700_000_000_000;
113
+ const SCOPE = "default";
114
+
115
+ function insertConversation(
116
+ id: string,
117
+ opts?: {
118
+ dirtyTailMessageId?: string | null;
119
+ updatedAt?: number;
120
+ memoryScopeId?: string;
121
+ contextSummary?: string;
122
+ },
123
+ ): void {
124
+ const raw = getSqlite();
125
+ raw.run(
126
+ `INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title,
127
+ memory_dirty_tail_since_message_id, context_summary)
128
+ VALUES (?, 'Test', ?, ?, 'standard', 'user', ?, 1, ?, ?)`,
129
+ [
130
+ id,
131
+ NOW,
132
+ opts?.updatedAt ?? NOW,
133
+ opts?.memoryScopeId ?? SCOPE,
134
+ opts?.dirtyTailMessageId ?? null,
135
+ opts?.contextSummary ?? null,
136
+ ],
137
+ );
138
+ }
139
+
140
+ function insertMessage(opts: {
141
+ id: string;
142
+ conversationId: string;
143
+ role?: string;
144
+ content?: string;
145
+ createdAt?: number;
146
+ }): void {
147
+ const raw = getSqlite();
148
+ raw.run(
149
+ `INSERT INTO messages (id, conversation_id, role, content, created_at)
150
+ VALUES (?, ?, ?, ?, ?)`,
151
+ [
152
+ opts.id,
153
+ opts.conversationId,
154
+ opts.role ?? "user",
155
+ opts.content ?? "test message",
156
+ opts.createdAt ?? NOW,
157
+ ],
158
+ );
159
+ }
160
+
161
+ function getRawConversation(conversationId: string): Record<string, unknown> {
162
+ const raw = getSqlite();
163
+ return raw
164
+ .query(`SELECT * FROM conversations WHERE id = ?`)
165
+ .get(conversationId) as Record<string, unknown>;
166
+ }
167
+
168
+ function makeReducerResult(overrides?: Partial<ReducerResult>): ReducerResult {
169
+ return {
170
+ timeContexts: [],
171
+ openLoops: [],
172
+ archiveObservations: [],
173
+ archiveEpisodes: [],
174
+ ...overrides,
175
+ };
176
+ }
177
+
178
+ // ── Teardown ──────────────────────────────────────────────────────
179
+
180
+ afterAll(() => {
181
+ resetDb();
182
+ try {
183
+ rmSync(testDir, { recursive: true });
184
+ } catch {
185
+ /* best effort */
186
+ }
187
+ });
188
+
189
+ beforeEach(() => {
190
+ resetTestTables(
191
+ "messages",
192
+ "conversations",
193
+ "memory_jobs",
194
+ "time_contexts",
195
+ "open_loops",
196
+ );
197
+ mockReducerResult = EMPTY_REDUCER_RESULT;
198
+ lastReducerInput = null;
199
+ reducerCallCount = 0;
200
+ });
201
+
202
+ // ── Tests ─────────────────────────────────────────────────────────
203
+
204
+ describe("findMostRecentDirtyConversation", () => {
205
+ test("returns the most recently updated dirty conversation", () => {
206
+ insertConversation("conv-old", {
207
+ dirtyTailMessageId: "msg-old",
208
+ updatedAt: NOW - 5000,
209
+ });
210
+ insertConversation("conv-recent", {
211
+ dirtyTailMessageId: "msg-recent",
212
+ updatedAt: NOW,
213
+ });
214
+ insertConversation("conv-target", { updatedAt: NOW + 1000 });
215
+
216
+ const result = findMostRecentDirtyConversation("conv-target");
217
+ // Should return the most recently updated dirty conversation (ordered by updatedAt DESC)
218
+ expect(result).toBe("conv-recent");
219
+ });
220
+
221
+ test("excludes the target conversation", () => {
222
+ insertConversation("conv-dirty", {
223
+ dirtyTailMessageId: "msg-1",
224
+ updatedAt: NOW,
225
+ });
226
+
227
+ const result = findMostRecentDirtyConversation("conv-dirty");
228
+ expect(result).toBeNull();
229
+ });
230
+
231
+ test("returns null when no dirty conversations exist", () => {
232
+ insertConversation("conv-clean", { updatedAt: NOW });
233
+
234
+ const result = findMostRecentDirtyConversation("conv-target");
235
+ expect(result).toBeNull();
236
+ });
237
+
238
+ test("returns null when only dirty conversation is the target", () => {
239
+ insertConversation("conv-target", {
240
+ dirtyTailMessageId: "msg-1",
241
+ updatedAt: NOW,
242
+ });
243
+ insertConversation("conv-clean", { updatedAt: NOW + 1000 });
244
+
245
+ const result = findMostRecentDirtyConversation("conv-target");
246
+ expect(result).toBeNull();
247
+ });
248
+ });
249
+
250
+ describe("reduceBeforeSwitch — conversation switch", () => {
251
+ test("reduces the dirty conversation before switching", async () => {
252
+ // Previous conversation with dirty messages
253
+ insertConversation("conv-prev", {
254
+ dirtyTailMessageId: "msg-1",
255
+ updatedAt: NOW,
256
+ });
257
+ insertMessage({
258
+ id: "msg-1",
259
+ conversationId: "conv-prev",
260
+ role: "user",
261
+ content: "Hello",
262
+ createdAt: NOW,
263
+ });
264
+ insertMessage({
265
+ id: "msg-2",
266
+ conversationId: "conv-prev",
267
+ role: "assistant",
268
+ content: "Hi there!",
269
+ createdAt: NOW + 1000,
270
+ });
271
+
272
+ // Target conversation
273
+ insertConversation("conv-target", { updatedAt: NOW + 5000 });
274
+
275
+ mockReducerResult = makeReducerResult({
276
+ openLoops: [
277
+ {
278
+ action: "create",
279
+ summary: "User greeted the assistant",
280
+ source: "conversation",
281
+ },
282
+ ],
283
+ });
284
+
285
+ const result = await reduceBeforeSwitch("conv-target");
286
+
287
+ // Should have reduced conv-prev
288
+ expect(result).toBe("conv-prev");
289
+ expect(reducerCallCount).toBe(1);
290
+
291
+ // Checkpoint should be advanced
292
+ const conv = getRawConversation("conv-prev");
293
+ expect(conv.memory_reduced_through_message_id).toBe("msg-2");
294
+ expect(conv.memory_dirty_tail_since_message_id).toBeNull();
295
+ });
296
+
297
+ test("skips when no eligible dirty conversation exists", async () => {
298
+ insertConversation("conv-clean", { updatedAt: NOW });
299
+ insertConversation("conv-target", { updatedAt: NOW + 1000 });
300
+
301
+ const result = await reduceBeforeSwitch("conv-target");
302
+
303
+ expect(result).toBeNull();
304
+ expect(reducerCallCount).toBe(0);
305
+ });
306
+
307
+ test("skips when the only dirty conversation is the target", async () => {
308
+ insertConversation("conv-target", {
309
+ dirtyTailMessageId: "msg-1",
310
+ updatedAt: NOW,
311
+ });
312
+ insertMessage({ id: "msg-1", conversationId: "conv-target" });
313
+
314
+ const result = await reduceBeforeSwitch("conv-target");
315
+
316
+ expect(result).toBeNull();
317
+ expect(reducerCallCount).toBe(0);
318
+ });
319
+
320
+ test("does not advance checkpoint when reducer returns empty result", async () => {
321
+ insertConversation("conv-prev", {
322
+ dirtyTailMessageId: "msg-1",
323
+ updatedAt: NOW,
324
+ });
325
+ insertMessage({
326
+ id: "msg-1",
327
+ conversationId: "conv-prev",
328
+ role: "user",
329
+ content: "Hello",
330
+ createdAt: NOW,
331
+ });
332
+ insertConversation("conv-target", { updatedAt: NOW + 5000 });
333
+
334
+ mockReducerResult = EMPTY_REDUCER_RESULT;
335
+
336
+ const result = await reduceBeforeSwitch("conv-target");
337
+
338
+ // Returns null because empty result means nothing was reduced
339
+ expect(result).toBeNull();
340
+
341
+ // Checkpoint should NOT advance
342
+ const conv = getRawConversation("conv-prev");
343
+ expect(conv.memory_reduced_through_message_id).toBeNull();
344
+ expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
345
+ });
346
+ });
347
+
348
+ describe("reduceBeforeSwitch — new conversation", () => {
349
+ test("reduces the previous dirty conversation when starting a new one", async () => {
350
+ insertConversation("conv-prev", {
351
+ dirtyTailMessageId: "msg-1",
352
+ updatedAt: NOW,
353
+ });
354
+ insertMessage({
355
+ id: "msg-1",
356
+ conversationId: "conv-prev",
357
+ role: "user",
358
+ content: "Some prior work",
359
+ createdAt: NOW,
360
+ });
361
+
362
+ // The new conversation ID (just created)
363
+ const newConvId = "conv-new";
364
+ insertConversation(newConvId, { updatedAt: NOW + 5000 });
365
+
366
+ mockReducerResult = makeReducerResult({
367
+ timeContexts: [
368
+ {
369
+ action: "create",
370
+ summary: "Prior work in progress",
371
+ source: "conversation",
372
+ activeFrom: NOW,
373
+ activeUntil: NOW + 7 * 24 * 60 * 60 * 1000,
374
+ },
375
+ ],
376
+ });
377
+
378
+ const result = await reduceBeforeSwitch(newConvId);
379
+
380
+ expect(result).toBe("conv-prev");
381
+ expect(reducerCallCount).toBe(1);
382
+
383
+ // Verify the previous conversation's checkpoint was advanced
384
+ const conv = getRawConversation("conv-prev");
385
+ expect(conv.memory_reduced_through_message_id).toBe("msg-1");
386
+ expect(conv.memory_dirty_tail_since_message_id).toBeNull();
387
+ });
388
+ });
389
+
390
+ describe("reduceBeforeSwitch — most recent dirty selection", () => {
391
+ test("selects the most recently updated dirty conversation when multiple exist", async () => {
392
+ // Two dirty conversations — conv-newer is more recently updated
393
+ insertConversation("conv-older", {
394
+ dirtyTailMessageId: "msg-older",
395
+ updatedAt: NOW - 10_000,
396
+ });
397
+ insertMessage({
398
+ id: "msg-older",
399
+ conversationId: "conv-older",
400
+ role: "user",
401
+ content: "Older conversation",
402
+ createdAt: NOW - 10_000,
403
+ });
404
+
405
+ insertConversation("conv-newer", {
406
+ dirtyTailMessageId: "msg-newer",
407
+ updatedAt: NOW,
408
+ });
409
+ insertMessage({
410
+ id: "msg-newer",
411
+ conversationId: "conv-newer",
412
+ role: "user",
413
+ content: "Newer conversation",
414
+ createdAt: NOW,
415
+ });
416
+
417
+ insertConversation("conv-target", { updatedAt: NOW + 5000 });
418
+
419
+ mockReducerResult = makeReducerResult();
420
+
421
+ // Even though two are dirty, we only reduce one per switch.
422
+ // The function picks the most recently updated (by updatedAt DESC).
423
+ const result = await reduceBeforeSwitch("conv-target");
424
+
425
+ // Should pick the most recently updated dirty conversation
426
+ expect(result).toBe("conv-newer");
427
+ expect(reducerCallCount).toBe(1);
428
+ expect(lastReducerInput?.conversationId).toBe("conv-newer");
429
+ });
430
+ });
431
+
432
+ describe("reduceBeforeSwitch — error handling", () => {
433
+ test("returns null and continues when reducer throws", async () => {
434
+ insertConversation("conv-prev", {
435
+ dirtyTailMessageId: "msg-1",
436
+ updatedAt: NOW,
437
+ });
438
+ insertMessage({
439
+ id: "msg-1",
440
+ conversationId: "conv-prev",
441
+ role: "user",
442
+ content: "Hello",
443
+ createdAt: NOW,
444
+ });
445
+ insertConversation("conv-target", { updatedAt: NOW + 5000 });
446
+
447
+ // Override mock to throw
448
+ mock.module("../memory/reducer.js", () => ({
449
+ runReducer: async () => {
450
+ reducerCallCount++;
451
+ throw new Error("Provider timeout");
452
+ },
453
+ }));
454
+
455
+ const result = await reduceBeforeSwitch("conv-target");
456
+
457
+ // Should return null (graceful failure, don't block the switch)
458
+ expect(result).toBeNull();
459
+
460
+ // Checkpoint should NOT advance
461
+ const conv = getRawConversation("conv-prev");
462
+ expect(conv.memory_reduced_through_message_id).toBeNull();
463
+ expect(conv.memory_dirty_tail_since_message_id).toBe("msg-1");
464
+
465
+ // Restore normal mock for subsequent tests
466
+ mock.module("../memory/reducer.js", () => ({
467
+ runReducer: async (input: ReducerPromptInput) => {
468
+ lastReducerInput = input;
469
+ reducerCallCount++;
470
+ return mockReducerResult;
471
+ },
472
+ }));
473
+ });
474
+ });
@@ -41,6 +41,7 @@ describe("schedule_syntax column migration", () => {
41
41
  routing_intent TEXT NOT NULL DEFAULT 'all_channels',
42
42
  routing_hints_json TEXT NOT NULL DEFAULT '{}',
43
43
  status TEXT NOT NULL DEFAULT 'active',
44
+ quiet INTEGER NOT NULL DEFAULT 0,
44
45
  created_at INTEGER NOT NULL,
45
46
  updated_at INTEGER NOT NULL
46
47
  )
@@ -96,6 +97,7 @@ describe("schedule_syntax column migration", () => {
96
97
  routing_intent TEXT NOT NULL DEFAULT 'all_channels',
97
98
  routing_hints_json TEXT NOT NULL DEFAULT '{}',
98
99
  status TEXT NOT NULL DEFAULT 'active',
100
+ quiet INTEGER NOT NULL DEFAULT 0,
99
101
  created_at INTEGER NOT NULL,
100
102
  updated_at INTEGER NOT NULL
101
103
  )
@@ -145,6 +147,7 @@ describe("schedule_syntax column migration", () => {
145
147
  routing_intent TEXT NOT NULL DEFAULT 'all_channels',
146
148
  routing_hints_json TEXT NOT NULL DEFAULT '{}',
147
149
  status TEXT NOT NULL DEFAULT 'active',
150
+ quiet INTEGER NOT NULL DEFAULT 0,
148
151
  created_at INTEGER NOT NULL,
149
152
  updated_at INTEGER NOT NULL
150
153
  )