@vellumai/assistant 0.5.3 → 0.5.5

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 (111) hide show
  1. package/Dockerfile +18 -27
  2. package/docs/architecture/memory.md +105 -0
  3. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  4. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/archive-recall.test.ts +560 -0
  7. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  8. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  11. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  12. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  13. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  14. package/src/__tests__/memory-reducer.test.ts +7 -1
  15. package/src/__tests__/memory-regressions.test.ts +24 -4
  16. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  17. package/src/__tests__/openai-whisper.test.ts +93 -0
  18. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  19. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  20. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  21. package/src/__tests__/volume-security-guard.test.ts +155 -0
  22. package/src/cli/commands/conversations.ts +18 -0
  23. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  24. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  25. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  26. package/src/config/env-registry.ts +9 -0
  27. package/src/config/feature-flag-registry.json +8 -0
  28. package/src/config/loader.ts +0 -1
  29. package/src/config/schemas/memory-simplified.ts +1 -1
  30. package/src/credential-execution/managed-catalog.ts +5 -15
  31. package/src/daemon/config-watcher.ts +4 -1
  32. package/src/daemon/conversation-memory.ts +117 -0
  33. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  34. package/src/daemon/daemon-control.ts +7 -0
  35. package/src/daemon/handlers/conversations.ts +11 -0
  36. package/src/daemon/lifecycle.ts +51 -2
  37. package/src/daemon/providers-setup.ts +2 -1
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/archive-recall.ts +516 -0
  41. package/src/memory/brief-time.ts +5 -4
  42. package/src/memory/conversation-crud.ts +210 -0
  43. package/src/memory/conversation-key-store.ts +33 -4
  44. package/src/memory/db-init.ts +4 -0
  45. package/src/memory/embedding-local.ts +11 -5
  46. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  47. package/src/memory/job-handlers/conversation-starters.ts +24 -30
  48. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  49. package/src/memory/jobs-store.ts +2 -0
  50. package/src/memory/jobs-worker.ts +8 -0
  51. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  52. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  53. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  54. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  55. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  56. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  57. package/src/memory/migrations/index.ts +1 -0
  58. package/src/memory/reducer-scheduler.ts +242 -0
  59. package/src/memory/reducer-types.ts +9 -2
  60. package/src/memory/reducer.ts +25 -11
  61. package/src/memory/schema/infrastructure.ts +1 -0
  62. package/src/messaging/provider.ts +9 -0
  63. package/src/messaging/providers/slack/adapter.ts +29 -2
  64. package/src/oauth/connection-resolver.test.ts +22 -18
  65. package/src/oauth/connection-resolver.ts +92 -7
  66. package/src/oauth/platform-connection.test.ts +78 -69
  67. package/src/oauth/platform-connection.ts +12 -19
  68. package/src/permissions/trust-client.ts +343 -0
  69. package/src/permissions/trust-store-interface.ts +105 -0
  70. package/src/permissions/trust-store.ts +523 -36
  71. package/src/platform/client.test.ts +148 -0
  72. package/src/platform/client.ts +71 -0
  73. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  74. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  75. package/src/providers/speech-to-text/resolve.ts +9 -0
  76. package/src/providers/speech-to-text/types.ts +17 -0
  77. package/src/runtime/auth/route-policy.ts +10 -1
  78. package/src/runtime/http-server.ts +2 -2
  79. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  80. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  81. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  82. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  83. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  84. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  85. package/src/runtime/routes/log-export-routes.ts +1 -0
  86. package/src/runtime/routes/secret-routes.ts +5 -1
  87. package/src/schedule/schedule-store.ts +7 -0
  88. package/src/schedule/scheduler.ts +6 -2
  89. package/src/security/ces-credential-client.ts +173 -0
  90. package/src/security/secure-keys.ts +65 -22
  91. package/src/signals/bash.ts +3 -0
  92. package/src/signals/cancel.ts +3 -0
  93. package/src/signals/confirm.ts +3 -0
  94. package/src/signals/conversation-undo.ts +3 -0
  95. package/src/signals/event-stream.ts +7 -0
  96. package/src/signals/shotgun.ts +3 -0
  97. package/src/signals/trust-rule.ts +3 -0
  98. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  99. package/src/telemetry/usage-telemetry-reporter.ts +22 -20
  100. package/src/tools/filesystem/edit.ts +6 -1
  101. package/src/tools/filesystem/read.ts +6 -1
  102. package/src/tools/filesystem/write.ts +6 -1
  103. package/src/tools/memory/handlers.ts +129 -1
  104. package/src/tools/schedule/create.ts +3 -0
  105. package/src/tools/schedule/list.ts +5 -1
  106. package/src/tools/schedule/update.ts +6 -0
  107. package/src/util/device-id.ts +70 -7
  108. package/src/util/logger.ts +35 -9
  109. package/src/util/platform.ts +29 -5
  110. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  111. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,473 @@
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(join(tmpdir(), "reducer-scheduling-test-"));
9
+
10
+ mock.module("../util/platform.js", () => ({
11
+ getDataDir: () => testDir,
12
+ getRootDir: () => testDir,
13
+ getWorkspaceDir: () => join(testDir, "workspace"),
14
+ getConversationsDir: () => join(testDir, "workspace", "conversations"),
15
+ isMacOS: () => process.platform === "darwin",
16
+ isLinux: () => process.platform === "linux",
17
+ isWindows: () => process.platform === "win32",
18
+ getPidPath: () => join(testDir, "test.pid"),
19
+ getDbPath: () => join(testDir, "test.db"),
20
+ getLogPath: () => join(testDir, "test.log"),
21
+ ensureDataDir: () => {},
22
+ }));
23
+
24
+ mock.module("../util/logger.js", () => ({
25
+ getLogger: () =>
26
+ new Proxy({} as Record<string, unknown>, {
27
+ get: () => () => {},
28
+ }),
29
+ }));
30
+
31
+ // ── Config mock — controllable idleDelayMs ───────────────────────
32
+
33
+ let mockIdleDelayMs = 30_000;
34
+
35
+ mock.module("../config/loader.js", () => ({
36
+ getConfig: () => ({
37
+ memory: {
38
+ simplified: {
39
+ reducer: {
40
+ idleDelayMs: mockIdleDelayMs,
41
+ switchWaitMs: 5_000,
42
+ },
43
+ },
44
+ },
45
+ }),
46
+ loadConfig: () => ({
47
+ memory: {
48
+ simplified: {
49
+ reducer: {
50
+ idleDelayMs: mockIdleDelayMs,
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
+ // ── Imports (after mocks) ─────────────────────────────────────────
81
+
82
+ import {
83
+ markConversationMemoryDirty,
84
+ scheduleReducerJob,
85
+ sweepStaleReducerJobs,
86
+ } from "../memory/conversation-crud.js";
87
+ import { initializeDb, resetDb } from "../memory/db.js";
88
+ import { getSqlite } from "../memory/db-connection.js";
89
+ import { resetTestTables } from "../memory/raw-query.js";
90
+
91
+ initializeDb();
92
+
93
+ // ── Helpers ───────────────────────────────────────────────────────
94
+
95
+ const NOW = 1_700_000_000_000;
96
+
97
+ function insertConversation(
98
+ id: string,
99
+ opts?: {
100
+ dirtyTailMessageId?: string | null;
101
+ createdAt?: number;
102
+ },
103
+ ): void {
104
+ const raw = getSqlite();
105
+ raw.run(
106
+ `INSERT INTO conversations (id, title, created_at, updated_at, conversation_type, source, memory_scope_id, is_auto_title,
107
+ memory_dirty_tail_since_message_id)
108
+ VALUES (?, 'Test', ?, ?, 'standard', 'user', 'default', 1, ?)`,
109
+ [
110
+ id,
111
+ opts?.createdAt ?? NOW,
112
+ opts?.createdAt ?? NOW,
113
+ opts?.dirtyTailMessageId ?? null,
114
+ ],
115
+ );
116
+ }
117
+
118
+ function insertMessage(opts: {
119
+ id: string;
120
+ conversationId: string;
121
+ role?: string;
122
+ content?: string;
123
+ createdAt?: number;
124
+ }): void {
125
+ const raw = getSqlite();
126
+ raw.run(
127
+ `INSERT INTO messages (id, conversation_id, role, content, created_at)
128
+ VALUES (?, ?, ?, ?, ?)`,
129
+ [
130
+ opts.id,
131
+ opts.conversationId,
132
+ opts.role ?? "user",
133
+ opts.content ?? "test message",
134
+ opts.createdAt ?? NOW,
135
+ ],
136
+ );
137
+ }
138
+
139
+ function getReducerJobs(
140
+ conversationId: string,
141
+ ): Array<Record<string, unknown>> {
142
+ const raw = getSqlite();
143
+ return raw
144
+ .query(
145
+ `SELECT * FROM memory_jobs
146
+ WHERE type = 'reduce_conversation_memory'
147
+ AND json_extract(payload, '$.conversationId') = ?
148
+ ORDER BY created_at ASC`,
149
+ )
150
+ .all(conversationId) as Array<Record<string, unknown>>;
151
+ }
152
+
153
+ function insertReducerJob(
154
+ conversationId: string,
155
+ opts?: { status?: string; runAfter?: number },
156
+ ): void {
157
+ const raw = getSqlite();
158
+ const now = Date.now();
159
+ raw.run(
160
+ `INSERT INTO memory_jobs (id, type, payload, status, attempts, deferrals, run_after, created_at, updated_at)
161
+ VALUES (?, 'reduce_conversation_memory', ?, ?, 0, 0, ?, ?, ?)`,
162
+ [
163
+ `job-${conversationId}-${now}`,
164
+ JSON.stringify({ conversationId }),
165
+ opts?.status ?? "pending",
166
+ opts?.runAfter ?? now + 30_000,
167
+ now,
168
+ now,
169
+ ],
170
+ );
171
+ }
172
+
173
+ // ── Teardown ──────────────────────────────────────────────────────
174
+
175
+ afterAll(() => {
176
+ resetDb();
177
+ try {
178
+ rmSync(testDir, { recursive: true });
179
+ } catch {
180
+ /* best effort */
181
+ }
182
+ });
183
+
184
+ beforeEach(() => {
185
+ resetTestTables("messages", "conversations", "memory_jobs");
186
+ mockIdleDelayMs = 30_000;
187
+ });
188
+
189
+ // ── Tests ─────────────────────────────────────────────────────────
190
+
191
+ describe("markConversationMemoryDirty — reducer job scheduling", () => {
192
+ test("creates a pending reducer job on first dirty mark", () => {
193
+ insertConversation("conv-1");
194
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
195
+
196
+ markConversationMemoryDirty("conv-1", "msg-1");
197
+
198
+ const jobs = getReducerJobs("conv-1");
199
+ expect(jobs).toHaveLength(1);
200
+ expect(jobs[0].status).toBe("pending");
201
+ expect(jobs[0].type).toBe("reduce_conversation_memory");
202
+ });
203
+
204
+ test("schedules reducer job with idleDelayMs offset from now", () => {
205
+ mockIdleDelayMs = 60_000;
206
+ insertConversation("conv-1");
207
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
208
+
209
+ const before = Date.now();
210
+ markConversationMemoryDirty("conv-1", "msg-1");
211
+ const after = Date.now();
212
+
213
+ const jobs = getReducerJobs("conv-1");
214
+ expect(jobs).toHaveLength(1);
215
+ const runAfter = jobs[0].run_after as number;
216
+ // runAfter should be approximately now + 60_000
217
+ expect(runAfter).toBeGreaterThanOrEqual(before + 60_000);
218
+ expect(runAfter).toBeLessThanOrEqual(after + 60_000);
219
+ });
220
+
221
+ test("deduplicates: second mark does not create a second job", () => {
222
+ insertConversation("conv-1");
223
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
224
+ insertMessage({
225
+ id: "msg-2",
226
+ conversationId: "conv-1",
227
+ createdAt: NOW + 1000,
228
+ });
229
+
230
+ markConversationMemoryDirty("conv-1", "msg-1");
231
+ markConversationMemoryDirty("conv-1", "msg-2");
232
+
233
+ const jobs = getReducerJobs("conv-1");
234
+ expect(jobs).toHaveLength(1);
235
+ });
236
+
237
+ test("reschedules: second mark pushes runAfter forward", () => {
238
+ mockIdleDelayMs = 10_000;
239
+ insertConversation("conv-1");
240
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
241
+
242
+ markConversationMemoryDirty("conv-1", "msg-1");
243
+ const jobs1 = getReducerJobs("conv-1");
244
+ const firstRunAfter = jobs1[0].run_after as number;
245
+
246
+ // Simulate a short delay before the next message
247
+ const pauseMs = 50;
248
+ Bun.sleepSync(pauseMs);
249
+
250
+ insertMessage({
251
+ id: "msg-2",
252
+ conversationId: "conv-1",
253
+ createdAt: NOW + 5000,
254
+ });
255
+ markConversationMemoryDirty("conv-1", "msg-2");
256
+
257
+ const jobs2 = getReducerJobs("conv-1");
258
+ expect(jobs2).toHaveLength(1);
259
+ const secondRunAfter = jobs2[0].run_after as number;
260
+ // The second runAfter should be later than the first
261
+ expect(secondRunAfter).toBeGreaterThan(firstRunAfter);
262
+ });
263
+
264
+ test("creates separate jobs for different conversations", () => {
265
+ insertConversation("conv-1");
266
+ insertConversation("conv-2");
267
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
268
+ insertMessage({ id: "msg-2", conversationId: "conv-2" });
269
+
270
+ markConversationMemoryDirty("conv-1", "msg-1");
271
+ markConversationMemoryDirty("conv-2", "msg-2");
272
+
273
+ const jobs1 = getReducerJobs("conv-1");
274
+ const jobs2 = getReducerJobs("conv-2");
275
+ expect(jobs1).toHaveLength(1);
276
+ expect(jobs2).toHaveLength(1);
277
+ // They should be different job rows
278
+ expect(jobs1[0].id).not.toBe(jobs2[0].id);
279
+ });
280
+
281
+ test("does not reschedule completed or failed jobs", () => {
282
+ insertConversation("conv-1");
283
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
284
+
285
+ // Insert a completed job for this conversation
286
+ insertReducerJob("conv-1", { status: "completed" });
287
+
288
+ markConversationMemoryDirty("conv-1", "msg-1");
289
+
290
+ // Should create a new pending job (not reuse the completed one)
291
+ const jobs = getReducerJobs("conv-1");
292
+ const pendingJobs = jobs.filter((j) => j.status === "pending");
293
+ expect(pendingJobs).toHaveLength(1);
294
+ });
295
+
296
+ test("does not reschedule running jobs", () => {
297
+ insertConversation("conv-1");
298
+ insertMessage({ id: "msg-1", conversationId: "conv-1" });
299
+
300
+ // Insert a running job for this conversation
301
+ insertReducerJob("conv-1", { status: "running" });
302
+
303
+ markConversationMemoryDirty("conv-1", "msg-1");
304
+
305
+ // Should create a new pending job since we only look at pending for rescheduling
306
+ const jobs = getReducerJobs("conv-1");
307
+ const pendingJobs = jobs.filter((j) => j.status === "pending");
308
+ expect(pendingJobs).toHaveLength(1);
309
+ });
310
+ });
311
+
312
+ describe("scheduleReducerJob — explicit runAfter override", () => {
313
+ test("accepts a custom runAfter timestamp", () => {
314
+ insertConversation("conv-1");
315
+
316
+ const customRunAfter = NOW + 999_999;
317
+ scheduleReducerJob("conv-1", customRunAfter);
318
+
319
+ const jobs = getReducerJobs("conv-1");
320
+ expect(jobs).toHaveLength(1);
321
+ expect(jobs[0].run_after).toBe(customRunAfter);
322
+ });
323
+ });
324
+
325
+ describe("sweepStaleReducerJobs — startup sweep", () => {
326
+ test("enqueues immediate jobs for stale dirty conversations", () => {
327
+ mockIdleDelayMs = 30_000;
328
+ const oldTime = Date.now() - 60_000; // Well past idle delay
329
+
330
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
331
+ insertMessage({
332
+ id: "msg-1",
333
+ conversationId: "conv-1",
334
+ createdAt: oldTime,
335
+ });
336
+
337
+ const count = sweepStaleReducerJobs();
338
+
339
+ expect(count).toBe(1);
340
+ const jobs = getReducerJobs("conv-1");
341
+ expect(jobs).toHaveLength(1);
342
+ expect(jobs[0].status).toBe("pending");
343
+ // Should be scheduled for immediate execution (runAfter <= now)
344
+ expect(jobs[0].run_after as number).toBeLessThanOrEqual(Date.now());
345
+ });
346
+
347
+ test("skips conversations that are not dirty", () => {
348
+ const oldTime = Date.now() - 60_000;
349
+
350
+ // Not dirty — no dirtyTailMessageId
351
+ insertConversation("conv-1");
352
+ insertMessage({
353
+ id: "msg-1",
354
+ conversationId: "conv-1",
355
+ createdAt: oldTime,
356
+ });
357
+
358
+ const count = sweepStaleReducerJobs();
359
+ expect(count).toBe(0);
360
+ expect(getReducerJobs("conv-1")).toHaveLength(0);
361
+ });
362
+
363
+ test("skips dirty conversations whose tail is within the idle window", () => {
364
+ mockIdleDelayMs = 30_000;
365
+ const recentTime = Date.now() - 5_000; // Only 5s ago, within idle delay
366
+
367
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
368
+ insertMessage({
369
+ id: "msg-1",
370
+ conversationId: "conv-1",
371
+ createdAt: recentTime,
372
+ });
373
+
374
+ const count = sweepStaleReducerJobs();
375
+ expect(count).toBe(0);
376
+ expect(getReducerJobs("conv-1")).toHaveLength(0);
377
+ });
378
+
379
+ test("skips conversations that already have a pending reducer job", () => {
380
+ mockIdleDelayMs = 30_000;
381
+ const oldTime = Date.now() - 60_000;
382
+
383
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
384
+ insertMessage({
385
+ id: "msg-1",
386
+ conversationId: "conv-1",
387
+ createdAt: oldTime,
388
+ });
389
+ insertReducerJob("conv-1", { status: "pending" });
390
+
391
+ const count = sweepStaleReducerJobs();
392
+ expect(count).toBe(0);
393
+ // Only the pre-existing job should be there
394
+ const jobs = getReducerJobs("conv-1");
395
+ expect(jobs).toHaveLength(1);
396
+ });
397
+
398
+ test("skips conversations that have a running reducer job", () => {
399
+ mockIdleDelayMs = 30_000;
400
+ const oldTime = Date.now() - 60_000;
401
+
402
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
403
+ insertMessage({
404
+ id: "msg-1",
405
+ conversationId: "conv-1",
406
+ createdAt: oldTime,
407
+ });
408
+ insertReducerJob("conv-1", { status: "running" });
409
+
410
+ const count = sweepStaleReducerJobs();
411
+ expect(count).toBe(0);
412
+ });
413
+
414
+ test("sweeps multiple stale conversations", () => {
415
+ mockIdleDelayMs = 30_000;
416
+ const oldTime = Date.now() - 60_000;
417
+
418
+ insertConversation("conv-1", { dirtyTailMessageId: "msg-1" });
419
+ insertMessage({
420
+ id: "msg-1",
421
+ conversationId: "conv-1",
422
+ createdAt: oldTime,
423
+ });
424
+
425
+ insertConversation("conv-2", { dirtyTailMessageId: "msg-2" });
426
+ insertMessage({
427
+ id: "msg-2",
428
+ conversationId: "conv-2",
429
+ createdAt: oldTime - 1000,
430
+ });
431
+
432
+ const count = sweepStaleReducerJobs();
433
+ expect(count).toBe(2);
434
+ expect(getReducerJobs("conv-1")).toHaveLength(1);
435
+ expect(getReducerJobs("conv-2")).toHaveLength(1);
436
+ });
437
+
438
+ test("only enqueues for stale conversations in a mixed set", () => {
439
+ mockIdleDelayMs = 30_000;
440
+ const oldTime = Date.now() - 60_000;
441
+ const recentTime = Date.now() - 5_000;
442
+
443
+ // Stale dirty conversation
444
+ insertConversation("conv-stale", { dirtyTailMessageId: "msg-1" });
445
+ insertMessage({
446
+ id: "msg-1",
447
+ conversationId: "conv-stale",
448
+ createdAt: oldTime,
449
+ });
450
+
451
+ // Recent dirty conversation (within idle window)
452
+ insertConversation("conv-recent", { dirtyTailMessageId: "msg-2" });
453
+ insertMessage({
454
+ id: "msg-2",
455
+ conversationId: "conv-recent",
456
+ createdAt: recentTime,
457
+ });
458
+
459
+ // Clean conversation
460
+ insertConversation("conv-clean");
461
+ insertMessage({
462
+ id: "msg-3",
463
+ conversationId: "conv-clean",
464
+ createdAt: oldTime,
465
+ });
466
+
467
+ const count = sweepStaleReducerJobs();
468
+ expect(count).toBe(1);
469
+ expect(getReducerJobs("conv-stale")).toHaveLength(1);
470
+ expect(getReducerJobs("conv-recent")).toHaveLength(0);
471
+ expect(getReducerJobs("conv-clean")).toHaveLength(0);
472
+ });
473
+ });
@@ -84,9 +84,12 @@ describe("parseReducerOutput — invalid inputs", () => {
84
84
  });
85
85
 
86
86
  test("returns empty result for object with no recognized arrays", () => {
87
- expect(parseReducerOutput(toRaw({ foo: "bar" }))).toBe(
88
- EMPTY_REDUCER_RESULT,
89
- );
87
+ expect(parseReducerOutput(toRaw({ foo: "bar" }))).toEqual({
88
+ timeContexts: [],
89
+ openLoops: [],
90
+ archiveObservations: [],
91
+ archiveEpisodes: [],
92
+ });
90
93
  });
91
94
 
92
95
  test("returns empty result when all recognized keys are not arrays", () => {
@@ -99,7 +102,12 @@ describe("parseReducerOutput — invalid inputs", () => {
99
102
  archiveEpisodes: true,
100
103
  }),
101
104
  ),
102
- ).toBe(EMPTY_REDUCER_RESULT);
105
+ ).toEqual({
106
+ timeContexts: [],
107
+ openLoops: [],
108
+ archiveObservations: [],
109
+ archiveEpisodes: [],
110
+ });
103
111
  });
104
112
  });
105
113
 
@@ -529,7 +529,13 @@ describe("runReducer — error handling", () => {
529
529
 
530
530
  const result = await runReducer(makeInput());
531
531
 
532
- expect(result).toBe(EMPTY_REDUCER_RESULT);
532
+ expect(result).toEqual({
533
+ timeContexts: [],
534
+ openLoops: [],
535
+ archiveObservations: [],
536
+ archiveEpisodes: [],
537
+ });
538
+ expect(result).not.toBe(EMPTY_REDUCER_RESULT);
533
539
  });
534
540
  });
535
541
 
@@ -540,13 +540,23 @@ describe("Memory regressions", () => {
540
540
 
541
541
  test("memory_save sets verificationState to user_confirmed", async () => {
542
542
  const { handleMemorySave } = await import("../tools/memory/handlers.js");
543
+ const legacyConfig = {
544
+ ...DEFAULT_CONFIG,
545
+ memory: {
546
+ ...DEFAULT_CONFIG.memory,
547
+ simplified: {
548
+ ...DEFAULT_CONFIG.memory.simplified,
549
+ enabled: false,
550
+ },
551
+ },
552
+ };
543
553
 
544
554
  const result = await handleMemorySave(
545
555
  {
546
556
  statement: "User explicitly saved this preference",
547
557
  kind: "preference",
548
558
  },
549
- DEFAULT_CONFIG,
559
+ legacyConfig,
550
560
  "conv-verify-save",
551
561
  "msg-verify-save",
552
562
  );
@@ -563,13 +573,23 @@ describe("Memory regressions", () => {
563
573
 
564
574
  test("memory_save in different scopes creates separate items", async () => {
565
575
  const { handleMemorySave } = await import("../tools/memory/handlers.js");
576
+ const legacyConfig = {
577
+ ...DEFAULT_CONFIG,
578
+ memory: {
579
+ ...DEFAULT_CONFIG.memory,
580
+ simplified: {
581
+ ...DEFAULT_CONFIG.memory.simplified,
582
+ enabled: false,
583
+ },
584
+ },
585
+ };
566
586
 
567
587
  const sharedArgs = { statement: "I prefer dark mode", kind: "preference" };
568
588
 
569
589
  // Save in the default scope
570
590
  const r1 = await handleMemorySave(
571
591
  sharedArgs,
572
- DEFAULT_CONFIG,
592
+ legacyConfig,
573
593
  "conv-scope-1",
574
594
  "msg-scope-1",
575
595
  "default",
@@ -580,7 +600,7 @@ describe("Memory regressions", () => {
580
600
  // Save the identical statement in a private scope
581
601
  const r2 = await handleMemorySave(
582
602
  sharedArgs,
583
- DEFAULT_CONFIG,
603
+ legacyConfig,
584
604
  "conv-scope-2",
585
605
  "msg-scope-2",
586
606
  "private-abc",
@@ -604,7 +624,7 @@ describe("Memory regressions", () => {
604
624
  // Saving the same statement again in default scope should dedup (not create a third)
605
625
  const r3 = await handleMemorySave(
606
626
  sharedArgs,
607
- DEFAULT_CONFIG,
627
+ legacyConfig,
608
628
  "conv-scope-3",
609
629
  "msg-scope-3",
610
630
  "default",
@@ -83,7 +83,7 @@ describe("MemorySimplifiedConfigSchema", () => {
83
83
  test("parses empty object with all defaults", () => {
84
84
  const result = MemorySimplifiedConfigSchema.parse({});
85
85
  expect(result).toEqual({
86
- enabled: false,
86
+ enabled: true,
87
87
  brief: { maxTokens: 4000 },
88
88
  reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
89
89
  archiveRecall: { maxSnippets: 10 },
@@ -175,7 +175,7 @@ describe("AssistantConfigSchema memory.simplified", () => {
175
175
  test("empty config exposes memory.simplified with defaults", () => {
176
176
  const result = AssistantConfigSchema.parse({});
177
177
  expect(result.memory.simplified).toEqual({
178
- enabled: false,
178
+ enabled: true,
179
179
  brief: { maxTokens: 4000 },
180
180
  reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
181
181
  archiveRecall: { maxSnippets: 10 },
@@ -248,7 +248,7 @@ describe("loadConfig with memory.simplified", () => {
248
248
  writeConfig({});
249
249
  const config = loadConfig();
250
250
  expect(config.memory.simplified).toEqual({
251
- enabled: false,
251
+ enabled: true,
252
252
  brief: { maxTokens: 4000 },
253
253
  reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
254
254
  archiveRecall: { maxSnippets: 10 },
@@ -258,7 +258,7 @@ describe("loadConfig with memory.simplified", () => {
258
258
  test("no config file loads cleanly with simplified defaults", () => {
259
259
  const config = loadConfig();
260
260
  expect(config.memory.simplified).toEqual({
261
- enabled: false,
261
+ enabled: true,
262
262
  brief: { maxTokens: 4000 },
263
263
  reducer: { idleDelayMs: 30_000, switchWaitMs: 5_000 },
264
264
  archiveRecall: { maxSnippets: 10 },