@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,462 @@
1
+ /**
2
+ * Backfill job handler: migrates legacy memory rows into the simplified memory
3
+ * system without deleting the old tables.
4
+ *
5
+ * Migration mapping:
6
+ * - `memory_segments` -> `memory_chunks` (via `memory_observations`)
7
+ * - `memory_summaries` -> `memory_episodes`
8
+ * - Active/high-confidence `memory_items` -> `memory_observations`,
9
+ * plus `time_contexts` or `open_loops` when the mapping is unambiguous.
10
+ *
11
+ * The handler is idempotent: content-hash deduplication on chunks and
12
+ * checkpoint tracking prevent double-writes on re-runs.
13
+ */
14
+
15
+ import { eq } from "drizzle-orm";
16
+ import { v4 as uuid } from "uuid";
17
+
18
+ import { estimateTextTokens } from "../../context/token-estimator.js";
19
+ import { getLogger } from "../../util/logger.js";
20
+ import {
21
+ computeChunkContentHash,
22
+ insertObservation,
23
+ } from "../archive-store.js";
24
+ import { getMemoryCheckpoint, setMemoryCheckpoint } from "../checkpoints.js";
25
+ import { getDb, rawAll } from "../db.js";
26
+ import type { MemoryJob } from "../jobs-store.js";
27
+ import { enqueueMemoryJob } from "../jobs-store.js";
28
+ import {
29
+ conversations,
30
+ memoryChunks,
31
+ memoryEpisodes,
32
+ memoryObservations,
33
+ openLoops,
34
+ timeContexts,
35
+ } from "../schema.js";
36
+
37
+ const log = getLogger("backfill-simplified-memory");
38
+
39
+ /** Checkpoint keys for tracking backfill progress. */
40
+ const CHECKPOINT_SEGMENTS = "simplified_backfill:segments:last_id";
41
+ const CHECKPOINT_SUMMARIES = "simplified_backfill:summaries:last_id";
42
+ const CHECKPOINT_ITEMS = "simplified_backfill:items:last_id";
43
+ const CHECKPOINT_COMPLETE = "simplified_backfill:complete";
44
+
45
+ /** Batch size for each migration pass. */
46
+ const BATCH_SIZE = 200;
47
+
48
+ // ── Legacy row types ──────────────────────────────────────────────────
49
+
50
+ interface LegacySegment {
51
+ id: string;
52
+ message_id: string;
53
+ conversation_id: string;
54
+ role: string;
55
+ text: string;
56
+ token_estimate: number;
57
+ scope_id: string;
58
+ content_hash: string | null;
59
+ created_at: number;
60
+ }
61
+
62
+ interface LegacySummary {
63
+ id: string;
64
+ scope: string;
65
+ scope_key: string;
66
+ summary: string;
67
+ token_estimate: number;
68
+ scope_id: string;
69
+ start_at: number;
70
+ end_at: number;
71
+ created_at: number;
72
+ }
73
+
74
+ interface LegacyItem {
75
+ id: string;
76
+ kind: string;
77
+ subject: string;
78
+ statement: string;
79
+ status: string;
80
+ confidence: number;
81
+ scope_id: string;
82
+ first_seen_at: number;
83
+ last_seen_at: number;
84
+ valid_from: number | null;
85
+ invalid_at: number | null;
86
+ }
87
+
88
+ // ── Entry point ───────────────────────────────────────────────────────
89
+
90
+ export async function backfillSimplifiedMemoryJob(
91
+ job: MemoryJob,
92
+ ): Promise<void> {
93
+ const force = job.payload.force === true;
94
+
95
+ if (!force) {
96
+ const complete = getMemoryCheckpoint(CHECKPOINT_COMPLETE);
97
+ if (complete === "true") {
98
+ log.debug("Simplified memory backfill already complete, skipping");
99
+ return;
100
+ }
101
+ }
102
+
103
+ if (force) {
104
+ // Reset all checkpoints so the backfill restarts from scratch
105
+ setMemoryCheckpoint(CHECKPOINT_SEGMENTS, "");
106
+ setMemoryCheckpoint(CHECKPOINT_SUMMARIES, "");
107
+ setMemoryCheckpoint(CHECKPOINT_ITEMS, "");
108
+ setMemoryCheckpoint(CHECKPOINT_COMPLETE, "false");
109
+ }
110
+
111
+ let hasMore = false;
112
+
113
+ // ── Phase 1: memory_segments -> memory_observations + memory_chunks
114
+ hasMore = migrateSegments();
115
+ if (hasMore) {
116
+ enqueueMemoryJob("backfill_simplified_memory", {});
117
+ return;
118
+ }
119
+
120
+ // ── Phase 2: memory_summaries -> memory_episodes
121
+ hasMore = migrateSummaries();
122
+ if (hasMore) {
123
+ enqueueMemoryJob("backfill_simplified_memory", {});
124
+ return;
125
+ }
126
+
127
+ // ── Phase 3: active memory_items -> memory_observations (+ brief-state)
128
+ hasMore = migrateItems();
129
+ if (hasMore) {
130
+ enqueueMemoryJob("backfill_simplified_memory", {});
131
+ return;
132
+ }
133
+
134
+ // All phases complete
135
+ setMemoryCheckpoint(CHECKPOINT_COMPLETE, "true");
136
+ log.info("Simplified memory backfill completed");
137
+ }
138
+
139
+ // ── Phase 1: Segments ─────────────────────────────────────────────────
140
+
141
+ function migrateSegments(): boolean {
142
+ const lastId = getMemoryCheckpoint(CHECKPOINT_SEGMENTS) ?? "";
143
+
144
+ const segments = rawAll<LegacySegment>(
145
+ `SELECT id, message_id, conversation_id, role, text, token_estimate,
146
+ scope_id, content_hash, created_at
147
+ FROM memory_segments
148
+ WHERE id > ?
149
+ ORDER BY id ASC
150
+ LIMIT ?`,
151
+ lastId,
152
+ BATCH_SIZE,
153
+ );
154
+
155
+ if (segments.length === 0) return false;
156
+
157
+ for (const seg of segments) {
158
+ try {
159
+ // Insert as an observation — insertObservation handles chunk dedup
160
+ insertObservation({
161
+ conversationId: seg.conversation_id,
162
+ messageId: seg.message_id,
163
+ role: seg.role,
164
+ content: seg.text,
165
+ scopeId: seg.scope_id,
166
+ modality: "text",
167
+ source: "backfill:segment",
168
+ });
169
+ } catch (err) {
170
+ // Log and continue — individual failures should not block the batch
171
+ log.warn(
172
+ { err, segmentId: seg.id },
173
+ "Failed to migrate segment, skipping",
174
+ );
175
+ }
176
+ }
177
+
178
+ const lastSegment = segments[segments.length - 1];
179
+ setMemoryCheckpoint(CHECKPOINT_SEGMENTS, lastSegment.id);
180
+
181
+ log.debug(
182
+ { migrated: segments.length, lastId: lastSegment.id },
183
+ "Migrated segment batch",
184
+ );
185
+
186
+ return segments.length === BATCH_SIZE;
187
+ }
188
+
189
+ // ── Phase 2: Summaries ────────────────────────────────────────────────
190
+
191
+ function migrateSummaries(): boolean {
192
+ const lastId = getMemoryCheckpoint(CHECKPOINT_SUMMARIES) ?? "";
193
+
194
+ const summaries = rawAll<LegacySummary>(
195
+ `SELECT id, scope, scope_key, summary, token_estimate, scope_id,
196
+ start_at, end_at, created_at
197
+ FROM memory_summaries
198
+ WHERE id > ?
199
+ ORDER BY id ASC
200
+ LIMIT ?`,
201
+ lastId,
202
+ BATCH_SIZE,
203
+ );
204
+
205
+ if (summaries.length === 0) return false;
206
+
207
+ const db = getDb();
208
+ const now = Date.now();
209
+
210
+ for (const sum of summaries) {
211
+ try {
212
+ // Derive a conversation ID from the scope_key if it looks like a conversation summary.
213
+ // scope_key format: "conversation:<conversationId>" or "<scope>:<key>"
214
+ const conversationId = extractConversationId(sum.scope, sum.scope_key);
215
+ if (!conversationId) {
216
+ log.debug(
217
+ { summaryId: sum.id, scope: sum.scope, scopeKey: sum.scope_key },
218
+ "Skipping non-conversation summary",
219
+ );
220
+ continue;
221
+ }
222
+
223
+ const episodeId = uuid();
224
+ const title = buildEpisodeTitle(sum.scope, sum.scope_key);
225
+
226
+ db.insert(memoryEpisodes)
227
+ .values({
228
+ id: episodeId,
229
+ scopeId: sum.scope_id,
230
+ conversationId,
231
+ title,
232
+ summary: sum.summary,
233
+ tokenEstimate: sum.token_estimate,
234
+ source: "backfill:summary",
235
+ startAt: sum.start_at,
236
+ endAt: sum.end_at,
237
+ createdAt: now,
238
+ updatedAt: now,
239
+ })
240
+ .onConflictDoNothing()
241
+ .run();
242
+
243
+ // Enqueue embedding for the new episode
244
+ enqueueMemoryJob("embed_episode", { episodeId });
245
+ } catch (err) {
246
+ log.warn(
247
+ { err, summaryId: sum.id },
248
+ "Failed to migrate summary, skipping",
249
+ );
250
+ }
251
+ }
252
+
253
+ const lastSummary = summaries[summaries.length - 1];
254
+ setMemoryCheckpoint(CHECKPOINT_SUMMARIES, lastSummary.id);
255
+
256
+ log.debug(
257
+ { migrated: summaries.length, lastId: lastSummary.id },
258
+ "Migrated summary batch",
259
+ );
260
+
261
+ return summaries.length === BATCH_SIZE;
262
+ }
263
+
264
+ // ── Phase 3: Items ────────────────────────────────────────────────────
265
+
266
+ /** Sentinel conversation ID for legacy items that have no conversation linkage. */
267
+ const LEGACY_SENTINEL_CONVERSATION_ID = "__legacy_backfill__";
268
+
269
+ /**
270
+ * Ensure the legacy sentinel conversation row exists. This is needed because
271
+ * memory_observations has a FK constraint on conversation_id.
272
+ */
273
+ function ensureLegacySentinelConversation(): void {
274
+ const db = getDb();
275
+ const existing = db
276
+ .select({ id: conversations.id })
277
+ .from(conversations)
278
+ .where(eq(conversations.id, LEGACY_SENTINEL_CONVERSATION_ID))
279
+ .get();
280
+ if (existing) return;
281
+
282
+ const now = Date.now();
283
+ db.insert(conversations)
284
+ .values({
285
+ id: LEGACY_SENTINEL_CONVERSATION_ID,
286
+ title: "[Legacy Memory Backfill]",
287
+ createdAt: now,
288
+ updatedAt: now,
289
+ })
290
+ .run();
291
+ }
292
+
293
+ function migrateItems(): boolean {
294
+ const lastId = getMemoryCheckpoint(CHECKPOINT_ITEMS) ?? "";
295
+
296
+ const items = rawAll<LegacyItem>(
297
+ `SELECT id, kind, subject, statement, status, confidence, scope_id,
298
+ first_seen_at, last_seen_at, valid_from, invalid_at
299
+ FROM memory_items
300
+ WHERE id > ?
301
+ AND status = 'active'
302
+ AND confidence >= 0.5
303
+ AND invalid_at IS NULL
304
+ ORDER BY id ASC
305
+ LIMIT ?`,
306
+ lastId,
307
+ BATCH_SIZE,
308
+ );
309
+
310
+ if (items.length === 0) return false;
311
+
312
+ // Ensure the sentinel conversation exists for items without conversation linkage
313
+ ensureLegacySentinelConversation();
314
+
315
+ const db = getDb();
316
+ const now = Date.now();
317
+
318
+ for (const item of items) {
319
+ try {
320
+ // Every active item becomes an observation
321
+ const observationId = uuid();
322
+ const observationContent = `[${item.kind}] ${item.subject}: ${item.statement}`;
323
+
324
+ db.insert(memoryObservations)
325
+ .values({
326
+ id: observationId,
327
+ scopeId: item.scope_id,
328
+ conversationId: LEGACY_SENTINEL_CONVERSATION_ID,
329
+ role: "user",
330
+ content: observationContent,
331
+ modality: "text",
332
+ source: "backfill:item",
333
+ createdAt: now,
334
+ })
335
+ .run();
336
+
337
+ // Create a chunk for the observation (with dedup)
338
+ const contentHash = computeChunkContentHash(
339
+ item.scope_id,
340
+ observationContent,
341
+ );
342
+ const chunkId = uuid();
343
+ const tokenEstimate = estimateTextTokens(observationContent);
344
+
345
+ db.insert(memoryChunks)
346
+ .values({
347
+ id: chunkId,
348
+ scopeId: item.scope_id,
349
+ observationId,
350
+ content: observationContent,
351
+ tokenEstimate,
352
+ contentHash,
353
+ createdAt: now,
354
+ })
355
+ .onConflictDoNothing({
356
+ target: [memoryChunks.scopeId, memoryChunks.contentHash],
357
+ })
358
+ .run();
359
+
360
+ // Enqueue embedding for the observation's chunk
361
+ enqueueMemoryJob("embed_chunk", { chunkId, scopeId: item.scope_id });
362
+
363
+ // ── Brief-state: map unambiguous items to time_contexts or open_loops
364
+ mapItemToBriefState(item, now);
365
+ } catch (err) {
366
+ log.warn({ err, itemId: item.id }, "Failed to migrate item, skipping");
367
+ }
368
+ }
369
+
370
+ const lastItem = items[items.length - 1];
371
+ setMemoryCheckpoint(CHECKPOINT_ITEMS, lastItem.id);
372
+
373
+ log.debug(
374
+ { migrated: items.length, lastId: lastItem.id },
375
+ "Migrated item batch",
376
+ );
377
+
378
+ return items.length === BATCH_SIZE;
379
+ }
380
+
381
+ // ── Brief-state mapping ───────────────────────────────────────────────
382
+
383
+ /**
384
+ * Map a legacy memory item to `time_contexts` or `open_loops` when the
385
+ * mapping is unambiguous.
386
+ *
387
+ * - Items with `valid_from` and a future `invalid_at` -> time_context
388
+ * - `event` kind items with future timestamps -> open_loop
389
+ */
390
+ function mapItemToBriefState(item: LegacyItem, now: number): void {
391
+ const db = getDb();
392
+
393
+ // Time-bounded items -> time_contexts
394
+ if (
395
+ item.valid_from != null &&
396
+ item.invalid_at != null &&
397
+ item.invalid_at > now
398
+ ) {
399
+ db.insert(timeContexts)
400
+ .values({
401
+ id: uuid(),
402
+ scopeId: item.scope_id,
403
+ summary: `${item.subject}: ${item.statement}`,
404
+ source: "backfill:item",
405
+ activeFrom: item.valid_from,
406
+ activeUntil: item.invalid_at,
407
+ createdAt: now,
408
+ updatedAt: now,
409
+ })
410
+ .run();
411
+ return;
412
+ }
413
+
414
+ // Event items with future last_seen_at -> open_loops
415
+ if (item.kind === "event" && item.last_seen_at > now) {
416
+ db.insert(openLoops)
417
+ .values({
418
+ id: uuid(),
419
+ scopeId: item.scope_id,
420
+ summary: `${item.subject}: ${item.statement}`,
421
+ source: "backfill:item",
422
+ status: "open",
423
+ dueAt: item.last_seen_at,
424
+ createdAt: now,
425
+ updatedAt: now,
426
+ })
427
+ .run();
428
+ }
429
+ }
430
+
431
+ // ── Helpers ───────────────────────────────────────────────────────────
432
+
433
+ /**
434
+ * Extract a conversation ID from the summary's scope and scope_key.
435
+ * Returns null for non-conversation summaries.
436
+ */
437
+ function extractConversationId(scope: string, scopeKey: string): string | null {
438
+ // Conversation summaries use scope "conversation" with scope_key as the ID
439
+ if (scope === "conversation") return scopeKey;
440
+
441
+ // Some summaries use "conversation:<id>" as scope_key
442
+ const match = scopeKey.match(/^conversation:(.+)$/);
443
+ if (match) return match[1];
444
+
445
+ return null;
446
+ }
447
+
448
+ /**
449
+ * Build a human-readable episode title from the summary's scope metadata.
450
+ */
451
+ function buildEpisodeTitle(scope: string, scopeKey: string): string {
452
+ if (scope === "conversation") {
453
+ return `Conversation summary`;
454
+ }
455
+ if (scope === "weekly") {
456
+ return `Weekly summary (${scopeKey})`;
457
+ }
458
+ if (scope === "monthly") {
459
+ return `Monthly summary (${scopeKey})`;
460
+ }
461
+ return `${scope} summary`;
462
+ }
@@ -221,7 +221,7 @@ Favor what is live over what is merely true. Recent changes matter more than old
221
221
  Return exactly 4 starters in rank order (best first).
