@vellumai/assistant 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/Dockerfile +18 -27
  2. package/docs/architecture/memory.md +105 -0
  3. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  4. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/archive-recall.test.ts +560 -0
  7. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  8. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  11. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  12. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  13. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  14. package/src/__tests__/memory-reducer.test.ts +7 -1
  15. package/src/__tests__/memory-regressions.test.ts +24 -4
  16. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  17. package/src/__tests__/openai-whisper.test.ts +93 -0
  18. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  19. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  20. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  21. package/src/__tests__/volume-security-guard.test.ts +155 -0
  22. package/src/cli/commands/conversations.ts +18 -0
  23. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  24. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  25. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  26. package/src/config/env-registry.ts +9 -0
  27. package/src/config/feature-flag-registry.json +8 -0
  28. package/src/config/loader.ts +0 -1
  29. package/src/config/schemas/memory-simplified.ts +1 -1
  30. package/src/credential-execution/managed-catalog.ts +5 -15
  31. package/src/daemon/config-watcher.ts +4 -1
  32. package/src/daemon/conversation-memory.ts +117 -0
  33. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  34. package/src/daemon/daemon-control.ts +7 -0
  35. package/src/daemon/handlers/conversations.ts +11 -0
  36. package/src/daemon/lifecycle.ts +51 -2
  37. package/src/daemon/providers-setup.ts +2 -1
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/archive-recall.ts +516 -0
  41. package/src/memory/brief-time.ts +5 -4
  42. package/src/memory/conversation-crud.ts +210 -0
  43. package/src/memory/conversation-key-store.ts +33 -4
  44. package/src/memory/db-init.ts +4 -0
  45. package/src/memory/embedding-local.ts +11 -5
  46. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  47. package/src/memory/job-handlers/conversation-starters.ts +24 -30
  48. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  49. package/src/memory/jobs-store.ts +2 -0
  50. package/src/memory/jobs-worker.ts +8 -0
  51. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  52. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  53. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  54. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  55. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  56. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  57. package/src/memory/migrations/index.ts +1 -0
  58. package/src/memory/reducer-scheduler.ts +242 -0
  59. package/src/memory/reducer-types.ts +9 -2
  60. package/src/memory/reducer.ts +25 -11
  61. package/src/memory/schema/infrastructure.ts +1 -0
  62. package/src/messaging/provider.ts +9 -0
  63. package/src/messaging/providers/slack/adapter.ts +29 -2
  64. package/src/oauth/connection-resolver.test.ts +22 -18
  65. package/src/oauth/connection-resolver.ts +92 -7
  66. package/src/oauth/platform-connection.test.ts +78 -69
  67. package/src/oauth/platform-connection.ts +12 -19
  68. package/src/permissions/trust-client.ts +343 -0
  69. package/src/permissions/trust-store-interface.ts +105 -0
  70. package/src/permissions/trust-store.ts +523 -36
  71. package/src/platform/client.test.ts +148 -0
  72. package/src/platform/client.ts +71 -0
  73. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  74. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  75. package/src/providers/speech-to-text/resolve.ts +9 -0
  76. package/src/providers/speech-to-text/types.ts +17 -0
  77. package/src/runtime/auth/route-policy.ts +10 -1
  78. package/src/runtime/http-server.ts +2 -2
  79. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  80. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  81. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  82. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  83. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  84. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  85. package/src/runtime/routes/log-export-routes.ts +1 -0
  86. package/src/runtime/routes/secret-routes.ts +5 -1
  87. package/src/schedule/schedule-store.ts +7 -0
  88. package/src/schedule/scheduler.ts +6 -2
  89. package/src/security/ces-credential-client.ts +173 -0
  90. package/src/security/secure-keys.ts +65 -22
  91. package/src/signals/bash.ts +3 -0
  92. package/src/signals/cancel.ts +3 -0
  93. package/src/signals/confirm.ts +3 -0
  94. package/src/signals/conversation-undo.ts +3 -0
  95. package/src/signals/event-stream.ts +7 -0
  96. package/src/signals/shotgun.ts +3 -0
  97. package/src/signals/trust-rule.ts +3 -0
  98. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  99. package/src/telemetry/usage-telemetry-reporter.ts +22 -20
  100. package/src/tools/filesystem/edit.ts +6 -1
  101. package/src/tools/filesystem/read.ts +6 -1
  102. package/src/tools/filesystem/write.ts +6 -1
  103. package/src/tools/memory/handlers.ts +129 -1
  104. package/src/tools/schedule/create.ts +3 -0
  105. package/src/tools/schedule/list.ts +5 -1
  106. package/src/tools/schedule/update.ts +6 -0
  107. package/src/util/device-id.ts +70 -7
  108. package/src/util/logger.ts +35 -9
  109. package/src/util/platform.ts +29 -5
  110. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  111. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,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
