@vellumai/assistant 0.5.4 → 0.5.6

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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. package/src/memory/schema/memory-brief.ts +0 -55
@@ -1,516 +0,0 @@
1
- /**
2
- * Archive recall: retrieval layer over the simplified memory archive tables
3
- * (memory_observations, memory_chunks, memory_episodes).
4
- *
5
- * Two retrieval paths:
6
- *
7
- * 1. **Prefetch** — lightweight query run on every turn. Fetches recent
8
- * episodes and observations to detect whether the user's turn references
9
- * past context that the archive can answer.
10
- *
11
- * 2. **Deeper recall** — triggered when the prefetch surfaces strong hits,
12
- * or when the user's turn contains explicit past-reference or
13
- * analogy/debugging-shaped language. Queries all three archive tables
14
- * and returns up to 3 source-linked bullets wrapped in
15
- * `<supporting_recall>`.
16
- *
17
- * Empty results produce no output (no `<supporting_recall>` tag).
18
- */
19
-
20
- import { and, desc, eq, like, or, sql } from "drizzle-orm";
21
-
22
- import { getLogger } from "../util/logger.js";
23
- import { getDb } from "./db.js";
24
- import { memoryChunks, memoryEpisodes, memoryObservations } from "./schema.js";
25
-
26
- const log = getLogger("memory-archive-recall");
27
-
28
- // ── Pattern matchers ────────────────────────────────────────────────
29
-
30
- /**
31
- * Phrases that signal the user is explicitly referencing a past
32
- * interaction, artifact, or fact the assistant should recall.
33
- */
34
- const PAST_REFERENCE_PATTERNS = [
35
- /\b(?:remember|recall|mentioned|talked about|discussed|said|told you|last time|earlier|before|previously)\b/i,
36
- /\bwhat (?:did|was|were)\b.*\b(?:we|i|you)\b/i,
37
- /\bdo you (?:know|remember)\b/i,
38
- ];
39
-
40
- /**
41
- * Phrases that signal an analogy or debugging-shaped query where
42
- * historical context would be especially valuable.
43
- */
44
- const ANALOGY_DEBUG_PATTERNS = [
45
- /\b(?:similar to|like when|same (?:issue|problem|error|bug)|happened before|recurring|déjà vu)\b/i,
46
- /\b(?:last time.*(?:fix|solve|debug|resolve))\b/i,
47
- /\b(?:keep (?:getting|seeing|hitting)|again|keeps happening)\b/i,
48
- ];
49
-
50
- // ── Turn classification ─────────────────────────────────────────────
51
-
52
- export type RecallTrigger =
53
- | "explicit_past_reference"
54
- | "analogy_debug"
55
- | "strong_prefetch"
56
- | "none";
57
-
58
- /**
59
- * Classify whether a user turn warrants deeper archive recall.
60
- */
61
- export function classifyRecallTrigger(
62
- userText: string,
63
- prefetchHitCount: number,
64
- ): RecallTrigger {
65
- if (PAST_REFERENCE_PATTERNS.some((p) => p.test(userText))) {
66
- return "explicit_past_reference";
67
- }
68
- if (ANALOGY_DEBUG_PATTERNS.some((p) => p.test(userText))) {
69
- return "analogy_debug";
70
- }
71
- if (prefetchHitCount >= 2) {
72
- return "strong_prefetch";
73
- }
74
- return "none";
75
- }
76
-
77
- // ── Prefetch ────────────────────────────────────────────────────────
78
-
79
- /** A lightweight prefetch hit from the archive tables. */
80
- export interface PrefetchHit {
81
- source: "episode" | "observation" | "chunk";
82
- id: string;
83
- content: string;
84
- createdAt: number;
85
- conversationId?: string | null;
86
- }
87
-
88
- /**
89
- * Lightweight prefetch over recent episodes and observations for the
90
- * given scope. Returns up to `limit` hits ordered by recency. This is
91
- * cheap enough to run on every turn.
92
- */
93
- export function prefetchArchive(
94
- scopeId: string,
95
- userText: string,
96
- limit: number = 10,
97
- ): PrefetchHit[] {
98
- const db = getDb();
99
- const hits: PrefetchHit[] = [];
100
-
101
- // Extract meaningful keywords from user text (words >= 4 chars)
102
- const keywords = extractKeywords(userText);
103
- if (keywords.length === 0) return hits;
104
-
105
- try {
106
- // Query recent episodes whose title or summary contain any keyword
107
- const episodeConditions = keywords.map((kw) =>
108
- or(
109
- like(memoryEpisodes.title, `%${kw}%`),
110
- like(memoryEpisodes.summary, `%${kw}%`),
111
- ),
112
- );
113
-
114
- const episodes = db
115
- .select({
116
- id: memoryEpisodes.id,
117
- title: memoryEpisodes.title,
118
- summary: memoryEpisodes.summary,
119
- createdAt: memoryEpisodes.createdAt,
120
- conversationId: memoryEpisodes.conversationId,
121
- })
122
- .from(memoryEpisodes)
123
- .where(and(eq(memoryEpisodes.scopeId, scopeId), or(...episodeConditions)))
124
- .orderBy(desc(memoryEpisodes.createdAt))
125
- .limit(limit)
126
- .all();
127
-
128
- for (const ep of episodes) {
129
- hits.push({
130
- source: "episode",
131
- id: ep.id,
132
- content: `${ep.title}: ${ep.summary}`,
133
- createdAt: ep.createdAt,
134
- conversationId: ep.conversationId,
135
- });
136
- }
137
-
138
- // Query recent observations whose content matches any keyword
139
- const observationConditions = keywords.map((kw) =>
140
- like(memoryObservations.content, `%${kw}%`),
141
- );
142
-
143
- const observations = db
144
- .select({
145
- id: memoryObservations.id,
146
- content: memoryObservations.content,
147
- createdAt: memoryObservations.createdAt,
148
- conversationId: memoryObservations.conversationId,
149
- })
150
- .from(memoryObservations)
151
- .where(
152
- and(
153
- eq(memoryObservations.scopeId, scopeId),
154
- or(...observationConditions),
155
- ),
156
- )
157
- .orderBy(desc(memoryObservations.createdAt))
158
- .limit(limit)
159
- .all();
160
-
161
- for (const obs of observations) {
162
- hits.push({
163
- source: "observation",
164
- id: obs.id,
165
- content: obs.content,
166
- createdAt: obs.createdAt,
167
- conversationId: obs.conversationId,
168
- });
169
- }
170
- } catch (err) {
171
- log.warn({ err }, "Archive prefetch failed");
172
- }
173
-
174
- // Sort all hits by recency and cap at limit
175
- hits.sort((a, b) => b.createdAt - a.createdAt);
176
- return hits.slice(0, limit);
177
- }
178
-
179
- // ── Deeper recall ───────────────────────────────────────────────────
180
-
181
- /** A source-linked recall bullet for injection. */
182
- export interface RecallBullet {
183
- /** Human-readable one-line summary. */
184
- text: string;
185
- /** Which archive table sourced this bullet. */
186
- source: "episode" | "observation" | "chunk";
187
- /** Row ID in the source table. */
188
- sourceId: string;
189
- /** Optional conversation title for provenance. */
190
- conversationTitle?: string | null;
191
- }
192
-
193
- export interface ArchiveRecallResult {
194
- /** The recall trigger that activated deeper recall (or "none"). */
195
- trigger: RecallTrigger;
196
- /** Up to 3 source-linked bullets. Empty when no relevant results. */
197
- bullets: RecallBullet[];
198
- /** Rendered `<supporting_recall>` block, or empty string. */
199
- text: string;
200
- /** Number of prefetch hits examined. */
201
- prefetchHitCount: number;
202
- }
203
-
204
- /**
205
- * Run archive recall for a user turn.
206
- *
207
- * 1. Runs a lightweight prefetch over episodes and observations.
208
- * 2. Classifies whether deeper recall is warranted.
209
- * 3. If triggered, queries all three archive tables and assembles
210
- * up to 3 source-linked bullets.
211
- * 4. Returns rendered `<supporting_recall>` or empty string.
212
- */
213
- export function buildArchiveRecall(
214
- scopeId: string,
215
- userText: string,
216
- ): ArchiveRecallResult {
217
- // Step 1: prefetch
218
- const prefetchHits = prefetchArchive(scopeId, userText);
219
- const prefetchHitCount = prefetchHits.length;
220
-
221
- // Step 2: classify
222
- const trigger = classifyRecallTrigger(userText, prefetchHitCount);
223
-
224
- if (trigger === "none") {
225
- return {
226
- trigger,
227
- bullets: [],
228
- text: "",
229
- prefetchHitCount,
230
- };
231
- }
232
-
233
- // Step 3: deeper recall
234
- const bullets = deeperRecall(scopeId, userText, prefetchHits);
235
-
236
- // Step 4: render
237
- const text = renderSupportingRecall(bullets);
238
-
239
- log.debug(
240
- {
241
- trigger,
242
- prefetchHitCount,
243
- bulletCount: bullets.length,
244
- },
245
- "Archive recall completed",
246
- );
247
-
248
- return {
249
- trigger,
250
- bullets,
251
- text,
252
- prefetchHitCount,
253
- };
254
- }
255
-
256
- // ── Deeper recall implementation ────────────────────────────────────
257
-
258
- /**
259
- * Query all three archive tables for the user's text and assemble
260
- * up to 3 source-linked bullets. Prioritizes episodes (narrative
261
- * summaries) over observations (raw facts) over chunks (indexed text).
262
- */
263
- function deeperRecall(
264
- scopeId: string,
265
- userText: string,
266
- prefetchHits: PrefetchHit[],
267
- ): RecallBullet[] {
268
- const db = getDb();
269
- const keywords = extractKeywords(userText);
270
- if (keywords.length === 0) return [];
271
-
272
- const bullets: RecallBullet[] = [];
273
- const seenContent = new Set<string>();
274
- const MAX_BULLETS = 3;
275
-
276
- try {
277
- // --- Episodes: highest signal (narrative summaries) ---
278
- const episodeConditions = keywords.map((kw) =>
279
- or(
280
- like(memoryEpisodes.title, `%${kw}%`),
281
- like(memoryEpisodes.summary, `%${kw}%`),
282
- ),
283
- );
284
-
285
- const episodes = db
286
- .select({
287
- id: memoryEpisodes.id,
288
- title: memoryEpisodes.title,
289
- summary: memoryEpisodes.summary,
290
- conversationId: memoryEpisodes.conversationId,
291
- })
292
- .from(memoryEpisodes)
293
- .where(and(eq(memoryEpisodes.scopeId, scopeId), or(...episodeConditions)))
294
- .orderBy(desc(memoryEpisodes.createdAt))
295
- .limit(MAX_BULLETS)
296
- .all();
297
-
298
- for (const ep of episodes) {
299
- if (bullets.length >= MAX_BULLETS) break;
300
- const normalized = normalizeForDedup(ep.summary);
301
- if (seenContent.has(normalized)) continue;
302
- seenContent.add(normalized);
303
-
304
- const convTitle = lookupConversationTitle(db, ep.conversationId);
305
- bullets.push({
306
- text: `${ep.title} — ${truncate(ep.summary, 200)}`,
307
- source: "episode",
308
- sourceId: ep.id,
309
- conversationTitle: convTitle,
310
- });
311
- }
312
-
313
- // --- Observations: raw factual statements ---
314
- if (bullets.length < MAX_BULLETS) {
315
- const observationConditions = keywords.map((kw) =>
316
- like(memoryObservations.content, `%${kw}%`),
317
- );
318
-
319
- const observations = db
320
- .select({
321
- id: memoryObservations.id,
322
- content: memoryObservations.content,
323
- conversationId: memoryObservations.conversationId,
324
- })
325
- .from(memoryObservations)
326
- .where(
327
- and(
328
- eq(memoryObservations.scopeId, scopeId),
329
- or(...observationConditions),
330
- ),
331
- )
332
- .orderBy(desc(memoryObservations.createdAt))
333
- .limit(MAX_BULLETS)
334
- .all();
335
-
336
- for (const obs of observations) {
337
- if (bullets.length >= MAX_BULLETS) break;
338
- const normalized = normalizeForDedup(obs.content);
339
- if (seenContent.has(normalized)) continue;
340
- seenContent.add(normalized);
341
-
342
- const convTitle = lookupConversationTitle(db, obs.conversationId);
343
- bullets.push({
344
- text: truncate(obs.content, 200),
345
- source: "observation",
346
- sourceId: obs.id,
347
- conversationTitle: convTitle,
348
- });
349
- }
350
- }
351
-
352
- // --- Chunks: indexed text fragments ---
353
- if (bullets.length < MAX_BULLETS) {
354
- const chunkConditions = keywords.map((kw) =>
355
- like(memoryChunks.content, `%${kw}%`),
356
- );
357
-
358
- const chunks = db
359
- .select({
360
- id: memoryChunks.id,
361
- content: memoryChunks.content,
362
- observationId: memoryChunks.observationId,
363
- })
364
- .from(memoryChunks)
365
- .where(and(eq(memoryChunks.scopeId, scopeId), or(...chunkConditions)))
366
- .orderBy(desc(memoryChunks.createdAt))
367
- .limit(MAX_BULLETS)
368
- .all();
369
-
370
- for (const chunk of chunks) {
371
- if (bullets.length >= MAX_BULLETS) break;
372
- const normalized = normalizeForDedup(chunk.content);
373
- if (seenContent.has(normalized)) continue;
374
- seenContent.add(normalized);
375
-
376
- // Look up the observation's conversationId for provenance
377
- const obs = db
378
- .select({ conversationId: memoryObservations.conversationId })
379
- .from(memoryObservations)
380
- .where(eq(memoryObservations.id, chunk.observationId))
381
- .get();
382
-
383
- const convTitle = obs
384
- ? lookupConversationTitle(db, obs.conversationId)
385
- : null;
386
-
387
- bullets.push({
388
- text: truncate(chunk.content, 200),
389
- source: "chunk",
390
- sourceId: chunk.id,
391
- conversationTitle: convTitle,
392
- });
393
- }
394
- }
395
- } catch (err) {
396
- log.warn({ err }, "Deeper archive recall failed");
397
- }
398
-
399
- // Also incorporate prefetch hits that weren't already captured
400
- for (const hit of prefetchHits) {
401
- if (bullets.length >= MAX_BULLETS) break;
402
- const normalized = normalizeForDedup(hit.content);
403
- if (seenContent.has(normalized)) continue;
404
- seenContent.add(normalized);
405
-
406
- bullets.push({
407
- text: truncate(hit.content, 200),
408
- source: hit.source,
409
- sourceId: hit.id,
410
- });
411
- }
412
-
413
- return bullets.slice(0, MAX_BULLETS);
414
- }
415
-
416
- // ── Rendering ───────────────────────────────────────────────────────
417
-
418
- /**
419
- * Render recall bullets into `<supporting_recall>` XML block.
420
- * Returns empty string when there are no bullets.
421
- */
422
- export function renderSupportingRecall(bullets: RecallBullet[]): string {
423
- if (bullets.length === 0) return "";
424
-
425
- const lines = bullets.map((b) => {
426
- const provenance = b.conversationTitle
427
- ? ` (from: ${b.conversationTitle})`
428
- : "";
429
- return `- ${b.text}${provenance}`;
430
- });
431
-
432
- return `<supporting_recall>\n${lines.join("\n")}\n</supporting_recall>`;
433
- }
434
-
435
- // ── Helpers ─────────────────────────────────────────────────────────
436
-
437
- /**
438
- * Extract meaningful keywords from user text for LIKE-based matching.
439
- * Filters out short words (< 4 chars) and common stop words.
440
- */
441
- export function extractKeywords(text: string): string[] {
442
- const STOP_WORDS = new Set([
443
- "about",
444
- "also",
445
- "been",
446
- "could",
447
- "does",
448
- "from",
449
- "have",
450
- "into",
451
- "just",
452
- "know",
453
- "like",
454
- "make",
455
- "more",
456
- "much",
457
- "only",
458
- "over",
459
- "said",
460
- "some",
461
- "than",
462
- "that",
463
- "them",
464
- "then",
465
- "they",
466
- "this",
467
- "very",
468
- "want",
469
- "were",
470
- "what",
471
- "when",
472
- "will",
473
- "with",
474
- "your",
475
- ]);
476
-
477
- const words = text
478
- .toLowerCase()
479
- .replace(/[^\w\s]/g, " ")
480
- .split(/\s+/)
481
- .filter((w) => w.length >= 4 && !STOP_WORDS.has(w));
482
-
483
- // Deduplicate while preserving order
484
- return [...new Set(words)];
485
- }
486
-
487
- /**
488
- * Look up a conversation's title for provenance display.
489
- */
490
- function lookupConversationTitle(
491
- db: ReturnType<typeof getDb>,
492
- conversationId: string,
493
- ): string | null {
494
- try {
495
- const row = db
496
- .select({ title: sql<string | null>`title` })
497
- .from(sql`conversations`)
498
- .where(sql`id = ${conversationId}`)
499
- .get();
500
- return row?.title ?? null;
501
- } catch {
502
- return null;
503
- }
504
- }
505
-
506
- function truncate(text: string, max: number): string {
507
- if (text.length <= max) return text;
508
- return `${text.slice(0, max - 3)}...`;
509
- }
510
-
511
- /**
512
- * Normalize text for content deduplication across sources.
513
- */
514
- function normalizeForDedup(text: string): string {
515
- return text.toLowerCase().replace(/\s+/g, " ").trim();
516
- }