222
222
 
223
223
  Each starter has:
224
- - label: 3-6 words, max 40 chars, starts with a verb. Should sound like a smart offer of help, not a feature name or task description. Must sound natural when read aloud.
224
+ - label: 3-6 words, max 40 chars, starts with a verb. Should read as something the user wants to do — these chips send a message as the user, so the label must be in the user's voice. Must sound natural when read aloud.
225
225
  - prompt: 1-2 natural sentences, written as the user would actually say them — not templated.
226
226
  - category: one of ${CONVERSATION_STARTER_CATEGORIES.join(", ")}
227
227
 
@@ -239,6 +239,8 @@ If a label sounds like an issue title, project ticket, or implementation task, r
239
239
 
240
240
  Prefer natural, flowing language over mechanical or operational phrasing. "Get Slack messages flowing" is better than "Restore outgoing Slack messages." The label should sound like something a helpful person would say, not a support ticket.
241
241
 
242
+ Voice: The user clicks these chips to send a message. Every label must make sense as something the user is asking to do, never something the assistant is saying to the user.
243
+
242
244
  Before finalizing each label, ask yourself: would this feel good to click? Or does it sound like a backlog item? If it sounds like a backlog item, rewrite it.
243
245
 
244
246
  Examples of bad vs good:
@@ -249,7 +251,11 @@ Examples of bad vs good:
249
251
  - BAD: "Restore outgoing Slack messages" → GOOD: "Get Slack messages flowing"
