@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,229 @@
1
+ /**
2
+ * Job handler for `reduce_conversation_memory`.
3
+ *
4
+ * Ties together the reducer service ({@link runReducer}) and the transactional
5
+ * store ({@link applyReducerResult}) to process unreduced conversation turns
6
+ * as a background job.
7
+ *
8
+ * The handler:
9
+ * 1. Loads the conversation and validates the dirty tail marker.
10
+ * 2. Loads the unreduced message span (messages since the dirty tail).
11
+ * 3. Loads active time contexts and open loops for the conversation's scope.
12
+ * 4. Includes the current `contextSummary` when present (prepended as a
13
+ * synthetic system message so the reducer has compacted context).
14
+ * 5. Calls `runReducer` with the assembled input.
15
+ * 6. Applies the result transactionally via `applyReducerResult`.
16
+ *
17
+ * If the reducer fails or returns the {@link EMPTY_REDUCER_RESULT} sentinel
18
+ * (unparseable output), the checkpoint is NOT advanced — the dirty tail stays
19
+ * in place so the next run retries. A valid-but-empty model response (e.g.
20
+ * `{}`) returns a normal empty result that advances the checkpoint normally.
21
+ */
22
+
23
+ import { and, asc, eq, gte } from "drizzle-orm";
24
+
25
+ import { getLogger } from "../../util/logger.js";
26
+ import { type ConversationRow, getConversation } from "../conversation-crud.js";
27
+ import { getDb } from "../db.js";
28
+ import { asString } from "../job-utils.js";
29
+ import type { MemoryJob } from "../jobs-store.js";
30
+ import { type ReducerPromptInput, runReducer } from "../reducer.js";
31
+ import {
32
+ applyReducerResult,
33
+ getActiveOpenLoops,
34
+ getActiveTimeContexts,
35
+ } from "../reducer-store.js";
36
+ import { EMPTY_REDUCER_RESULT } from "../reducer-types.js";
37
+ import { messages } from "../schema.js";
38
+
39
+ const log = getLogger("reduce-conversation-memory-job");
40
+
41
+ export interface ReduceConversationMemoryPayload {
42
+ conversationId: string;
43
+ }
44
+
45
+ /**
46
+ * Process a `reduce_conversation_memory` job.
47
+ *
48
+ * @throws Re-throws reducer errors so the job worker can classify and retry.
49
+ */
50
+ export async function reduceConversationMemoryJob(
51
+ job: MemoryJob,
52
+ ): Promise<void> {
53
+ const conversationId = asString(job.payload.conversationId);
54
+ if (!conversationId) {
55
+ log.warn({ jobId: job.id }, "Missing conversationId in job payload");
56
+ return;
57
+ }
58
+
59
+ // ── 1. Load conversation and validate dirty tail ────────────────
60
+ const conversation = getConversation(conversationId);
61
+ if (!conversation) {
62
+ log.warn(
63
+ { jobId: job.id, conversationId },
64
+ "Conversation not found, skipping reduction",
65
+ );
66
+ return;
67
+ }
68
+
69
+ const dirtyTailMessageId = conversation.memoryDirtyTailSinceMessageId;
70
+ if (!dirtyTailMessageId) {
71
+ log.debug(
72
+ { jobId: job.id, conversationId },
73
+ "No dirty tail marker — conversation is already fully reduced",
74
+ );
75
+ return;
76
+ }
77
+
78
+ // ── 2. Load unreduced message span ──────────────────────────────
79
+ const unreducedMessages = loadUnreducedMessages(
80
+ conversationId,
81
+ dirtyTailMessageId,
82
+ );
83
+
84
+ if (unreducedMessages.length === 0) {
85
+ log.debug(
86
+ { jobId: job.id, conversationId, dirtyTailMessageId },
87
+ "No messages found from dirty tail — nothing to reduce",
88
+ );
89
+ return;
90
+ }
91
+
92
+ // ── 3. Load active brief-state context ──────────────────────────
93
+ const scopeId = conversation.memoryScopeId;
94
+ const now = Date.now();
95
+
96
+ const existingTimeContexts = getActiveTimeContexts(scopeId, now);
97
+ const existingOpenLoops = getActiveOpenLoops(scopeId);
98
+
99
+ // ── 4. Build reducer input ──────────────────────────────────────
100
+ const newMessages = buildNewMessages(conversation, unreducedMessages);
101
+
102
+ const reducerInput: ReducerPromptInput = {
103
+ conversationId,
104
+ newMessages,
105
+ existingTimeContexts: existingTimeContexts.map((tc) => ({
106
+ id: tc.id,
107
+ summary: tc.summary,
108
+ })),
109
+ existingOpenLoops: existingOpenLoops.map((ol) => ({
110
+ id: ol.id,
111
+ summary: ol.summary,
112
+ status: ol.status,
113
+ })),
114
+ nowMs: now,
115
+ scopeId,
116
+ };
117
+
118
+ // ── 5. Run the reducer ──────────────────────────────────────────
119
+ const result = await runReducer(reducerInput);
120
+
121
+ // If the reducer returns the empty sentinel, skip applying — the dirty
122
+ // tail stays in place so a future run can retry.
123
+ if (result === EMPTY_REDUCER_RESULT) {
124
+ log.warn(
125
+ { jobId: job.id, conversationId },
126
+ "Reducer returned empty result — not advancing checkpoint",
127
+ );
128
+ return;
129
+ }
130
+
131
+ // ── 6. Apply result transactionally ─────────────────────────────
132
+ const lastMessage = unreducedMessages[unreducedMessages.length - 1];
133
+ applyReducerResult({
134
+ result,
135
+ conversationId,
136
+ scopeId,
137
+ reducedThroughMessageId: lastMessage.id,
138
+ now,
139
+ });
140
+
141
+ log.info(
142
+ {
143
+ jobId: job.id,
144
+ conversationId,
145
+ reducedThroughMessageId: lastMessage.id,
146
+ messageCount: unreducedMessages.length,
147
+ timeContextOps: result.timeContexts.length,
148
+ openLoopOps: result.openLoops.length,
149
+ },
150
+ "Conversation memory reduction completed",
151
+ );
152
+ }
153
+
154
+ // ── Internal helpers ────────────────────────────────────────────────
155
+
156
+ interface MessageRow {
157
+ id: string;
158
+ role: string;
159
+ content: string;
160
+ createdAt: number;
161
+ }
162
+
163
+ /**
164
+ * Load messages from `dirtyTailMessageId` onward (inclusive), ordered by
165
+ * createdAt ascending. Uses the message's createdAt as the boundary since
166
+ * message ordering is timestamp-based.
167
+ */
168
+ function loadUnreducedMessages(
169
+ conversationId: string,
170
+ dirtyTailMessageId: string,
171
+ ): MessageRow[] {
172
+ const db = getDb();
173
+
174
+ // First, find the createdAt of the dirty tail message
175
+ const tailMessage = db
176
+ .select({ createdAt: messages.createdAt })
177
+ .from(messages)
178
+ .where(eq(messages.id, dirtyTailMessageId))
179
+ .get();
180
+
181
+ if (!tailMessage) {
182
+ return [];
183
+ }
184
+
185
+ // Load all messages from that timestamp onward
186
+ return db
187
+ .select({
188
+ id: messages.id,
189
+ role: messages.role,
190
+ content: messages.content,
191
+ createdAt: messages.createdAt,
192
+ })
193
+ .from(messages)
194
+ .where(
195
+ and(
196
+ eq(messages.conversationId, conversationId),
197
+ gte(messages.createdAt, tailMessage.createdAt),
198
+ ),
199
+ )
200
+ .orderBy(asc(messages.createdAt))
201
+ .all();
202
+ }
203
+
204
+ /**
205
+ * Build the `newMessages` array for the reducer input.
206
+ *
207
+ * When the conversation has a `contextSummary` (from context window
208
+ * compaction), it is prepended as a synthetic `system` message so the
209
+ * reducer has access to prior compacted context.
210
+ */
211
+ function buildNewMessages(
212
+ conversation: ConversationRow,
213
+ unreducedMessages: MessageRow[],
214
+ ): Array<{ role: string; content: string }> {
215
+ const result: Array<{ role: string; content: string }> = [];
216
+
217
+ if (conversation.contextSummary) {
218
+ result.push({
219
+ role: "system",
220
+ content: `[Prior context summary] ${conversation.contextSummary}`,
221
+ });
222
+ }
223
+
224
+ for (const msg of unreducedMessages) {
225
+ result.push({ role: msg.role, content: msg.content });
226
+ }
227
+
228
+ return result;
229
+ }
@@ -142,7 +142,7 @@ export function truncate(text: string, max: number): string {
142
142
 
143
143
  export async function embedAndUpsert(
144
144
  config: AssistantConfig,
145
- targetType: "segment" | "item" | "summary" | "media",
145
+ targetType: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media",
146
146
  targetId: string,
147
147
  input: EmbeddingInput,
148
148
  extraPayload?: Record<string, unknown>,
@@ -12,6 +12,9 @@ export type MemoryJobType =
12
12
  | "embed_segment"
13
13
  | "embed_item"
14
14
  | "embed_summary"
15
+ | "embed_chunk"
16
+ | "embed_episode"
17
+ | "embed_observation"
15
18
  | "extract_items"
16
19
  | "extract_entities"
17
20
  | "cleanup_stale_superseded_items"
@@ -27,6 +30,8 @@ export type MemoryJobType =
27
30
  | "embed_media"
28
31
  | "embed_attachment"
29
32
  | "generate_conversation_starters"
33
+ | "reduce_conversation_memory"
34
+ | "backfill_simplified_memory"
30
35
  | "generate_capability_cards" // legacy compat — silently dropped by worker (capability cards removed)
31
36
  | "generate_thread_starters"; // legacy compat — silently dropped by worker (renamed to generate_conversation_starters)
32
37
 
@@ -34,6 +39,9 @@ const EMBED_JOB_TYPES: MemoryJobType[] = [
34
39
  "embed_segment",
35
40
  "embed_item",
36
41
  "embed_summary",
42
+ "embed_chunk",
43
+ "embed_episode",
44
+ "embed_observation",
37
45
  "embed_media",
38
46
  "embed_attachment",
39
47
  ];
@@ -3,6 +3,7 @@ import type { AssistantConfig } from "../config/types.js";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { rawRun } from "./db.js";
5
5
  import { backfillJob } from "./job-handlers/backfill.js";
6
+ import { backfillSimplifiedMemoryJob } from "./job-handlers/backfill-simplified-memory.js";
6
7
  import {
7
8
  cleanupStaleSupersededItemsJob,
8
9
  pruneOldConversationsJob,
@@ -11,8 +12,11 @@ import { generateConversationStartersJob } from "./job-handlers/conversation-sta
11
12
  // ── Per-job-type handlers ──────────────────────────────────────────
12
13
  import {
13
14
  embedAttachmentJob,
15
+ embedChunkJob,
16
+ embedEpisodeJob,
14
17
  embedItemJob,
15
18
  embedMediaJob,
19
+ embedObservationJob,
16
20
  embedSegmentJob,
17
21
  embedSummaryJob,
18
22
  } from "./job-handlers/embedding.js";
@@ -22,6 +26,7 @@ import {
22
26
  rebuildIndexJob,
23
27
  } from "./job-handlers/index-maintenance.js";
24
28
  import { mediaProcessingJob } from "./job-handlers/media-processing.js";
29
+ import { reduceConversationMemoryJob } from "./job-handlers/reduce-conversation-memory.js";
25
30
  import { buildConversationSummaryJob } from "./job-handlers/summarization.js";
26
31
  import {
27
32
  BackendUnavailableError,
@@ -267,6 +272,15 @@ async function processJob(
267
272
  case "embed_summary":
268
273
  await embedSummaryJob(job, config);
269
274
  return;
275
+ case "embed_chunk":
276
+ await embedChunkJob(job, config);
277
+ return;
278
+ case "embed_episode":
279
+ await embedEpisodeJob(job, config);
280
+ return;
281
+ case "embed_observation":
282
+ await embedObservationJob(job, config);
283
+ return;
270
284
  case "extract_items":
271
285
  await extractItemsJob(job);
272
286
  return;
@@ -307,6 +321,12 @@ async function processJob(
307
321
  case "embed_attachment":
308
322
  await embedAttachmentJob(job, config);
309
323
  return;
324
+ case "reduce_conversation_memory":
325
+ await reduceConversationMemoryJob(job);
326
+ return;
327
+ case "backfill_simplified_memory":
328
+ await backfillSimplifiedMemoryJob(job);
329
+ return;
310
330
  case "generate_conversation_starters":
311
331
  await generateConversationStartersJob(job);
312
332
  return;
@@ -79,9 +79,27 @@ export function migrateNormalizePhoneIdentities(database: DrizzleDb): void {
79
79
  .get(table);
80
80
  const orderBy = hasUpdatedAt ? "updated_at DESC, rowid DESC" : "rowid DESC";
81
81
 
82
- const selectColumns = [`id`, column];
82
+ // Filter uniqueKeyScope to only include peer columns that actually exist in the table.
83
+ // If a peer column is missing, its unique index can't exist either, so no collision risk.
84
+ let effectiveScope = uniqueKeyScope;
83
85
  if (uniqueKeyScope) {
84
- for (const peer of uniqueKeyScope.peerColumns) {
86
+ const validPeers = uniqueKeyScope.peerColumns.filter(
87
+ (col) =>
88
+ !!raw
89
+ .query(`SELECT 1 FROM pragma_table_info(?) WHERE name = ?`)
90
+ .get(table, col),
91
+ );
92
+ effectiveScope =
93
+ validPeers.length === uniqueKeyScope.peerColumns.length
94
+ ? uniqueKeyScope
95
+ : validPeers.length > 0
96
+ ? { ...uniqueKeyScope, peerColumns: validPeers }
97
+ : undefined;
98
+ }
99
+
100
+ const selectColumns = [`id`, column];
101
+ if (effectiveScope) {
102
+ for (const peer of effectiveScope.peerColumns) {
85
103
  if (!selectColumns.includes(peer)) selectColumns.push(peer);
86
104
  }
87
105
  }
@@ -104,14 +122,14 @@ export function migrateNormalizePhoneIdentities(database: DrizzleDb): void {
104
122
  if (!original) continue;
105
123
  const normalized = normalizePhoneNumber(original);
106
124
  if (normalized && normalized !== original) {
107
- if (uniqueKeyScope) {
125
+ if (effectiveScope) {
108
126
  // Check if another row already has the normalized value within the same unique-key scope
109
- const peerConditions = uniqueKeyScope.peerColumns
127
+ const peerConditions = effectiveScope.peerColumns
110
128
  .map((col) => `${col} = ?`)
111
129
  .join(" AND ");
112
- const peerValues = uniqueKeyScope.peerColumns.map((col) => row[col]);
113
- const whereExtra = uniqueKeyScope.whereClause
114
- ? ` AND (${uniqueKeyScope.whereClause})`
130
+ const peerValues = effectiveScope.peerColumns.map((col) => row[col]);
131
+ const whereExtra = effectiveScope.whereClause
132
+ ? ` AND (${effectiveScope.whereClause})`
115
133
  : "";
116
134
  const existing = raw
117
135
  .query(
@@ -154,9 +172,26 @@ export function migrateNormalizePhoneIdentities(database: DrizzleDb): void {
154
172
  .get(table);
155
173
  const orderBy = hasUpdatedAt ? "updated_at DESC, rowid DESC" : "rowid DESC";
156
174
 
157
- const selectColumns = [`id`, column];
175
+ // Filter uniqueKeyScope to only include peer columns that actually exist in the table.
176
+ let effectiveScope = uniqueKeyScope;
158
177
  if (uniqueKeyScope) {
159
- for (const peer of uniqueKeyScope.peerColumns) {
178
+ const validPeers = uniqueKeyScope.peerColumns.filter(
179
+ (col) =>
180
+ !!raw
181
+ .query(`SELECT 1 FROM pragma_table_info(?) WHERE name = ?`)
182
+ .get(table, col),
183
+ );
184
+ effectiveScope =
185
+ validPeers.length === uniqueKeyScope.peerColumns.length
186
+ ? uniqueKeyScope
187
+ : validPeers.length > 0
188
+ ? { ...uniqueKeyScope, peerColumns: validPeers }
189
+ : undefined;
190
+ }
191
+
192
+ const selectColumns = [`id`, column];
193
+ if (effectiveScope) {
194
+ for (const peer of effectiveScope.peerColumns) {
160
195
  if (!selectColumns.includes(peer)) selectColumns.push(peer);
161
196
  }
162
197
  }
@@ -179,13 +214,13 @@ export function migrateNormalizePhoneIdentities(database: DrizzleDb): void {
179
214
  if (!original) continue;
180
215
  const normalized = normalizePhoneNumber(original);
181
216
  if (normalized && normalized !== original) {
182
- if (uniqueKeyScope) {
183
- const peerConditions = uniqueKeyScope.peerColumns
217
+ if (effectiveScope) {
218
+ const peerConditions = effectiveScope.peerColumns
184
219
  .map((col) => `${col} = ?`)
185
220
  .join(" AND ");
186
- const peerValues = uniqueKeyScope.peerColumns.map((col) => row[col]);
187
- const whereExtra = uniqueKeyScope.whereClause
188
- ? ` AND (${uniqueKeyScope.whereClause})`
221
+ const peerValues = effectiveScope.peerColumns.map((col) => row[col]);
222
+ const whereExtra = effectiveScope.whereClause
223
+ ? ` AND (${effectiveScope.whereClause})`
189
224
  : "";
190
225
  const existing = raw
191
226
  .query(
@@ -1,4 +1,4 @@
1
- import type { DrizzleDb } from "../db-connection.js";
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
2
  import { withCrashRecovery } from "./validate-migration-state.js";
3
3
 
4
4
  /**
@@ -7,6 +7,14 @@ import { withCrashRecovery } from "./validate-migration-state.js";
7
7
  * existing data, so it stays at 0 and accumulates going forward.
8
8
  */
9
9
  export function migrateBackfillContactInteractionStats(db: DrizzleDb): void {
10
+ const raw = getSqliteFrom(db);
11
+ const colExists = raw
12
+ .query(
13
+ `SELECT 1 FROM pragma_table_info('contacts') WHERE name = 'last_interaction'`,
14
+ )
15
+ .get();
16
+ if (!colExists) return;
17
+
10
18
  withCrashRecovery(db, "backfill_contact_interaction_stats", () => {
11
19
  db.run(/*sql*/ `
12
20
  UPDATE contacts
@@ -18,6 +18,14 @@ export function migrateRenameVerificationTable(database: DrizzleDb): void {
18
18
  .get();
19
19
  if (!oldTableExists) return;
20
20
 
21
+ // If the new table already exists, the rename would collide — skip
22
+ const newTableExists = raw
23
+ .query(
24
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'channel_verification_sessions'`,
25
+ )
26
+ .get();
27
+ if (newTableExists) return;
28
+
21
29
  // Rename the physical table
22
30
  raw.exec(
23
31
  /*sql*/ `ALTER TABLE channel_guardian_verification_challenges RENAME TO channel_verification_sessions`,
@@ -15,14 +15,19 @@ export function migrateRenameVerificationSessionIdColumn(
15
15
  () => {
16
16
  const raw = getSqliteFrom(database);
17
17
 
18
- // Check the old column exists before attempting the rename
18
+ // Check the old column exists and the new column doesn't before attempting the rename.
19
+ // Both checks are needed for crash recovery: if the rename succeeded but the checkpoint
20
+ // didn't commit, the old column is gone and the new one already exists.
19
21
  const columns = raw
20
22
  .query(`PRAGMA table_info(call_sessions)`)
21
23
  .all() as Array<{ name: string }>;
22
24
  const hasOldColumn = columns.some(
23
25
  (c) => c.name === "guardian_verification_session_id",
24
26
  );
25
- if (!hasOldColumn) return;
27
+ const hasNewColumn = columns.some(
28
+ (c) => c.name === "verification_session_id",
29
+ );
30
+ if (!hasOldColumn || hasNewColumn) return;
26
31
 
27
32
  raw.exec(
28
33
  /*sql*/ `ALTER TABLE call_sessions RENAME COLUMN guardian_verification_session_id TO verification_session_id`,
@@ -21,6 +21,14 @@ export function migrateRenameThreadStartersTable(database: DrizzleDb): void {
21
21
  .get();
22
22
  if (!oldTableExists) return;
23
23
 
24
+ // If the new table already exists (crash recovery), skip the rename
25
+ const newTableExists = raw
26
+ .query(
27
+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'conversation_starters'`,
28
+ )
29
+ .get();
30
+ if (newTableExists) return;
31
+
24
32
  // Rename the physical table
25
33
  raw.exec(
26
34
  /*sql*/ `ALTER TABLE thread_starters RENAME TO conversation_starters`,
@@ -0,0 +1,52 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ /**
5
+ * Create the memory brief state tables: time_contexts and open_loops.
6
+ *
7
+ * Both tables use CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS,
8
+ * making this migration inherently idempotent — safe to re-run on every startup
9
+ * without a checkpoint guard.
10
+ */
11
+ export function migrateMemoryBriefState(database: DrizzleDb): void {
12
+ const raw = getSqliteFrom(database);
13
+
14
+ // -- time_contexts: bounded temporal windows for the brief --
15
+ raw.exec(/*sql*/ `
16
+ CREATE TABLE IF NOT EXISTS time_contexts (
17
+ id TEXT PRIMARY KEY,
18
+ scope_id TEXT NOT NULL,
19
+ summary TEXT NOT NULL,
20
+ source TEXT NOT NULL,
21
+ active_from INTEGER NOT NULL,
22
+ active_until INTEGER NOT NULL,
23
+ created_at INTEGER NOT NULL,
24
+ updated_at INTEGER NOT NULL
25
+ )
26
+ `);
27
+
28
+ raw.exec(/*sql*/ `
29
+ CREATE INDEX IF NOT EXISTS idx_time_contexts_scope_active_until
30
+ ON time_contexts (scope_id, active_until)
31
+ `);
32
+
33
+ // -- open_loops: unresolved items the brief should surface --
34
+ raw.exec(/*sql*/ `
35
+ CREATE TABLE IF NOT EXISTS open_loops (
36
+ id TEXT PRIMARY KEY,
37
+ scope_id TEXT NOT NULL,
38
+ summary TEXT NOT NULL,
39
+ status TEXT NOT NULL DEFAULT 'open',
40
+ source TEXT NOT NULL,
41
+ due_at INTEGER,
42
+ surfaced_at INTEGER,
43
+ created_at INTEGER NOT NULL,
44
+ updated_at INTEGER NOT NULL
45
+ )
46
+ `);
47
+
48
+ raw.exec(/*sql*/ `
49
+ CREATE INDEX IF NOT EXISTS idx_open_loops_scope_status_due
50
+ ON open_loops (scope_id, status, due_at)
51
+ `);
52
+ }
@@ -0,0 +1,109 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ /**
5
+ * Create the memory archive tables (memory_observations, memory_chunks,
6
+ * memory_episodes) with prefetch indexes on scopeId, conversationId, and
7
+ * createdAt.
8
+ *
9
+ * All statements use IF NOT EXISTS / IF NOT EXISTS guards so the migration
10
+ * is safe to re-run on every startup.
11
+ */
12
+ export function migrateMemoryArchiveTables(database: DrizzleDb): void {
13
+ const raw = getSqliteFrom(database);
14
+
15
+ // -- memory_observations --------------------------------------------------
16
+ raw.exec(/*sql*/ `
17
+ CREATE TABLE IF NOT EXISTS memory_observations (
18
+ id TEXT PRIMARY KEY,
19
+ scope_id TEXT NOT NULL DEFAULT 'default',
20
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
21
+ message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
22
+ role TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ modality TEXT NOT NULL DEFAULT 'text',
25
+ source TEXT,
26
+ created_at INTEGER NOT NULL
27
+ )
28
+ `);
29
+
30
+ raw.exec(/*sql*/ `
31
+ CREATE INDEX IF NOT EXISTS idx_memory_observations_scope_id
32
+ ON memory_observations (scope_id)
33
+ `);
34
+
35
+ raw.exec(/*sql*/ `
36
+ CREATE INDEX IF NOT EXISTS idx_memory_observations_conversation_id
37
+ ON memory_observations (conversation_id)
38
+ `);
39
+
40
+ raw.exec(/*sql*/ `
41
+ CREATE INDEX IF NOT EXISTS idx_memory_observations_created_at
42
+ ON memory_observations (created_at)
43
+ `);
44
+
45
+ // -- memory_chunks --------------------------------------------------------
46
+ raw.exec(/*sql*/ `
47
+ CREATE TABLE IF NOT EXISTS memory_chunks (
48
+ id TEXT PRIMARY KEY,
49
+ scope_id TEXT NOT NULL DEFAULT 'default',
50
+ observation_id TEXT NOT NULL REFERENCES memory_observations(id) ON DELETE CASCADE,
51
+ content TEXT NOT NULL,
52
+ token_estimate INTEGER NOT NULL,
53
+ content_hash TEXT NOT NULL,
54
+ created_at INTEGER NOT NULL
55
+ )
56
+ `);
57
+
58
+ raw.exec(/*sql*/ `
59
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_scope_id
60
+ ON memory_chunks (scope_id)
61
+ `);
62
+
63
+ raw.exec(/*sql*/ `
64
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_observation_id
65
+ ON memory_chunks (observation_id)
66
+ `);
67
+
68
+ raw.exec(/*sql*/ `
69
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_chunks_content_hash
70
+ ON memory_chunks (scope_id, content_hash)
71
+ `);
72
+
73
+ raw.exec(/*sql*/ `
74
+ CREATE INDEX IF NOT EXISTS idx_memory_chunks_created_at
75
+ ON memory_chunks (created_at)
76
+ `);
77
+
78
+ // -- memory_episodes ------------------------------------------------------
79
+ raw.exec(/*sql*/ `
80
+ CREATE TABLE IF NOT EXISTS memory_episodes (
81
+ id TEXT PRIMARY KEY,
82
+ scope_id TEXT NOT NULL DEFAULT 'default',
83
+ conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
84
+ title TEXT NOT NULL,
85
+ summary TEXT NOT NULL,
86
+ token_estimate INTEGER NOT NULL,
87
+ source TEXT,
88
+ start_at INTEGER NOT NULL,
89
+ end_at INTEGER NOT NULL,
90
+ created_at INTEGER NOT NULL,
91
+ updated_at INTEGER NOT NULL
92
+ )
93
+ `);
94
+
95
+ raw.exec(/*sql*/ `
96
+ CREATE INDEX IF NOT EXISTS idx_memory_episodes_scope_id
97
+ ON memory_episodes (scope_id)
98
+ `);
99
+
100
+ raw.exec(/*sql*/ `
101
+ CREATE INDEX IF NOT EXISTS idx_memory_episodes_conversation_id
102
+ ON memory_episodes (conversation_id)
103
+ `);
104
+
105
+ raw.exec(/*sql*/ `
106
+ CREATE INDEX IF NOT EXISTS idx_memory_episodes_created_at
107
+ ON memory_episodes (created_at)
108
+ `);
109
+ }
@@ -0,0 +1,19 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ export function migrateMemoryReducerCheckpoints(database: DrizzleDb): void {
5
+ const raw = getSqliteFrom(database);
6
+ const columns = [
7
+ "memory_reduced_through_message_id TEXT",
8
+ "memory_dirty_tail_since_message_id TEXT",
9
+ "memory_last_reduced_at INTEGER",
10
+ ];
11
+
12
+ for (const column of columns) {
13
+ try {
14
+ raw.exec(`ALTER TABLE conversations ADD COLUMN ${column}`);
15
+ } catch {
16
+ // Column already exists — nothing to do.
17
+ }
18
+ }
19
+ }