@vellumai/assistant 0.5.2 → 0.5.3

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 (108) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/skills.md +100 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  5. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  6. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  7. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  8. package/src/__tests__/conversation-wipe.test.ts +226 -0
  9. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  10. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  11. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  12. package/src/__tests__/inline-command-runner.test.ts +311 -0
  13. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  14. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  16. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  17. package/src/__tests__/memory-brief-time.test.ts +285 -0
  18. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  19. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  20. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  21. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  22. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  23. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  24. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  25. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  26. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  27. package/src/__tests__/memory-reducer-types.test.ts +699 -0
  28. package/src/__tests__/memory-reducer.test.ts +698 -0
  29. package/src/__tests__/memory-regressions.test.ts +6 -4
  30. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  31. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  32. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  33. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  34. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  35. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  36. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  37. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  38. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  39. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  40. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  41. package/src/config/feature-flag-registry.json +16 -0
  42. package/src/config/loader.ts +1 -0
  43. package/src/config/raw-config-utils.ts +28 -0
  44. package/src/config/schema.ts +12 -0
  45. package/src/config/schemas/memory-simplified.ts +101 -0
  46. package/src/config/schemas/memory.ts +4 -0
  47. package/src/config/skills.ts +50 -4
  48. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  49. package/src/daemon/conversation-agent-loop.ts +71 -1
  50. package/src/daemon/conversation-lifecycle.ts +11 -1
  51. package/src/daemon/conversation-runtime-assembly.ts +2 -1
  52. package/src/daemon/conversation-surfaces.ts +31 -8
  53. package/src/daemon/conversation.ts +40 -23
  54. package/src/daemon/handlers/config-embeddings.ts +10 -2
  55. package/src/daemon/handlers/config-model.ts +0 -9
  56. package/src/daemon/handlers/identity.ts +12 -1
  57. package/src/daemon/lifecycle.ts +9 -1
  58. package/src/daemon/message-types/conversations.ts +0 -1
  59. package/src/daemon/server.ts +1 -1
  60. package/src/followups/followup-store.ts +47 -1
  61. package/src/memory/archive-store.ts +400 -0
  62. package/src/memory/brief-formatting.ts +33 -0
  63. package/src/memory/brief-open-loops.ts +266 -0
  64. package/src/memory/brief-time.ts +161 -0
  65. package/src/memory/brief.ts +75 -0
  66. package/src/memory/conversation-crud.ts +245 -101
  67. package/src/memory/db-init.ts +12 -0
  68. package/src/memory/indexer.ts +106 -15
  69. package/src/memory/job-handlers/embedding.test.ts +1 -0
  70. package/src/memory/job-handlers/embedding.ts +83 -0
  71. package/src/memory/job-utils.ts +1 -1
  72. package/src/memory/jobs-store.ts +6 -0
  73. package/src/memory/jobs-worker.ts +12 -0
  74. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  75. package/src/memory/migrations/186-memory-archive.ts +109 -0
  76. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  77. package/src/memory/migrations/index.ts +3 -0
  78. package/src/memory/qdrant-client.ts +23 -4
  79. package/src/memory/reducer-store.ts +271 -0
  80. package/src/memory/reducer-types.ts +99 -0
  81. package/src/memory/reducer.ts +453 -0
  82. package/src/memory/schema/conversations.ts +3 -0
  83. package/src/memory/schema/index.ts +2 -0
  84. package/src/memory/schema/memory-archive.ts +121 -0
  85. package/src/memory/schema/memory-brief.ts +55 -0
  86. package/src/memory/search/semantic.ts +17 -4
  87. package/src/oauth/oauth-store.ts +3 -1
  88. package/src/permissions/checker.ts +89 -6
  89. package/src/permissions/defaults.ts +14 -0
  90. package/src/runtime/routes/conversation-management-routes.ts +6 -0
  91. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  92. package/src/runtime/routes/conversation-routes.ts +52 -5
  93. package/src/runtime/routes/identity-routes.ts +2 -35
  94. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  95. package/src/runtime/routes/memory-item-routes.ts +90 -5
  96. package/src/runtime/routes/secret-routes.ts +2 -0
  97. package/src/runtime/routes/surface-action-routes.ts +68 -1
  98. package/src/schedule/schedule-store.ts +21 -0
  99. package/src/skills/inline-command-expansions.ts +204 -0
  100. package/src/skills/inline-command-render.ts +127 -0
  101. package/src/skills/inline-command-runner.ts +242 -0
  102. package/src/skills/transitive-version-hash.ts +88 -0
  103. package/src/tasks/task-store.ts +43 -1
  104. package/src/tools/permission-checker.ts +8 -1
  105. package/src/tools/skills/load.ts +140 -6
  106. package/src/util/platform.ts +18 -0
  107. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  108. package/src/workspace/migrations/registry.ts +1 -1