250
252
  - BAD: "Set up a playbook for inbox" → GOOD: "Catch the emails that matter"
251
253
 
252
- The good versions emphasize the user's payoff, not the internal mechanism.`;
254
+ Assistant-voice vs user-voice:
255
+ - BAD: "You've got a busy week ahead" → GOOD: "Plan my week ahead"
256
+ - BAD: "Let me check your calendar" → GOOD: "Check my Thursday schedule"
257
+
258
+ The good versions emphasize the user's payoff in the user's own voice, not the internal mechanism or the assistant's perspective.`;
253
259
 
254
260
  const { signal, cleanup } = createTimeout(20000);
255
261
  try {
@@ -274,7 +280,7 @@ The good versions emphasize the user's payoff, not the internal mechanism.`;
274
280
  label: {
275
281
  type: "string",
276
282
  description:
277
- "Concierge-quality chip text (2-7 words, max 40 chars, starts with a verb)",
283
+ "User-voice chip text (2-7 words, max 40 chars, starts with a verb)",
278
284
  },
279
285
  prompt: {
280
286
  type: "string",
@@ -183,6 +183,7 @@ describe("embedMediaJob", () => {
183
183
  expect(call.extraPayload).toEqual({
184
184
  created_at: now,
185
185
  kind: "image",
186
+ memory_scope_id: "default",
186
187
  subject: "My Screenshot",
187
188
  });
188
189
  });
@@ -11,7 +11,10 @@ import type { MemoryJob } from "../jobs-store.js";
11
11
  import { extractMediaBlocks } from "../message-content.js";
12
12
  import {
13
13
  mediaAssets,
14
+ memoryChunks,
15
+ memoryEpisodes,
14
16
  memoryItems,
17
+ memoryObservations,
15
18
  memorySegments,
16
19
  memorySummaries,
17
20
  messages,
@@ -34,6 +37,7 @@ export async function embedSegmentJob(
34
37
  conversation_id: segment.conversationId,
35
38
  message_id: segment.messageId,
36
39
  created_at: segment.createdAt,
40
+ memory_scope_id: segment.scopeId,
37
41
  });
38
42
  }
39
43
 
@@ -58,6 +62,7 @@ export async function embedItemJob(
58
62
  confidence: item.confidence,
59
63
  created_at: item.firstSeenAt,
60
64
  last_seen_at: item.lastSeenAt,
65
+ memory_scope_id: item.scopeId,
61
66
  });