+ }
@@ -176,11 +176,11 @@ async function generateStarters(scopeId: string): Promise<GeneratedStarter[]> {
176
176
  ? truncate(rawIdentityContext, 2000, "\n…[truncated]")
177
177
  : null;
178
178
 
179
- const systemPrompt = `You are generating 4 conversation starters for a personal assistant app. These appear as clickable chips on the empty conversation page — the first thing the user sees when they open the app.
179
+ const systemPrompt = `You are generating 4 conversation starters for a personal assistant app. These appear as clickable chips on the empty conversation page — the first thing the user sees when they open the app. Clicking a chip sends its prompt as a message from the user.
180
180
 
181
181
  ${timeContext}
182
182
 
183
- Your goal: look at what's going on in this person's life right now and suggest the 4 most useful things they could ask you to do. Think about what a thoughtful chief of staff would proactively bring up in a 30-second check-in.
183
+ Your goal: suggest the 4 most useful things this person could ask you to do right now.
184
184
 
185
185
  ${identityContext ? `## Assistant identity & user profile\n\n${identityContext}\n\n` : ""}## What you know
186
186
 
@@ -188,7 +188,9 @@ ${rollup}
188
188
  ${diff}
189
189
  ${skills}
190
190
 
191
- ## How to think about this
191
+ ## Selection
192
+
193
+ Generate exactly 4 starters, ranked #1 (best) to #4.
192
194
 
193
195
  Start from the user's situation, not from the skill list. Ask yourself:
194
196
  - What is this person likely dealing with right now (given the day/time and their context)?
@@ -197,11 +199,7 @@ Start from the user's situation, not from the skill list. Ask yourself:
197
199
 
198
200
  The skills list tells you what the assistant CAN do — use it to filter out suggestions the assistant can't actually help with, not as a menu to generate suggestions from.
199
201
 
200
- ## Selection
201
-
202
- Generate exactly 4 starters, ranked #1 (best) to #4.
203
-
204
- For each, you must be able to clearly answer:
202
+ For each starter, you must clearly answer:
205
203
  - Why now? (timing — day of week, recent activity, upcoming deadline)
206
204
  - Why this user? (grounded in their specific context, not generic)
207
205
  - Why would they be glad I suggested this? (genuine usefulness, not just relevance)
@@ -218,38 +216,34 @@ Favor what is live over what is merely true. Recent changes matter more than old
218
216
 
219
217
  ## Output format
220
218
 
221
- Return exactly 4 starters in rank order (best first).
222
-
223
219
  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.
225
- - prompt: 1-2 natural sentences, written as the user would actually say them — not templated.
220
+ - label: 3-6 words, max 40 chars, starts with a verb. Written in the user's voice something they'd want to do, not something the assistant is offering.
221
+ - prompt: 1-2 natural sentences, as the user would actually say them.
226
222
  - category: one of ${CONVERSATION_STARTER_CATEGORIES.join(", ")}
227
223
 
228
- The 4 starters should feel like one coherent set of recommendations for this moment — similar abstraction level, no jarring mix of mundane chores and life strategy. Don't lift raw memory phrases, project names, or jargon into labels unless they already sound natural in conversation.
229
-
230
- Never include a chip whose primary meaning is configuration, setup, workflow creation, or "set up X for Y" unless it solves an urgent pain the user is actively feeling right now. Prefer the outcome over the mechanism — "Catch the emails that matter" beats "Set up a playbook for inbox."
224
+ ## Constraints
231
225
 
232
- ## Topic diversity
226
+ **Voice**: The user clicks these chips to send a message. Every label must read as something the user is asking to do, never something the assistant is saying to the user.
233
227
 
234
- Each chip should cover a distinct topic or concern. Never have two chips about the same tool, project, or theme even if there are multiple related issues. Pick the single most impactful angle and give the other slot to something different. Four chips about three topics is too narrow; four chips about four topics is right.
228
+ **Coherence**: The 4 starters should feel like one set similar abstraction level, no jarring mix of mundane chores and life strategy.
235
229
 
236
- ## User-facingness check
230
+ **Diversity**: Each chip covers a distinct topic. Never two chips about the same tool, project, or theme. Four topics, four chips.
237
231
 
238
- If a label sounds like an issue title, project ticket, or implementation task, rewrite it. Prefer the user-visible payoff over the internal object name. The chip should feel inviting and useful, not merely accurate.
232
+ **No setup chips**: Never include a chip whose primary meaning is configuration or "set up X for Y" unless it solves an urgent pain the user is actively feeling. Prefer the outcome over the mechanism.
239
233
 
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.
234
+ **Natural language**: No jargon, project names, or raw memory phrases in labels unless they already sound natural in conversation. If a label sounds like a ticket title or backlog item, rewrite it as something the user would actually say.
241
235
 
242
- 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.
236
+ ## Examples
243
237
 
244
- Examples of bad vs good:
245
- - BAD: "Fix Slack Socket Mode blocker" → GOOD: "Fix Slack so it just works"
246
- - BAD: "Rewire messaging for Socket Mode" → GOOD: "Get Socket Mode stable"
247
- - BAD: "Review this week's calendar" → GOOD: "Protect this week's focus"
248
- - BAD: "Model the coaching transition" → GOOD: "Plan the coaching transition"
249
- - BAD: "Restore outgoing Slack messages" → GOOD: "Get Slack messages flowing"
250
- - BAD: "Set up a playbook for inbox" → GOOD: "Catch the emails that matter"
238
+ Bad Good (ticket-speak → natural):
239
+ - "Fix Slack Socket Mode blocker" → "Fix Slack so it just works"
240
+ - "Restore outgoing Slack messages" → "Get Slack messages flowing"
241
+ - "Review this week's calendar" → "Protect this week's focus"
242
+ - "Set up a playbook for inbox" → "Triage my inbox"
251
243
 
252
- The good versions emphasize the user's payoff, not the internal mechanism.`;
244
+ Bad Good (assistant voice user voice):
245
+ - "You've got a busy week ahead" → "Plan my week ahead"
246
+ - "Let me check your calendar" → "Check my Thursday schedule"`;
253
247
 
254
248
  const { signal, cleanup } = createTimeout(20000);
255
249
  try {
@@ -274,7 +268,7 @@ The good versions emphasize the user's payoff, not the internal mechanism.`;
274
268
  label: {
275
269
  type: "string",
276
270
  description:
277
- "Concierge-quality chip text (2-7 words, max 40 chars, starts with a verb)",
271
+ "User-voice chip label (2-7 words, max 40 chars, verb-first)",
278
272
  },
279
273
  prompt: {
280
274
  type: "string",