@@ -45,6 +45,7 @@ import { enqueueMemoryJob } from "./jobs-store.js";
45
45
  import {
46
46
  channelInboundEvents,
47
47
  conversations,
48
+ conversationStarters,
48
49
  llmRequestLogs,
49
50
  memoryEmbeddings,
50
51
  memoryItems,
@@ -171,6 +172,9 @@ export interface ConversationRow {
171
172
  forkParentMessageId: string | null;
172
173
  isAutoTitle: number;
173
174
  scheduleJobId: string | null;
175
+ memoryReducedThroughMessageId: string | null;
176
+ memoryDirtyTailSinceMessageId: string | null;
177
+ memoryLastReducedAt: number | null;
174
178
  }
175
179
 
176
180
  export const parseConversation = createRowMapper<
@@ -196,6 +200,9 @@ export const parseConversation = createRowMapper<
196
200
  forkParentMessageId: "forkParentMessageId",
197
201
  isAutoTitle: "isAutoTitle",
198
202
  scheduleJobId: "scheduleJobId",
203
+ memoryReducedThroughMessageId: "memoryReducedThroughMessageId",
204
+ memoryDirtyTailSinceMessageId: "memoryDirtyTailSinceMessageId",
205
+ memoryLastReducedAt: "memoryLastReducedAt",
199
206
  });
200
207
 
