@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
@@ -38,7 +38,6 @@ import {
38
38
  import { registerToolTraceListener } from "../events/tool-trace-listener.js";
39
39
  import { getHookManager } from "../hooks/manager.js";
40
40
  import { resolveCanonicalGuardianRequest } from "../memory/canonical-guardian-store.js";
41
- import { getMessages } from "../memory/conversation-crud.js";
42
41
  import { PermissionPrompter } from "../permissions/prompter.js";
43
42
  import { SecretPrompter } from "../permissions/secret-prompter.js";
44
43
  import { patternMatchesCandidate } from "../permissions/trust-store.js";
@@ -196,13 +195,24 @@ export class Conversation {
196
195
  >();
197
196
  /** @internal */ surfaceState = new Map<
198
197
  string,
199
- { surfaceType: SurfaceType; data: SurfaceData; title?: string }
198
+ {
199
+ surfaceType: SurfaceType;
200
+ data: SurfaceData;
201
+ title?: string;
202
+ actions?: Array<{
203
+ id: string;
204
+ label: string;
205
+ style?: string;
206
+ data?: Record<string, unknown>;
207
+ }>;
208
+ }
200
209
  >();
201
210
  /** @internal */ surfaceUndoStacks = new Map<string, string[]>();
202
211
  /** @internal */ accumulatedSurfaceState = new Map<
203
212
  string,
204
213
  Record<string, unknown>
205
214
  >();
215
+ /** @internal */ broadcastToAllClients?: (msg: ServerMessage) => void;
206
216
  /** @internal */ withSurface = createSurfaceMutex();
207
217
  /** @internal */ currentTurnSurfaces: Array<{
208
218
  surfaceId: string;
@@ -249,6 +259,7 @@ export class Conversation {
249
259
  this.provider = provider;
250
260
  this.workingDir = workingDir;
251
261
  this.sendToClient = sendToClient;
262
+ this.broadcastToAllClients = broadcastToAllClients;
252
263
  this.memoryPolicy = memoryPolicy
253
264
  ? { ...memoryPolicy }
254
265
  : { ...DEFAULT_MEMORY_POLICY };
@@ -386,30 +397,36 @@ export class Conversation {
386
397
  }
387
398
 
388
399
  /**
389
- * Scan ALL persisted messages (including compacted ones) for ui_surface
390
- * content blocks and populate surfaceState so findConversationBySurfaceId
391
- * works for surfaces restored from history (e.g. after daemon restart).
400
+ * Scan loaded conversation history for ui_surface content blocks and
401
+ * populate surfaceState so that findConversationBySurfaceId works for
402
+ * surfaces restored from history (e.g. after daemon restart).
403
+ *
404
+ * Only scans live (non-compacted) messages in this.messages — not all DB
405
+ * rows — because surface IDs are not globally unique and restoring stale
406
+ * compacted surfaces would let findConversationBySurfaceId route actions
407
+ * to the wrong conversation.
392
408
  */
393
409
  private restoreSurfaceStateFromHistory(): void {
394
- const dbMessages = getMessages(this.conversationId);
395
- for (const row of dbMessages) {
396
- try {
397
- const content = JSON.parse(row.content);
398
- if (!Array.isArray(content)) continue;
399
- for (const block of content) {
400
- if (
401
- block.type === "ui_surface" &&
402
- typeof block.surfaceId === "string"
403
- ) {
404
- this.surfaceState.set(block.surfaceId, {
405
- surfaceType: (block.surfaceType ?? "dynamic_page") as SurfaceType,
406
- data: (block.data ?? {}) as SurfaceData,
407
- title: block.title as string | undefined,
408
- });
409
- }
410
+ this.surfaceState.clear();
411
+ for (const msg of this.messages) {
412
+ if (!Array.isArray(msg.content)) continue;
413
+ for (const block of msg.content) {
414
+ const b = block as unknown as Record<string, unknown>;
415
+ if (b.type === "ui_surface" && typeof b.surfaceId === "string") {
416
+ this.surfaceState.set(b.surfaceId, {
417
+ surfaceType: (b.surfaceType ?? "dynamic_page") as SurfaceType,
418
+ data: (b.data ?? {}) as SurfaceData,
419
+ title: b.title as string | undefined,
420
+ actions: Array.isArray(b.actions)
421
+ ? (b.actions as Array<{
422
+ id: string;
423
+ label: string;
424
+ style?: string;
425
+ data?: Record<string, unknown>;
426
+ }>)
427
+ : undefined,
428
+ });
410
429
  }
411
- } catch {
412
- // Content isn't valid JSON — skip
413
430
  }
414
431
  }
415
432
  }
@@ -3,7 +3,10 @@ import {
3
3
  loadRawConfig,
4
4
  saveRawConfig,
5
5
  } from "../../config/loader.js";
6
- import { setMemoryEmbeddingField } from "../../config/raw-config-utils.js";
6
+ import {
7
+ deleteMemoryEmbeddingField,
8
+ setMemoryEmbeddingField,
9
+ } from "../../config/raw-config-utils.js";
7
10
  import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
8
11
  import {
9
12
  clearEmbeddingBackendCache,
@@ -118,7 +121,12 @@ export async function setEmbeddingConfig(
118
121
  if (model !== undefined) {
119
122
  const fieldName = PROVIDER_MODEL_FIELD[provider];
120
123
  if (fieldName) {
121
- setMemoryEmbeddingField(raw, fieldName, model);
124
+ if (model === "") {
125
+ // Empty string means "clear override — use schema default"
126
+ deleteMemoryEmbeddingField(raw, fieldName);
127
+ } else {
128
+ setMemoryEmbeddingField(raw, fieldName, model);
129
+ }
122
130
  }
123
131
  }
124
132
 
@@ -16,7 +16,6 @@ import {
16
16
  isProviderAvailable,
17
17
  } from "../../providers/provider-availability.js";
18
18
  import { initializeProviders } from "../../providers/registry.js";
19
- import { getMaskedProviderKey } from "../../security/secure-keys.js";
20
19
  import type {
21
20
  ImageGenModelSetRequest,
22
21
  ModelSetRequest,
@@ -44,7 +43,6 @@ export interface ModelInfo {
44
43
  configuredProviders?: string[];
45
44
  availableModels?: Array<{ id: string; displayName: string }>;
46
45
  allProviders?: ProviderCatalogEntry[];
47
- maskedKeys?: Record<string, string>;
48
46
  }
49
47
 
50
48
  /** Return current model configuration. */
@@ -52,19 +50,12 @@ export async function getModelInfo(): Promise<ModelInfo> {
52
50
  const config = getConfig();
53
51
  const provider = config.services.inference.provider;
54
52
 
55
- const maskedKeys: Record<string, string> = {};
56
- for (const p of VALID_INFERENCE_PROVIDERS) {
57
- const masked = await getMaskedProviderKey(p);
58
- if (masked) maskedKeys[p] = masked;
59
- }
60
-
61
53
  return {
62
54
  model: config.services.inference.model,
63
55
  provider,
64
56
  configuredProviders: await getConfiguredProviders(),
65
57
  availableModels: PROVIDER_CATALOG.find((p) => p.id === provider)?.models,
66
58
  allProviders: PROVIDER_CATALOG,
67
- maskedKeys,
68
59
  };
69
60
  }
70
61
 
@@ -23,6 +23,7 @@ import {
23
23
  queueGenerateConversationTitle,
24
24
  UNTITLED_FALLBACK,
25
25
  } from "../../memory/conversation-title-service.js";
26
+ import { reduceBeforeSwitch } from "../../memory/reducer-scheduler.js";
26
27
  import * as pendingInteractions from "../../runtime/pending-interactions.js";
27
28
  import { getSubagentManager } from "../../subagent/index.js";
28
29
  import { truncate } from "../../util/truncate.js";
@@ -233,6 +234,12 @@ export async function handleConversationCreate(
233
234
  conversationType: normalizeConversationType(conversation.conversationType),
234
235
  });
235
236
 
237
+ // Reduce the previous dirty conversation before processing the initial
238
+ // message so its memory is fresh for the next read.
239
+ if (msg.initialMessage) {
240
+ await reduceBeforeSwitch(conversation.id);
241
+ }
242
+
236
243
  // Auto-send the initial message if provided, kick-starting the skill.
237
244
  if (msg.initialMessage) {
238
245
  // Queue title generation eagerly — some processMessage paths (guardian
@@ -343,6 +350,10 @@ export async function switchConversation(
343
350
  return null;
344
351
  }
345
352
 
353
+ // Reduce the previous dirty conversation before switching so its memory
354
+ // is fresh for the next read.
355
+ await reduceBeforeSwitch(conversationId);
356
+
346
357
  // If the target conversation is headless-locked (actively executing a task run),
347
358
  // skip rebinding so tool confirmations stay suppressed.
348
359
  const existingConversation = ctx.conversations.get(conversationId);
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Returns true when the value is a template placeholder that should be treated
3
+ * as empty/unset. Placeholders follow the pattern `_(…)_`, e.g.
4
+ * `_(not yet chosen)_` or `_(not yet established)_`.
5
+ */
6
+ export function isTemplatePlaceholder(value: string): boolean {
7
+ return value.startsWith("_(") && value.endsWith(")_");
8
+ }
9
+
1
10
  export interface IdentityFields {
2
11
  name: string;
3
12
  role: string;
@@ -14,7 +23,9 @@ export function parseIdentityFields(content: string): IdentityFields {
14
23
  const lower = trimmed.toLowerCase();
15
24
  const extract = (prefix: string): string | null => {
16
25
  if (!lower.startsWith(prefix)) return null;
17
- return trimmed.split(":**").pop()?.trim() ?? null;
26
+ const value = trimmed.split(":**").pop()?.trim() ?? null;
27
+ if (value && isTemplatePlaceholder(value)) return null;
28
+ return value;
18
29
  };
19
30
 
20
31
  const name = extract("- **name:**");
@@ -30,6 +30,7 @@ import {
30
30
  getConversationType,
31
31
  getMessages,
32
32
  purgePrivateConversations,
33
+ sweepStaleReducerJobs,
33
34
  } from "../memory/conversation-crud.js";
34
35
  import { resolveConversationId } from "../memory/conversation-key-store.js";
35
36
  import { initializeDb } from "../memory/db.js";
@@ -200,14 +201,46 @@ export async function runDaemon(): Promise<void> {
200
201
  targetId: itemId,
201
202
  });
202
203
  }
204
+ for (const summaryId of deletedMemory.deletedSummaryIds) {
205
+ enqueueMemoryJob("delete_qdrant_vectors", {
206
+ targetType: "summary",
207
+ targetId: summaryId,
208
+ });
209
+ }
210
+ for (const obsId of deletedMemory.deletedObservationIds) {
211
+ enqueueMemoryJob("delete_qdrant_vectors", {
212
+ targetType: "observation",
213
+ targetId: obsId,
214
+ });
215
+ }
216
+ for (const chunkId of deletedMemory.deletedChunkIds) {
217
+ enqueueMemoryJob("delete_qdrant_vectors", {
218
+ targetType: "chunk",
219
+ targetId: chunkId,
220
+ });
221
+ }
222
+ for (const episodeId of deletedMemory.deletedEpisodeIds) {
223
+ enqueueMemoryJob("delete_qdrant_vectors", {
224
+ targetType: "episode",
225
+ targetId: episodeId,
226
+ });
227
+ }
203
228
  if (
204
229
  deletedMemory.segmentIds.length > 0 ||
205
- deletedMemory.orphanedItemIds.length > 0
230
+ deletedMemory.orphanedItemIds.length > 0 ||
231
+ deletedMemory.deletedSummaryIds.length > 0 ||
232
+ deletedMemory.deletedObservationIds.length > 0 ||
233
+ deletedMemory.deletedChunkIds.length > 0 ||
234
+ deletedMemory.deletedEpisodeIds.length > 0
206
235
  ) {
207
236
  log.info(
208
237
  {
209
238
  segments: deletedMemory.segmentIds.length,
210
239
  orphanedItems: deletedMemory.orphanedItemIds.length,
240
+ deletedSummaries: deletedMemory.deletedSummaryIds.length,
241
+ deletedObservations: deletedMemory.deletedObservationIds.length,
242
+ deletedChunks: deletedMemory.deletedChunkIds.length,
243
+ deletedEpisodes: deletedMemory.deletedEpisodeIds.length,
211
244
  },
212
245
  "Enqueued Qdrant vector cleanup jobs for purged private conversations",
213
246
  );
@@ -232,6 +265,24 @@ export async function runDaemon(): Promise<void> {
232
265
  );
233
266
  }
234
267
 
268
+ // Sweep dirty conversations whose tail messages are already past the
269
+ // idle delay — they should have been reduced while the daemon was down.
270
+ // Enqueue immediate reducer jobs so the memory worker picks them up.
271
+ try {
272
+ const sweepCount = sweepStaleReducerJobs();
273
+ if (sweepCount > 0) {
274
+ log.info(
275
+ { sweepCount },
276
+ `Enqueued reducer jobs for ${sweepCount} stale dirty conversation(s)`,
277
+ );
278
+ }
279
+ } catch (err) {
280
+ log.warn(
281
+ { err },
282
+ "Startup sweep for stale reducer jobs failed — continuing startup",
283
+ );
284
+ }
285
+
235
286
  // Ensure a vellum guardian binding exists so the identity system works
236
287
  // without requiring a manual bootstrap step.
237
288
  try {
@@ -258,7 +258,6 @@ export interface ModelInfo {
258
258
  apiKeyUrl?: string;
259
259
  apiKeyPlaceholder?: string;
260
260
  }>;
261
- maskedKeys?: Record<string, string>;
262
261
  }
263
262
 
264
263
  export interface HistoryResponseToolCall {
@@ -1262,7 +1262,7 @@ export class DaemonServer {
1262
1262
  const appId = surfaceId.slice(appOpenPrefix.length);
1263
1263
  for (const c of this.conversations.values()) {
1264
1264
  for (const [, state] of c.surfaceState.entries()) {
1265
- const data = state.data as Record<string, unknown>;
1265
+ const data = state.data as unknown as Record<string, unknown>;
1266
1266
  if (data?.appId === appId) {
1267
1267
  // Register this surfaceId so subsequent lookups are O(1)
1268
1268
  c.surfaceState.set(surfaceId, state);
@@ -1,4 +1,4 @@
1
- import { and, eq, lte, or } from "drizzle-orm";
1
+ import { and, desc, eq, lte, or } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import { getDb } from "../memory/db.js";
@@ -176,3 +176,49 @@ export function markNudged(id: string): FollowUp {
176
176
 
177
177
  return getFollowUp(id)!;
178
178
  }
179
+
180
+ // ── Brief Helpers ──────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Lightweight projection of a follow-up used by the brief compiler.
184
+ */
185
+ export interface BriefFollowUp {
186
+ id: string;
187
+ channel: string;
188
+ conversationId: string;
189
+ contactId: string | null;
190
+ expectedResponseBy: number | null;
191
+ status: FollowUpStatus;
192
+ updatedAt: number;
193
+ }
194
+
195
+ /**
196
+ * Return pending and overdue follow-ups for the brief compiler.
197
+ * Ordered by expectedResponseBy ascending (most urgent first).
198
+ */
199
+ export function getPendingAndOverdueFollowUps(): BriefFollowUp[] {
200
+ const db = getDb();
201
+
202
+ const rows = db
203
+ .select({
204
+ id: followups.id,
205
+ channel: followups.channel,
206
+ conversationId: followups.conversationId,
207
+ contactId: followups.contactId,
208
+ expectedResponseBy: followups.expectedResponseBy,
209
+ status: followups.status,
210
+ updatedAt: followups.updatedAt,
211
+ })
212
+ .from(followups)
213
+ .where(
214
+ or(
215
+ eq(followups.status, "pending"),
216
+ eq(followups.status, "overdue"),
217
+ eq(followups.status, "nudged"),
218
+ ),
219
+ )
220
+ .orderBy(desc(followups.expectedResponseBy))
221
+ .all();
222
+
223
+ return rows as BriefFollowUp[];
224
+ }