62
67
  }
63
68
 
@@ -83,10 +88,31 @@ export async function embedSummaryJob(
83
88
  kind: summary.scope,
84
89
  created_at: summary.startAt,
85
90
  last_seen_at: summary.endAt,
91
+ memory_scope_id: summary.scopeId,
86
92
  },
87
93
  );
88
94
  }
89
95
 
96
+ export async function embedChunkJob(
97
+ job: MemoryJob,
98
+ config: AssistantConfig,
99
+ ): Promise<void> {
100
+ const chunkId = asString(job.payload.chunkId);
101
+ if (!chunkId) return;
102
+ const db = getDb();
103
+ const chunk = db
104
+ .select()
105
+ .from(memoryChunks)
106
+ .where(eq(memoryChunks.id, chunkId))
107
+ .get();
108
+ if (!chunk) return;
109
+ await embedAndUpsert(config, "chunk", chunk.id, chunk.content, {
110
+ observation_id: chunk.observationId,
111
+ created_at: chunk.createdAt,
112
+ memory_scope_id: chunk.scopeId,
113
+ });
114
+ }
115
+
90
116
  export async function embedMediaJob(
91
117
  job: MemoryJob,
92
118
  config: AssistantConfig,
@@ -116,6 +142,41 @@ export async function embedMediaJob(
116
142
  created_at: asset.createdAt,
117
143
  kind: asset.mediaType,
118
144
  subject: asset.title,
145
+ memory_scope_id: "default",
146
+ });
147
+ }
148
+
149
+ export async function embedObservationJob(
150
+ job: MemoryJob,
151
+ config: AssistantConfig,
152
+ ): Promise<void> {
153
+ const observationId = asString(job.payload.observationId);
154
+ const chunkId = asString(job.payload.chunkId);
155
+ if (!observationId || !chunkId) return;
156
+
157
+ const db = getDb();
158
+ const observation = db
159
+ .select()
160
+ .from(memoryObservations)
161
+ .where(eq(memoryObservations.id, observationId))
162
+ .get();
163
+ if (!observation) return;
164
+
165
+ const chunk = db
166
+ .select()
167
+ .from(memoryChunks)
168
+ .where(eq(memoryChunks.id, chunkId))
169
+ .get();
170
+ if (!chunk) return;
171
+
172
+ await embedAndUpsert(config, "observation", chunk.id, chunk.content, {
173
+ observation_id: observationId,
174
+ conversation_id: observation.conversationId,
175
+ role: observation.role,
176
+ modality: observation.modality,
177
+ source: observation.source,
178
+ created_at: observation.createdAt,
179
+ memory_scope_id: observation.scopeId,
119
180
  });
120
181
  }
121
182
 
@@ -155,3 +216,25 @@ export async function embedAttachmentJob(
155
216
  memory_scope_id: memoryScopeId,
156
217
  });
157
218
  }
219
+
220
+ export async function embedEpisodeJob(
221
+ job: MemoryJob,
222
+ config: AssistantConfig,
223
+ ): Promise<void> {
224
+ const episodeId = asString(job.payload.episodeId);
225
+ if (!episodeId) return;
226
+ const db = getDb();
227
+ const episode = db
228
+ .select()
229
+ .from(memoryEpisodes)
230
+ .where(eq(memoryEpisodes.id, episodeId))
231
+ .get();
232
+ if (!episode) return;
233
+ const text = `[episode] ${episode.title}: ${episode.summary}`;
234
+ await embedAndUpsert(config, "episode", episode.id, text, {
235
+ conversation_id: episode.conversationId,
236
+ created_at: episode.startAt,
237
+ last_seen_at: episode.endAt,
238
+ memory_scope_id: episode.scopeId,
239
+ });
240
+ }