201
208
  export interface MessageRow {
@@ -375,127 +382,153 @@ export function forkConversation(params: {
375
382
  : ([] as MessageRow[]);
376
383
  const forkParentMessageId = messagesToCopy.at(-1)?.id ?? null;
377
384
  const forkTitle = `${sourceConversation.title ?? "Untitled"} (Fork)`;
378
- const forkedConversation = createConversation({
379
- title: forkTitle,
380
- conversationType: "standard",
381
- });
382
385
 
383
- db.update(conversations)
384
- .set({
385
- forkParentConversationId: sourceConversation.id,
386
- forkParentMessageId,
387
- contextSummary: preserveSourceCompactionState
388
- ? sourceConversation.contextSummary
389
- : null,
390
- contextCompactedMessageCount: preserveSourceCompactionState
391
- ? sourceConversation.contextCompactedMessageCount
392
- : 0,
393
- contextCompactedAt: preserveSourceCompactionState
394
- ? sourceConversation.contextCompactedAt
395
- : null,
396
- })
397
- .where(eq(conversations.id, forkedConversation.id))
398
- .run();
386
+ // Collect disk-sync work to run after the transaction commits.
387
+ const diskSyncQueue: Array<{
388
+ conversationId: string;
389
+ messageId: string;
390
+ createdAt: number;
391
+ }> = [];
392
+
393
+ // Wrap all DB mutations in a single transaction so a mid-flight failure
394
+ // rolls back cleanly instead of leaving a partial fork. Helper functions
395
+ // (linkAttachmentToMessage, relinkAttachments, seedForkedConversationAttention)
396
+ // use the same underlying bun:sqlite connection, so their writes participate
397
+ // in this transaction automatically.
398
+ const forkedConversation = db.transaction(() => {
399
+ const fc = createConversation({
400
+ title: forkTitle,
401
+ conversationType: "standard",
402
+ });
399
403
 
400
- const forkedMessageIds = new Map<string, string>();
401
- let latestForkedAssistant: { messageId: string; messageAt: number } | null =
402
- null;
403
-
404
- for (const message of messagesToCopy) {
405
- const forkedMessageId = uuid();
406
- db.insert(messages)
407
- .values({
408
- id: forkedMessageId,
409
- conversationId: forkedConversation.id,
410
- role: message.role,
411
- content: message.content,
412
- createdAt: message.createdAt,
413
- metadata: cloneForkMessageMetadata(message.metadata, message.id),
404
+ db.update(conversations)
405
+ .set({
406
+ forkParentConversationId: sourceConversation.id,
407
+ forkParentMessageId,
408
+ contextSummary: preserveSourceCompactionState
409
+ ? sourceConversation.contextSummary
410
+ : null,
411
+ contextCompactedMessageCount: preserveSourceCompactionState
412
+ ? sourceConversation.contextCompactedMessageCount
413
+ : 0,
414
+ contextCompactedAt: preserveSourceCompactionState
415
+ ? sourceConversation.contextCompactedAt
416
+ : null,
414
417
  })
418
+ .where(eq(conversations.id, fc.id))
415
419
  .run();
416
- forkedMessageIds.set(message.id, forkedMessageId);
417
-
418
- if (message.role === "assistant") {
419
- latestForkedAssistant = {
420
- messageId: forkedMessageId,
421
- messageAt: message.createdAt,
422
- };
423
- }
424
- }
425
420
 
426
- const attachmentIdMap = new Map<string, string>();
427
- for (const message of messagesToCopy) {
428
- const forkedMessageId = forkedMessageIds.get(message.id);
429
- if (!forkedMessageId) continue;
421
+ const forkedMessageIds = new Map<string, string>();
422
+ let latestForkedAssistant: {
423
+ messageId: string;
424
+ messageAt: number;
425
+ } | null = null;
430
426
 
431
- const attachmentLinks = db
432
- .select({
433
- attachmentId: messageAttachments.attachmentId,
434
- position: messageAttachments.position,
435
- })
436
- .from(messageAttachments)
437
- .where(eq(messageAttachments.messageId, message.id))
438
- .orderBy(messageAttachments.position)
439
- .all();
440
- const uncachedAttachmentLinks = attachmentLinks.filter(
441
- (link) => !attachmentIdMap.has(link.attachmentId),
442
- );
443
- const stagingMessageId = uncachedAttachmentLinks.length > 0 ? uuid() : null;
444
-
445
- if (stagingMessageId) {
427
+ for (const message of messagesToCopy) {
428
+ const forkedMessageId = uuid();
446
429
  db.insert(messages)
447
430
  .values({
448
- id: stagingMessageId,
449
- conversationId: forkedConversation.id,
431
+ id: forkedMessageId,
432
+ conversationId: fc.id,
450
433
  role: message.role,
451
- content: "",
434
+ content: message.content,
452
435
  createdAt: message.createdAt,
453
- metadata: null,
436
+ metadata: cloneForkMessageMetadata(message.metadata, message.id),
454
437
  })
455
438
  .run();
439
+ forkedMessageIds.set(message.id, forkedMessageId);
440
+
441
+ if (message.role === "assistant") {
442
+ latestForkedAssistant = {
443
+ messageId: forkedMessageId,
444
+ messageAt: message.createdAt,
445
+ };
446
+ }
456
447
  }
457
448
 
458
- for (const link of attachmentLinks) {
459
- const cachedAttachmentId = attachmentIdMap.get(link.attachmentId);
460
- if (cachedAttachmentId) {
461
- db.insert(messageAttachments)
449
+ const attachmentIdMap = new Map<string, string>();
450
+ for (const message of messagesToCopy) {
451
+ const forkedMessageId = forkedMessageIds.get(message.id);
452
+ if (!forkedMessageId) continue;
453
+
454
+ const attachmentLinks = db
455
+ .select({
456
+ attachmentId: messageAttachments.attachmentId,
457
+ position: messageAttachments.position,
458
+ })
459
+ .from(messageAttachments)
460
+ .where(eq(messageAttachments.messageId, message.id))
461
+ .orderBy(messageAttachments.position)
462
+ .all();
463
+ const uncachedAttachmentLinks = attachmentLinks.filter(
464
+ (link) => !attachmentIdMap.has(link.attachmentId),
465
+ );
466
+ const stagingMessageId =
467
+ uncachedAttachmentLinks.length > 0 ? uuid() : null;
468
+
469
+ if (stagingMessageId) {
470
+ db.insert(messages)
462
471
  .values({
463
- id: uuid(),
464
- messageId: forkedMessageId,
465
- attachmentId: cachedAttachmentId,
466
- position: link.position,
467
- createdAt: Date.now(),
472
+ id: stagingMessageId,
473
+ conversationId: fc.id,
474
+ role: message.role,
475
+ content: "",
476
+ createdAt: message.createdAt,
477
+ metadata: null,
468
478
  })
469
479
  .run();
470
- continue;
471
480
  }
472
481
 
473
- const scopedAttachmentId = linkAttachmentToMessage(
474
- stagingMessageId ?? forkedMessageId,
475
- link.attachmentId,
476
- link.position,
477
- );
478
- attachmentIdMap.set(link.attachmentId, scopedAttachmentId);
479
- }
482
+ for (const link of attachmentLinks) {
483
+ const cachedAttachmentId = attachmentIdMap.get(link.attachmentId);
484
+ if (cachedAttachmentId) {
485
+ db.insert(messageAttachments)
486
+ .values({
487
+ id: uuid(),
488
+ messageId: forkedMessageId,
489
+ attachmentId: cachedAttachmentId,
490
+ position: link.position,
491
+ createdAt: Date.now(),
492
+ })
493
+ .run();
494
+ continue;
495
+ }
496
+
497
+ const scopedAttachmentId = linkAttachmentToMessage(
498
+ stagingMessageId ?? forkedMessageId,
499
+ link.attachmentId,
500
+ link.position,
501
+ );
502
+ attachmentIdMap.set(link.attachmentId, scopedAttachmentId);
503
+ }
504
+
505
+ if (stagingMessageId) {
506
+ relinkAttachments([stagingMessageId], forkedMessageId);
507
+ db.delete(messages).where(eq(messages.id, stagingMessageId)).run();
508
+ }
480
509
 
481
- if (stagingMessageId) {
482
- relinkAttachments([stagingMessageId], forkedMessageId);
483
- db.delete(messages).where(eq(messages.id, stagingMessageId)).run();
510
+ diskSyncQueue.push({
511
+ conversationId: fc.id,
512
+ messageId: forkedMessageId,
513
+ createdAt: fc.createdAt,
514
+ });
484
515
  }
485
516
 
486
- syncMessageToDisk(
487
- forkedConversation.id,
488
- forkedMessageId,
489
- forkedConversation.createdAt,
490
- );
491
- }
517
+ seedForkedConversationAttention({
518
+ conversationId: fc.id,
519
+ latestAssistantMessageId: latestForkedAssistant?.messageId ?? null,
520
+ latestAssistantMessageAt: latestForkedAssistant?.messageAt ?? null,
521
+ });
492
522
 
493
- seedForkedConversationAttention({
494
- conversationId: forkedConversation.id,
495
- latestAssistantMessageId: latestForkedAssistant?.messageId ?? null,
496
- latestAssistantMessageAt: latestForkedAssistant?.messageAt ?? null,
523
+ return fc;
497
524
  });
498
525
 
526
+ // Disk-view sync runs after commit — file I/O is idempotent and
527
+ // conversation deletion cleans up orphaned directories.
528
+ for (const entry of diskSyncQueue) {
529
+ syncMessageToDisk(entry.conversationId, entry.messageId, entry.createdAt);
530
+ }
531
+
499
532
  const persistedFork = getConversation(forkedConversation.id);
500
533
  if (!persistedFork) {
501
534
  throw new Error(
@@ -513,12 +546,18 @@ export function forkConversation(params: {
513
546
  */
514
547
  export function deleteConversation(id: string): DeletedMemoryIds {
515
548
  const db = getDb();
516
- const result: DeletedMemoryIds = { segmentIds: [], orphanedItemIds: [] };
549
+ const result: DeletedMemoryIds = {
550
+ segmentIds: [],
551
+ orphanedItemIds: [],
552
+ deletedSummaryIds: [],
553
+ };
517
554
 
518
555
  // Capture createdAt before the transaction deletes the row — needed to
519
556
  // resolve the conversation's disk-view directory path after deletion.
520
557
  const convBeforeDelete = getConversation(id);
521
558
  const createdAtForDiskCleanup = convBeforeDelete?.createdAt;
559
+ const memoryScopeId = convBeforeDelete?.memoryScopeId;
560
+ const isPrivateScope = memoryScopeId?.startsWith("private:") ?? false;
522
561
 
523
562
  db.transaction((tx) => {
524
563
  // Collect all message IDs for this conversation.
@@ -607,6 +646,65 @@ export function deleteConversation(id: string): DeletedMemoryIds {
607
646
  .run();
608
647
  }
609
648
 
649
+ if (isPrivateScope && memoryScopeId) {
650
+ // Sweep remaining memory items with this private scopeId.
651
+ const scopeItems = tx
652
+ .select({ id: memoryItems.id })
653
+ .from(memoryItems)
654
+ .where(eq(memoryItems.scopeId, memoryScopeId))
655
+ .all();
656
+ const alreadyDeleted = new Set(result.orphanedItemIds);
657
+ const scopeItemIds = scopeItems
658
+ .map((r) => r.id)
659
+ .filter((id) => !alreadyDeleted.has(id));
660
+
661
+ if (scopeItemIds.length > 0) {
662
+ tx.delete(memoryEmbeddings)
663
+ .where(
664
+ and(
665
+ eq(memoryEmbeddings.targetType, "item"),
666
+ inArray(memoryEmbeddings.targetId, scopeItemIds),
667
+ ),
668
+ )
669
+ .run();
670
+ tx.delete(memoryItemSources)
671
+ .where(inArray(memoryItemSources.memoryItemId, scopeItemIds))
672
+ .run();
673
+ tx.delete(memoryItems)
674
+ .where(inArray(memoryItems.id, scopeItemIds))
675
+ .run();
676
+ result.orphanedItemIds.push(...scopeItemIds);
677
+ }
678
+
679
+ // Sweep memory summaries with this private scopeId.
680
+ const scopeSummaries = tx
681
+ .select({ id: memorySummaries.id })
682
+ .from(memorySummaries)
683
+ .where(eq(memorySummaries.scopeId, memoryScopeId))
684
+ .all();
685
+ const scopeSummaryIds = scopeSummaries.map((r) => r.id);
686
+
687
+ if (scopeSummaryIds.length > 0) {
688
+ tx.delete(memoryEmbeddings)
689
+ .where(
690
+ and(
691
+ eq(memoryEmbeddings.targetType, "summary"),
692
+ inArray(memoryEmbeddings.targetId, scopeSummaryIds),
693
+ ),
694
+ )
695
+ .run();
696
+ tx.delete(memorySummaries)
697
+ .where(inArray(memorySummaries.id, scopeSummaryIds))
698
+ .run();
699
+ result.deletedSummaryIds.push(...scopeSummaryIds);
700
+ }
701
+
702
+ // Sweep conversation starters with this private scopeId.
703
+ tx.delete(conversationStarters)
704
+ .where(eq(conversationStarters.scopeId, memoryScopeId))
705
+ .run();
706
+ }
707
+
610
708
  tx.delete(conversations).where(eq(conversations.id, id)).run();
611
709
  });
612
710
 
@@ -799,7 +897,10 @@ export function wipeConversation(id: string): WipeConversationResult {
799
897
  return {
800
898
  ...deletedMemoryIds,
801
899
  unsupersededItemIds,
802
- deletedSummaryIds,
900
+ deletedSummaryIds: [
901
+ ...deletedSummaryIds,
902
+ ...deletedMemoryIds.deletedSummaryIds,
903
+ ],
803
904
  cancelledJobCount,
804
905
  };
805
906
  }
@@ -821,16 +922,25 @@ export function purgePrivateConversations(): {
821
922
  .all();
822
923
 
823
924
  if (privateConvs.length === 0) {
824
- return { count: 0, deletedMemory: { segmentIds: [], orphanedItemIds: [] } };
925
+ return {
926
+ count: 0,
927
+ deletedMemory: {
928
+ segmentIds: [],
929
+ orphanedItemIds: [],
930
+ deletedSummaryIds: [],
931
+ },
932
+ };
825
933
  }
826
934
 
827
935
  const allSegmentIds: string[] = [];
828
936
  const allOrphanedItemIds: string[] = [];
937
+ const allDeletedSummaryIds: string[] = [];
829
938
 
830
939
  for (const conv of privateConvs) {
831
940
  const deleted = deleteConversation(conv.id);
832
941
  allSegmentIds.push(...deleted.segmentIds);
833
942
  allOrphanedItemIds.push(...deleted.orphanedItemIds);
943
+ allDeletedSummaryIds.push(...deleted.deletedSummaryIds);
834
944
  }
835
945
 
836
946
  return {
@@ -838,6 +948,7 @@ export function purgePrivateConversations(): {
838
948
  deletedMemory: {
839
949
  segmentIds: allSegmentIds,
840
950
  orphanedItemIds: allOrphanedItemIds,
951
+ deletedSummaryIds: allDeletedSummaryIds,
841
952
  },
842
953
  };
843
954
  }
@@ -920,6 +1031,13 @@ export async function addMessage(
920
1031
  throw err;
921
1032
  }
922
1033
  }
1034
+
1035
+ // Mark the conversation dirty for delayed memory reduction. This runs
1036
+ // after the insert transaction succeeds so the reducer knows which
1037
+ // conversations have unprocessed messages. The helper preserves the
1038
+ // earliest unreduced boundary (no-op when already dirty).
1039
+ markConversationMemoryDirty(conversationId, messageId);
1040
+
923
1041
  const message = {
924
1042
  id: messageId,
925
1043
  conversationId,
@@ -1214,11 +1332,11 @@ export function deleteLastExchange(conversationId: string): number {
1214
1332
  export interface DeletedMemoryIds {
1215
1333
  segmentIds: string[];
1216
1334
  orphanedItemIds: string[];
1335
+ deletedSummaryIds: string[];
1217
1336
  }
1218
1337
 
1219
1338
  export interface WipeConversationResult extends DeletedMemoryIds {
1220
1339
  unsupersededItemIds: string[];
1221
- deletedSummaryIds: string[];
1222
1340
  cancelledJobCount: number;
1223
1341
  }
1224
1342
 
@@ -1284,7 +1402,11 @@ export function relinkAttachments(
1284
1402
  */
1285
1403
  export function deleteMessageById(messageId: string): DeletedMemoryIds {
1286
1404
  const db = getDb();
1287
- const result: DeletedMemoryIds = { segmentIds: [], orphanedItemIds: [] };
1405
+ const result: DeletedMemoryIds = {
1406
+ segmentIds: [],
1407
+ orphanedItemIds: [],
1408
+ deletedSummaryIds: [],
1409
+ };
1288
1410
 
1289
1411
  // Collect attachment IDs linked to this message before cascade-delete
1290
1412
  // so we can scope orphan cleanup to only those candidates.
@@ -1372,6 +1494,28 @@ export function deleteMessageById(messageId: string): DeletedMemoryIds {
1372
1494
  return result;
1373
1495
  }
1374
1496
 
1497
+ /**
1498
+ * Mark a conversation as having unreduced messages starting from the given
1499
+ * message. Sets `memoryDirtyTailSinceMessageId` only when it is currently
1500
+ * null so the earliest unreduced boundary is preserved across multiple
1501
+ * messages — later messages must not clobber the original dirty marker.
1502
+ */
1503
+ export function markConversationMemoryDirty(
1504
+ conversationId: string,
1505
+ messageId: string,
1506
+ ): void {
1507
+ const db = getDb();
1508
+ db.update(conversations)
1509
+ .set({ memoryDirtyTailSinceMessageId: messageId })
1510
+ .where(
1511
+ and(
1512
+ eq(conversations.id, conversationId),
1513
+ isNull(conversations.memoryDirtyTailSinceMessageId),
1514
+ ),
1515
+ )
1516
+ .run();
1517
+ }
1518
+
1375
1519
  export function setConversationOriginChannelIfUnset(
1376
1520
  conversationId: string,
1377
1521
  channel: ChannelId,
@@ -82,7 +82,10 @@ import {
82
82
  migrateInviteContactId,
83
83
  migrateLlmRequestLogMessageId,
84
84
  migrateLlmRequestLogProvider,
85
+ migrateMemoryArchiveTables,
86
+ migrateMemoryBriefState,
85
87
  migrateMemoryItemSupersession,
88
+ migrateMemoryReducerCheckpoints,
86
89
  migrateMessagesFtsBackfill,
87
90
  migrateNormalizePhoneIdentities,
88
91
  migrateNotificationDeliveryThreadDecision,
@@ -484,6 +487,15 @@ export function initializeDb(): void {
484
487
  // 84. Add nullable conversation fork lineage columns and parent lookup index
485
488
  migrateConversationForkLineage(database);
486
489
 
490
+ // 85. Memory brief state tables (time_contexts, open_loops) for simplified memory system
491
+ migrateMemoryBriefState(database);
492
+
493
+ // 86. Memory archive tables (observations, chunks, episodes) for simplified memory v1
494
+ migrateMemoryArchiveTables(database);
495
+
496
+ // 87. Add memory reducer checkpoint columns to conversations
497
+ migrateMemoryReducerCheckpoints(database);
498
+
487
499
  validateMigrationState(database);
488
500
 
489
501
  if (process.env.BUN_TEST === "1") {