@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,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
+ });
@@ -26,6 +26,7 @@ mock.module("../util/logger.js", () => ({
26
26
  import {
27
27
  addMessage,
28
28
  createConversation,
29
+ deleteConversation,
29
30
  getConversation,
30
31
  getMessages,
31
32
  wipeConversation,
@@ -436,3 +437,228 @@ describe("wipeConversation", () => {
436
437
  expect(itemBRow).not.toBeNull();
437
438
  });
438
439
  });
440
+
441
+ describe("deleteConversation — private scope cleanup", () => {
442
+ beforeEach(() => {
443
+ const db = getDb();
444
+ db.run(`DELETE FROM conversation_starters`);
445
+ db.run(`DELETE FROM memory_item_sources`);
446
+ db.run(`DELETE FROM memory_segments`);
447
+ db.run(`DELETE FROM memory_items`);
448
+ db.run(`DELETE FROM memory_summaries`);
449
+ db.run(`DELETE FROM memory_embeddings`);
450
+ db.run(`DELETE FROM memory_jobs`);
451
+ db.run(`DELETE FROM tool_invocations`);
452
+ db.run(`DELETE FROM llm_request_logs`);
453
+ db.run(`DELETE FROM messages`);
454
+ db.run(`DELETE FROM conversations`);
455
+ });
456
+
457
+ test("sourceless items cleaned up", () => {
458
+ const conv = createConversation({ conversationType: "private" });
459
+ const scopeId = conv.memoryScopeId;
460
+ const now = Date.now();
461
+
462
+ const raw = (
463
+ getDb() as unknown as {
464
+ $client: import("bun:sqlite").Database;
465
+ }
466
+ ).$client;
467
+
468
+ // Insert a memory item with matching scopeId but no memory_item_sources
469
+ raw
470
+ .query(
471
+ `INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
472
+ VALUES ('priv-item-1', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-priv-1', ?, ?, ?)`,
473
+ )
474
+ .run(scopeId, now, now);
475
+
476
+ const result = deleteConversation(conv.id);
477
+
478
+ // Item should be gone
479
+ const itemRow = raw
480
+ .query("SELECT * FROM memory_items WHERE id = 'priv-item-1'")
481
+ .get();
482
+ expect(itemRow).toBeNull();
483
+
484
+ // Its ID should be in orphanedItemIds
485
+ expect(result.orphanedItemIds).toContain("priv-item-1");
486
+ });
487
+
488
+ test("summaries cleaned up", () => {
489
+ const conv = createConversation({ conversationType: "private" });
490
+ const scopeId = conv.memoryScopeId;
491
+ const now = Date.now();
492
+
493
+ const raw = (
494
+ getDb() as unknown as {
495
+ $client: import("bun:sqlite").Database;
496
+ }
497
+ ).$client;
498
+
499
+ // Insert a memory summary with matching scopeId
500
+ raw
501
+ .query(
502
+ `INSERT INTO memory_summaries (id, scope, scope_key, summary, token_estimate, version, scope_id, start_at, end_at, created_at, updated_at)
503
+ VALUES ('priv-sum-1', 'global', 'all', 'private summary', 100, 1, ?, ?, ?, ?, ?)`,
504
+ )
505
+ .run(scopeId, now, now, now, now);
506
+
507
+ const result = deleteConversation(conv.id);
508
+
509
+ // Summary should be gone
510
+ const summaryRow = raw
511
+ .query("SELECT * FROM memory_summaries WHERE id = 'priv-sum-1'")
512
+ .get();
513
+ expect(summaryRow).toBeNull();
514
+
515
+ // Its ID should be in deletedSummaryIds
516
+ expect(result.deletedSummaryIds).toContain("priv-sum-1");
517
+ });
518
+
519
+ test("standard conversations unaffected", async () => {
520
+ const conv = createConversation("standard test");
521
+ const now = Date.now();
522
+
523
+ const raw = (
524
+ getDb() as unknown as {
525
+ $client: import("bun:sqlite").Database;
526
+ }
527
+ ).$client;
528
+
529
+ // Insert items with scopeId = "default"
530
+ raw
531
+ .query(
532
+ `INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
533
+ VALUES ('default-item-1', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-default', 'default', ?, ?)`,
534
+ )
535
+ .run(now, now);
536
+
537
+ deleteConversation(conv.id);
538
+
539
+ // Default-scope items should still exist
540
+ const itemRow = raw
541
+ .query("SELECT * FROM memory_items WHERE id = 'default-item-1'")
542
+ .get();
543
+ expect(itemRow).not.toBeNull();
544
+ });
545
+
546
+ test("embeddings cleaned up", () => {
547
+ const conv = createConversation({ conversationType: "private" });
548
+ const scopeId = conv.memoryScopeId;
549
+ const now = Date.now();
550
+
551
+ const raw = (
552
+ getDb() as unknown as {
553
+ $client: import("bun:sqlite").Database;
554
+ }
555
+ ).$client;
556
+
557
+ // Insert a memory item with matching scopeId
558
+ raw
559
+ .query(
560
+ `INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
561
+ VALUES ('priv-item-emb', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-priv-emb', ?, ?, ?)`,
562
+ )
563
+ .run(scopeId, now, now);
564
+
565
+ // Insert a corresponding embedding
566
+ raw
567
+ .query(
568
+ `INSERT INTO memory_embeddings (id, target_type, target_id, provider, model, dimensions, created_at, updated_at)
569
+ VALUES ('emb-priv-item', 'item', 'priv-item-emb', 'test', 'test', 384, ?, ?)`,
570
+ )
571
+ .run(now, now);
572
+
573
+ deleteConversation(conv.id);
574
+
575
+ // Both item and embedding should be deleted
576
+ const itemRow = raw
577
+ .query("SELECT * FROM memory_items WHERE id = 'priv-item-emb'")
578
+ .get();
579
+ expect(itemRow).toBeNull();
580
+
581
+ const embeddingRow = raw
582
+ .query("SELECT * FROM memory_embeddings WHERE id = 'emb-priv-item'")
583
+ .get();
584
+ expect(embeddingRow).toBeNull();
585
+ });
586
+
587
+ test("conversationStarters cleaned up", () => {
588
+ const conv = createConversation({ conversationType: "private" });
589
+ const scopeId = conv.memoryScopeId;
590
+ const now = Date.now();
591
+
592
+ const raw = (
593
+ getDb() as unknown as {
594
+ $client: import("bun:sqlite").Database;
595
+ }
596
+ ).$client;
597
+
598
+ // Insert a conversation_starters row with the private scopeId
599
+ raw
600
+ .query(
601
+ `INSERT INTO conversation_starters (id, label, prompt, generation_batch, scope_id, card_type, created_at)
602
+ VALUES ('starter-1', 'Test starter', 'Tell me about tests', 1, ?, 'chip', ?)`,
603
+ )
604
+ .run(scopeId, now);
605
+
606
+ // Also insert a default-scope starter that should NOT be deleted
607
+ raw
608
+ .query(
609
+ `INSERT INTO conversation_starters (id, label, prompt, generation_batch, scope_id, card_type, created_at)
610
+ VALUES ('starter-default', 'Default starter', 'Hello', 1, 'default', 'chip', ?)`,
611
+ )
612
+ .run(now);
613
+
614
+ deleteConversation(conv.id);
615
+
616
+ // Private-scope starter should be gone
617
+ const starterRow = raw
618
+ .query("SELECT * FROM conversation_starters WHERE id = 'starter-1'")
619
+ .get();
620
+ expect(starterRow).toBeNull();
621
+
622
+ // Default-scope starter should still exist
623
+ const defaultStarterRow = raw
624
+ .query("SELECT * FROM conversation_starters WHERE id = 'starter-default'")
625
+ .get();
626
+ expect(defaultStarterRow).not.toBeNull();
627
+ });
628
+
629
+ test("no duplicate IDs", async () => {
630
+ const conv = createConversation({ conversationType: "private" });
631
+ const scopeId = conv.memoryScopeId;
632
+ const msg = await addMessage(conv.id, "user", "hello");
633
+ const now = Date.now();
634
+
635
+ const raw = (
636
+ getDb() as unknown as {
637
+ $client: import("bun:sqlite").Database;
638
+ }
639
+ ).$client;
640
+
641
+ // Insert a memory item with the private scopeId AND a source linking to the message
642
+ raw
643
+ .query(
644
+ `INSERT INTO memory_items (id, status, kind, subject, statement, confidence, fingerprint, scope_id, first_seen_at, last_seen_at)
645
+ VALUES ('priv-item-dup', 'active', 'fact', 'test', 'test fact', 0.8, 'fp-priv-dup', ?, ?, ?)`,
646
+ )
647
+ .run(scopeId, now, now);
648
+
649
+ raw
650
+ .query(
651
+ `INSERT INTO memory_item_sources (memory_item_id, message_id, created_at) VALUES ('priv-item-dup', ?, ?)`,
652
+ )
653
+ .run(msg.id, now);
654
+
655
+ const result = deleteConversation(conv.id);
656
+
657
+ // The item ID should appear exactly once in orphanedItemIds (caught by
658
+ // source-based cleanup, not double-counted by scope sweep).
659
+ const count = result.orphanedItemIds.filter(
660
+ (id) => id === "priv-item-dup",
661
+ ).length;
662
+ expect(count).toBe(1);
663
+ });
664
+ });