@vellumai/assistant 0.5.7 → 0.5.9

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 (205) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/eslint.config.mjs +0 -31
  5. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  9. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/approval-cascade.test.ts +0 -1
  12. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  13. package/src/__tests__/call-controller.test.ts +0 -1
  14. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  15. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  16. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  17. package/src/__tests__/config-schema.test.ts +2 -0
  18. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  19. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  20. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  21. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  22. package/src/__tests__/conversation-error.test.ts +15 -1
  23. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  24. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  26. package/src/__tests__/conversation-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
  28. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  29. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  30. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  31. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  32. package/src/__tests__/credential-execution-client.test.ts +5 -2
  33. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  34. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  35. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  37. package/src/__tests__/credentials-cli.test.ts +4 -3
  38. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  39. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  40. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  41. package/src/__tests__/journal-context.test.ts +335 -0
  42. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  43. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  44. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  45. package/src/__tests__/memory-regressions.test.ts +408 -363
  46. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  47. package/src/__tests__/non-member-access-request.test.ts +2 -2
  48. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  49. package/src/__tests__/oauth-cli.test.ts +5 -1
  50. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  51. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  52. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  53. package/src/__tests__/relay-server.test.ts +1 -2
  54. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  55. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  56. package/src/__tests__/secure-keys.test.ts +18 -15
  57. package/src/__tests__/skill-memory.test.ts +17 -3
  58. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  59. package/src/__tests__/stt-hints.test.ts +437 -0
  60. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  61. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  62. package/src/__tests__/voice-quality.test.ts +58 -0
  63. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  64. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  65. package/src/acp/agent-process.ts +9 -1
  66. package/src/agent/loop.ts +1 -1
  67. package/src/approvals/guardian-request-resolvers.ts +164 -38
  68. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  69. package/src/calls/call-controller.ts +9 -5
  70. package/src/calls/fish-audio-client.ts +26 -14
  71. package/src/calls/stt-hints.ts +189 -0
  72. package/src/calls/tts-text-sanitizer.ts +61 -0
  73. package/src/calls/twilio-routes.ts +32 -4
  74. package/src/calls/voice-quality.ts +15 -3
  75. package/src/calls/voice-session-bridge.ts +1 -0
  76. package/src/cli/commands/avatar.ts +2 -2
  77. package/src/cli/commands/credentials.ts +110 -94
  78. package/src/cli/commands/doctor.ts +2 -2
  79. package/src/cli/commands/keys.ts +7 -7
  80. package/src/cli/commands/memory.ts +1 -1
  81. package/src/cli/commands/oauth/connections.ts +11 -29
  82. package/src/cli/commands/oauth/platform.ts +389 -43
  83. package/src/cli/lib/daemon-credential-client.ts +284 -0
  84. package/src/cli.ts +1 -1
  85. package/src/config/bundled-skills/AGENTS.md +34 -0
  86. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  87. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  88. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  89. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  90. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  91. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  92. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  93. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  94. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  95. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  96. package/src/config/bundled-tool-registry.ts +4 -0
  97. package/src/config/defaults.ts +0 -2
  98. package/src/config/env-registry.ts +4 -4
  99. package/src/config/env.ts +14 -1
  100. package/src/config/feature-flag-registry.json +1 -1
  101. package/src/config/loader.ts +8 -11
  102. package/src/config/schema.ts +5 -16
  103. package/src/config/schemas/calls.ts +17 -0
  104. package/src/config/schemas/inference.ts +2 -2
  105. package/src/config/schemas/journal.ts +16 -0
  106. package/src/config/schemas/memory-processing.ts +2 -2
  107. package/src/config/types.ts +1 -0
  108. package/src/contacts/contact-store.ts +2 -2
  109. package/src/credential-execution/executable-discovery.ts +1 -1
  110. package/src/credential-execution/startup-timeout.ts +36 -0
  111. package/src/daemon/approval-generators.ts +3 -9
  112. package/src/daemon/conversation-agent-loop.ts +6 -0
  113. package/src/daemon/conversation-error.ts +13 -1
  114. package/src/daemon/conversation-memory.ts +1 -2
  115. package/src/daemon/conversation-process.ts +18 -1
  116. package/src/daemon/conversation-runtime-assembly.ts +61 -1
  117. package/src/daemon/conversation-surfaces.ts +30 -1
  118. package/src/daemon/conversation.ts +20 -9
  119. package/src/daemon/guardian-action-generators.ts +3 -9
  120. package/src/daemon/lifecycle.ts +18 -11
  121. package/src/daemon/message-types/conversations.ts +1 -0
  122. package/src/daemon/server.ts +2 -3
  123. package/src/memory/app-store.ts +31 -0
  124. package/src/memory/db-init.ts +4 -0
  125. package/src/memory/indexer.ts +19 -10
  126. package/src/memory/items-extractor.ts +315 -322
  127. package/src/memory/job-handlers/summarization.ts +26 -16
  128. package/src/memory/jobs-store.ts +33 -1
  129. package/src/memory/journal-memory.ts +214 -0
  130. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  131. package/src/memory/migrations/index.ts +1 -0
  132. package/src/memory/migrations/registry.ts +8 -0
  133. package/src/memory/retriever.test.ts +37 -25
  134. package/src/memory/retriever.ts +24 -49
  135. package/src/memory/schema/memory-core.ts +2 -0
  136. package/src/memory/search/formatting.ts +7 -44
  137. package/src/memory/search/staleness.ts +4 -0
  138. package/src/memory/search/tier-classifier.ts +10 -2
  139. package/src/memory/search/types.ts +2 -5
  140. package/src/memory/task-memory-cleanup.ts +4 -3
  141. package/src/notifications/adapters/slack.ts +168 -6
  142. package/src/notifications/broadcaster.ts +1 -0
  143. package/src/notifications/copy-composer.ts +59 -2
  144. package/src/notifications/signal.ts +2 -0
  145. package/src/notifications/types.ts +2 -0
  146. package/src/prompts/journal-context.ts +133 -0
  147. package/src/prompts/persona-resolver.ts +80 -24
  148. package/src/prompts/system-prompt.ts +30 -0
  149. package/src/prompts/templates/NOW.md +26 -0
  150. package/src/prompts/templates/SOUL.md +20 -0
  151. package/src/prompts/update-bulletin-format.ts +0 -2
  152. package/src/providers/provider-send-message.ts +3 -32
  153. package/src/providers/registry.ts +2 -139
  154. package/src/providers/types.ts +1 -1
  155. package/src/runtime/access-request-helper.ts +4 -0
  156. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  157. package/src/runtime/auth/route-policy.ts +2 -0
  158. package/src/runtime/gateway-client.ts +47 -4
  159. package/src/runtime/guardian-decision-types.ts +45 -4
  160. package/src/runtime/http-server.ts +5 -2
  161. package/src/runtime/routes/access-request-decision.ts +2 -2
  162. package/src/runtime/routes/app-management-routes.ts +2 -1
  163. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  164. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  165. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  166. package/src/runtime/routes/debug-routes.ts +12 -9
  167. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  168. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  169. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  170. package/src/runtime/routes/identity-routes.ts +1 -1
  171. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  172. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  173. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  174. package/src/runtime/routes/integrations/twilio.ts +52 -10
  175. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  176. package/src/runtime/routes/memory-item-routes.ts +25 -11
  177. package/src/runtime/routes/secret-routes.ts +141 -10
  178. package/src/runtime/routes/tts-routes.ts +11 -1
  179. package/src/security/ces-credential-client.ts +18 -9
  180. package/src/security/ces-rpc-credential-backend.ts +4 -3
  181. package/src/security/credential-backend.ts +10 -4
  182. package/src/security/secure-keys.ts +21 -4
  183. package/src/skills/catalog-install.ts +4 -36
  184. package/src/skills/inline-command-expansions.ts +7 -7
  185. package/src/skills/skill-memory.ts +1 -0
  186. package/src/subagent/manager.ts +2 -5
  187. package/src/tools/acp/spawn.ts +78 -1
  188. package/src/tools/credentials/vault.ts +5 -3
  189. package/src/tools/memory/definitions.ts +3 -2
  190. package/src/tools/memory/handlers.ts +10 -7
  191. package/src/tools/sensitive-output-placeholders.ts +2 -2
  192. package/src/tools/terminal/safe-env.ts +1 -0
  193. package/src/util/browser.ts +15 -0
  194. package/src/util/platform.ts +1 -1
  195. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  196. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  197. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  198. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  199. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  200. package/src/workspace/migrations/registry.ts +4 -0
  201. package/src/workspace/provider-commit-message-generator.ts +12 -21
  202. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  203. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  204. package/src/memory/search/lexical.ts +0 -48
  205. package/src/providers/failover.ts +0 -186
@@ -19,18 +19,25 @@ import { memorySegments, memorySummaries } from "../schema.js";
19
19
  const log = getLogger("memory-jobs-worker");
20
20
 
21
21
  const SUMMARY_LLM_TIMEOUT_MS = 20_000;
22
- const SUMMARY_MAX_TOKENS = 800;
22
+ const SUMMARY_MAX_TOKENS = 500;
23
23
 
24
24
  const CONVERSATION_SUMMARY_SYSTEM_PROMPT = [
25
- "You are a memory summarization system. Your job is to produce a compact, information-dense summary of a conversation.",
25
+ "You compress conversation transcripts into compact summaries for semantic search and memory retrieval.",
26
+ "Focus on durable facts, not transient discussion.",
27
+ "Preserve: goals, decisions, constraints, preferences, names, technical details, actions taken.",
28
+ "Remove: filler, pleasantries, tool invocation details, transient status updates.",
26
29
  "",
27
- "Guidelines:",
28
- "- Focus on key facts, decisions, user preferences, and actionable information.",
29
- "- Preserve concrete details: names, file paths, tool choices, technical decisions, constraints.",
30
- "- Remove filler, pleasantries, and transient discussion that has no lasting value.",
31
- "- Use concise bullet points grouped by topic.",
32
- "- Target 400-600 tokens. Be dense but readable.",
33
- "- If updating an existing summary with new data, merge new information and remove anything that was superseded.",
30
+ "Return concise markdown:",
31
+ "## Topic",
32
+ "One-line description of what the conversation is about.",
33
+ "## Key Facts",
34
+ "Bullet points of concrete facts, names, decisions, preferences.",
35
+ "## Outcomes",
36
+ "What was decided, resolved, or accomplished.",
37
+ "## Open Items",
38
+ "Unresolved questions, pending tasks, or follow-ups (omit section if none).",
39
+ "",
40
+ "Target 200-400 tokens. Be dense.",
34
41
  ].join("\n");
35
42
 
36
43
  export async function buildConversationSummaryJob(
@@ -62,9 +69,8 @@ export async function buildConversationSummaryJob(
62
69
 
63
70
  // Build segment text for LLM input (chronological order)
64
71
  const segmentTexts = rows
65
- .slice(0, 30)
66
72
  .reverse()
67
- .map((row) => `[${row.role}] ${truncate(row.text, 400)}`)
73
+ .map((row) => `[${row.role}] ${truncate(row.text, 600)}`)
68
74
  .join("\n\n");
69
75
 
70
76
  const summaryText = await summarizeWithLLM(
@@ -208,14 +214,18 @@ async function summarizeWithLLM(
208
214
  }
209
215
 
210
216
  function buildFallbackSummary(
211
- _existingSummary: string | null,
217
+ existingSummary: string | null,
212
218
  newContent: string,
213
219
  label: string,
214
220
  ): string {
215
221
  const lines = newContent.split("\n").filter((l) => l.trim().length > 0);
216
- const snippets = lines
217
- .slice(0, 20)
218
- .map((l) => `- ${truncate(l.trim(), 180)}`);
219
- const parts: string[] = [`${label} summary`, "", ...snippets];
222
+ if (lines.length === 0) return existingSummary ?? `${label} (no content)`;
223
+ const head = lines.slice(0, 3).map((l) => `- ${truncate(l.trim(), 200)}`);
224
+ const tail =
225
+ lines.length > 6
226
+ ? lines.slice(-3).map((l) => `- ${truncate(l.trim(), 200)}`)
227
+ : [];
228
+ const parts = [`${label} summary`, "", ...head];
229
+ if (tail.length > 0) parts.push("", "...", "", ...tail);
220
230
  return parts.join("\n");
221
231
  }
@@ -1,4 +1,4 @@
1
- import { and, asc, eq, inArray, lte, notInArray } from "drizzle-orm";
1
+ import { and, asc, eq, inArray, lte, notInArray, sql } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import { getLogger } from "../util/logger.js";
@@ -86,6 +86,38 @@ export function enqueueMemoryJob(
86
86
  return id;
87
87
  }
88
88
 
89
+ /**
90
+ * Upsert a debounced job: if a pending job of the same type and conversation
91
+ * already exists, push its `runAfter` forward instead of creating a duplicate.
92
+ * This prevents rapid message indexing from spawning redundant jobs.
93
+ */
94
+ export function upsertDebouncedJob(
95
+ type: MemoryJobType,
96
+ payload: { conversationId: string },
97
+ runAfter: number,
98
+ ): void {
99
+ const db = getDb();
100
+ const existing = db
101
+ .select()
102
+ .from(memoryJobs)
103
+ .where(
104
+ and(
105
+ eq(memoryJobs.type, type),
106
+ eq(memoryJobs.status, "pending"),
107
+ sql`json_extract(${memoryJobs.payload}, '$.conversationId') = ${payload.conversationId}`,
108
+ ),
109
+ )
110
+ .get();
111
+ if (existing) {
112
+ db.update(memoryJobs)
113
+ .set({ runAfter, updatedAt: Date.now() })
114
+ .where(eq(memoryJobs.id, existing.id))
115
+ .run();
116
+ } else {
117
+ enqueueMemoryJob(type, payload, runAfter);
118
+ }
119
+ }
120
+
89
121
  export function enqueueCleanupStaleSupersededItemsJob(
90
122
  retentionMs?: number,
91
123
  ): string {
@@ -0,0 +1,214 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { and, eq } from "drizzle-orm";
5
+ import { v4 as uuid } from "uuid";
6
+
7
+ import { getLogger } from "../util/logger.js";
8
+ import { getWorkspaceDir } from "../util/platform.js";
9
+ import { type DrizzleDb, getDb } from "./db.js";
10
+ import { computeMemoryFingerprint } from "./fingerprint.js";
11
+ import { enqueueMemoryJob } from "./jobs-store.js";
12
+ import { memoryItems, memoryItemSources } from "./schema.js";
13
+
14
+ const log = getLogger("memory-journal");
15
+
16
+ /**
17
+ * Process a single journal `.md` file: read content, derive subject, compute
18
+ * fingerprint, upsert to DB, and enqueue an embed job.
19
+ *
20
+ * Returns `true` if a new memory item was inserted, `false` if it already
21
+ * existed (or was skipped).
22
+ */
23
+ function upsertSingleJournalFile(
24
+ filepath: string,
25
+ filename: string,
26
+ messageCreatedAt: number,
27
+ scopeId: string,
28
+ messageId: string,
29
+ db: DrizzleDb,
30
+ ): boolean {
31
+ const content = readFileSync(filepath, "utf-8");
32
+
33
+ // Derive subject from filename:
34
+ // strip .md extension, strip leading date prefix, replace hyphens with spaces, capitalize first letter
35
+ const basename = filename.replace(/\.md$/, "");
36
+ const withoutDate = basename.replace(/^\d{4}-\d{2}-\d{2}-?/, "");
37
+ const withSpaces = withoutDate.replace(/-/g, " ");
38
+ const subject =
39
+ withSpaces.length > 0
40
+ ? withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1)
41
+ : basename;
42
+
43
+ const fingerprint = computeMemoryFingerprint(
44
+ scopeId,
45
+ "journal",
46
+ subject,
47
+ content,
48
+ );
49
+
50
+ const existing = db
51
+ .select()
52
+ .from(memoryItems)
53
+ .where(
54
+ and(
55
+ eq(memoryItems.fingerprint, fingerprint),
56
+ eq(memoryItems.scopeId, scopeId),
57
+ ),
58
+ )
59
+ .get();
60
+
61
+ let memoryItemId: string;
62
+ let inserted = false;
63
+
64
+ if (existing) {
65
+ memoryItemId = existing.id;
66
+ db.update(memoryItems)
67
+ .set({
68
+ lastSeenAt: messageCreatedAt,
69
+ status: "active",
70
+ })
71
+ .where(eq(memoryItems.id, existing.id))
72
+ .run();
73
+ } else {
74
+ memoryItemId = uuid();
75
+ db.insert(memoryItems)
76
+ .values({
77
+ id: memoryItemId,
78
+ kind: "journal",
79
+ subject,
80
+ statement: content,
81
+ status: "active",
82
+ confidence: 0.95,
83
+ importance: 0.8,
84
+ fingerprint,
85
+ sourceType: "extraction",
86
+ sourceMessageRole: "assistant",
87
+ verificationState: "assistant_inferred",
88
+ scopeId,
89
+ firstSeenAt: messageCreatedAt,
90
+ lastSeenAt: messageCreatedAt,
91
+ lastUsedAt: null,
92
+ supersedes: null,
93
+ overrideConfidence: null,
94
+ })
95
+ .run();
96
+ inserted = true;
97
+ }
98
+
99
+ db.insert(memoryItemSources)
100
+ .values({
101
+ memoryItemId,
102
+ messageId,
103
+ evidence: content,
104
+ createdAt: Date.now(),
105
+ })
106
+ .onConflictDoNothing()
107
+ .run();
108
+
109
+ enqueueMemoryJob("embed_item", { itemId: memoryItemId });
110
+
111
+ return inserted;
112
+ }
113
+
114
+ /**
115
+ * Scan the journal directory for `.md` files created during (or after) the
116
+ * given message timestamp and upsert them as journal memory items with the
117
+ * raw, unedited file content as the `statement`.
118
+ *
119
+ * Also scans immediate subdirectories (e.g. per-user folders like
120
+ * `journal/sidd/`) so that user-scoped journal entries are indexed alongside
121
+ * root-level files.
122
+ *
123
+ * This bypasses the LLM extraction layer entirely — journal memories are
124
+ * stored verbatim so they are never summarised or rewritten.
125
+ *
126
+ * Returns the number of newly inserted items.
127
+ */
128
+ export function upsertJournalMemoriesFromDisk(
129
+ messageCreatedAt: number,
130
+ scopeId: string,
131
+ messageId: string,
132
+ ): number {
133
+ try {
134
+ const journalDir = join(getWorkspaceDir(), "journal");
135
+
136
+ let files: string[];
137
+ try {
138
+ files = readdirSync(journalDir);
139
+ } catch {
140
+ // Directory doesn't exist — no journal entries
141
+ return 0;
142
+ }
143
+
144
+ // Filter for .md files, excluding readme.md (case-insensitive)
145
+ const mdFiles = files.filter(
146
+ (f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md",
147
+ );
148
+
149
+ let upserted = 0;
150
+ const db = getDb();
151
+
152
+ for (const filename of mdFiles) {
153
+ try {
154
+ const filepath = join(journalDir, filename);
155
+ const stat = statSync(filepath);
156
+ if (!stat.isFile()) continue;
157
+
158
+ // Only process files created during or after this message
159
+ if (stat.birthtimeMs < messageCreatedAt) continue;
160
+
161
+ if (upsertSingleJournalFile(filepath, filename, messageCreatedAt, scopeId, messageId, db)) {
162
+ upserted += 1;
163
+ }
164
+ } catch (err) {
165
+ log.warn(
166
+ { filename, err: err instanceof Error ? err.message : String(err) },
167
+ "Failed to process journal file for memory — skipping",
168
+ );
169
+ }
170
+ }
171
+
172
+ // Scan per-user journal subdirectories
173
+ for (const entry of files) {
174
+ try {
175
+ const subdirPath = join(journalDir, entry);
176
+ if (!statSync(subdirPath).isDirectory()) continue;
177
+
178
+ const subFiles = readdirSync(subdirPath).filter(
179
+ (f) => f.endsWith(".md") && f.toLowerCase() !== "readme.md",
180
+ );
181
+
182
+ for (const filename of subFiles) {
183
+ try {
184
+ const filepath = join(subdirPath, filename);
185
+ const stat = statSync(filepath);
186
+ if (!stat.isFile()) continue;
187
+
188
+ // Only process files created during or after this message
189
+ if (stat.birthtimeMs < messageCreatedAt) continue;
190
+
191
+ if (upsertSingleJournalFile(filepath, filename, messageCreatedAt, scopeId, messageId, db)) {
192
+ upserted += 1;
193
+ }
194
+ } catch (err) {
195
+ log.warn(
196
+ { filename, err: err instanceof Error ? err.message : String(err) },
197
+ "Failed to process journal file for memory — skipping",
198
+ );
199
+ }
200
+ }
201
+ } catch {
202
+ // Skip unreadable subdirectories
203
+ }
204
+ }
205
+
206
+ return upserted;
207
+ } catch (err) {
208
+ log.warn(
209
+ { err: err instanceof Error ? err.message : String(err) },
210
+ "Failed to scan journal directory for memories",
211
+ );
212
+ return 0;
213
+ }
214
+ }
@@ -0,0 +1,81 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+ import { tableHasColumn } from "./schema-introspection.js";
4
+ import { withCrashRecovery } from "./validate-migration-state.js";
5
+
6
+ /**
7
+ * Add source_type and source_message_role columns to memory_items.
8
+ *
9
+ * - source_type: "extraction" (default) or "tool" — distinguishes how the
10
+ * memory was created (LLM/pattern extraction vs explicit tool/API save).
11
+ * - source_message_role: the role of the source message (e.g. "user",
12
+ * "assistant") when the item was created via extraction.
13
+ *
14
+ * Backfills:
15
+ * 1. Items with verification_state = "user_confirmed" → source_type = "tool"
16
+ * 2. source_message_role from the earliest source message's role via subquery
17
+ */
18
+ export function migrateAddSourceTypeColumns(database: DrizzleDb): void {
19
+ withCrashRecovery(database, "migration_add_source_type_columns_v1", () => {
20
+ const raw = getSqliteFrom(database);
21
+
22
+ // Add source_type column if it doesn't exist
23
+ if (!tableHasColumn(database, "memory_items", "source_type")) {
24
+ raw.exec(
25
+ /*sql*/ `ALTER TABLE memory_items ADD COLUMN source_type TEXT NOT NULL DEFAULT 'extraction'`,
26
+ );
27
+ }
28
+
29
+ // Add source_message_role column if it doesn't exist
30
+ if (!tableHasColumn(database, "memory_items", "source_message_role")) {
31
+ raw.exec(
32
+ /*sql*/ `ALTER TABLE memory_items ADD COLUMN source_message_role TEXT`,
33
+ );
34
+ }
35
+
36
+ // Backfill source_type = 'tool' for items that were explicitly saved
37
+ raw.exec(
38
+ /*sql*/ `UPDATE memory_items SET source_type = 'tool' WHERE verification_state = 'user_confirmed'`,
39
+ );
40
+
41
+ // Backfill source_message_role from the earliest source message's role.
42
+ // Only backfill where source_message_role is currently NULL and a source
43
+ // message exists.
44
+ raw.exec(/*sql*/ `
45
+ UPDATE memory_items
46
+ SET source_message_role = (
47
+ SELECT m.role
48
+ FROM memory_item_sources mis
49
+ JOIN messages m ON m.id = mis.message_id
50
+ WHERE mis.memory_item_id = memory_items.id
51
+ ORDER BY mis.created_at ASC
52
+ LIMIT 1
53
+ )
54
+ WHERE source_message_role IS NULL
55
+ AND EXISTS (
56
+ SELECT 1
57
+ FROM memory_item_sources mis
58
+ WHERE mis.memory_item_id = memory_items.id
59
+ )
60
+ `);
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Reverse: drop source_type and source_message_role columns.
66
+ *
67
+ * SQLite doesn't support DROP COLUMN on older versions, but modern SQLite
68
+ * (3.35.0+) does. Since Bun bundles a modern SQLite, this is safe.
69
+ */
70
+ export function migrateAddSourceTypeColumnsDown(database: DrizzleDb): void {
71
+ const raw = getSqliteFrom(database);
72
+
73
+ if (tableHasColumn(database, "memory_items", "source_type")) {
74
+ raw.exec(/*sql*/ `ALTER TABLE memory_items DROP COLUMN source_type`);
75
+ }
76
+ if (tableHasColumn(database, "memory_items", "source_message_role")) {
77
+ raw.exec(
78
+ /*sql*/ `ALTER TABLE memory_items DROP COLUMN source_message_role`,
79
+ );
80
+ }
81
+ }
@@ -131,6 +131,7 @@ export { migrateDropSimplifiedMemory } from "./189-drop-simplified-memory.js";
131
131
  export { migrateCallSessionSkipDisclosure } from "./190-call-session-skip-disclosure.js";
132
132
  export { migrateBackfillAudioAttachmentMimeTypes } from "./191-backfill-audio-attachment-mime-types.js";
133
133
  export { migrateContactsUserFileColumn } from "./192-contacts-user-file-column.js";
134
+ export { migrateAddSourceTypeColumns } from "./193-add-source-type-columns.js";
134
135
  export {
135
136
  MIGRATION_REGISTRY,
136
137
  type MigrationRegistryEntry,
@@ -37,6 +37,7 @@ import { migrateDropCapabilityCardStateDown } from "./176-drop-capability-card-s
37
37
  import { migrateBackfillInlineAttachmentsToDiskDown } from "./180-backfill-inline-attachments-to-disk.js";
38
38
  import { migrateRenameThreadStartersCheckpointsDown } from "./181-rename-thread-starters-checkpoints.js";
39
39
  import { migrateBackfillAudioAttachmentMimeTypesDown } from "./191-backfill-audio-attachment-mime-types.js";
40
+ import { migrateAddSourceTypeColumnsDown } from "./193-add-source-type-columns.js";
40
41
 
41
42
  export interface MigrationRegistryEntry {
42
43
  /** The checkpoint key written to memory_checkpoints on completion. */
@@ -325,6 +326,13 @@ export const MIGRATION_REGISTRY: MigrationRegistryEntry[] = [
325
326
  "Backfill correct MIME types for audio attachments stored as application/octet-stream due to missing extension map entries",
326
327
  down: migrateBackfillAudioAttachmentMimeTypesDown,
327
328
  },
329
+ {
330
+ key: "migration_add_source_type_columns_v1",
331
+ version: 37,
332
+ description:
333
+ "Add source_type and source_message_role columns to memory_items with backfill from verification_state and source messages",
334
+ down: migrateAddSourceTypeColumnsDown,
335
+ },
328
336
  ];
329
337
 
330
338
  export function getMaxMigrationVersion(): number {
@@ -337,10 +337,7 @@ describe("Memory Retriever Pipeline", () => {
337
337
  expect(result.tier1Count).toBeDefined();
338
338
  expect(result.tier2Count).toBeDefined();
339
339
  expect(result.hybridSearchMs).toBeDefined();
340
- // Recency search finds raw candidates from this conversation…
341
- expect(result.recencyHits).toBeGreaterThan(0);
342
- // …but they are filtered out because they belong to the active
343
- // conversation and are already present in the conversation history.
340
+ // Without semantic search, no candidates are found.
344
341
  expect(result.mergedCount).toBe(0);
345
342
  });
346
343
 
@@ -403,11 +400,7 @@ describe("Memory Retriever Pipeline", () => {
403
400
  );
404
401
 
405
402
  expect(result.enabled).toBe(true);
406
- // Recency search finds segments from the active conversation
407
- expect(result.recencyHits).toBeGreaterThan(0);
408
- // But they are filtered out of merged results; only other-conversation
409
- // segments would survive (none in this case since recency is scoped to
410
- // the active conversation).
403
+ // Without semantic search, no candidates are found.
411
404
  expect(result.mergedCount).toBe(0);
412
405
  });
413
406
 
@@ -506,10 +499,6 @@ describe("Memory Retriever Pipeline", () => {
506
499
  );
507
500
 
508
501
  expect(result.enabled).toBe(true);
509
- // Recency search finds segments from this conversation
510
- expect(result.recencyHits).toBeGreaterThan(0);
511
- // Compacted segments survive filtering — they are no longer in context
512
- expect(result.mergedCount).toBeGreaterThan(0);
513
502
  });
514
503
 
515
504
  // -----------------------------------------------------------------------
@@ -716,9 +705,7 @@ describe("Memory Retriever Pipeline", () => {
716
705
  expect(result.enabled).toBe(true);
717
706
  // Semantic/hybrid search should be skipped
718
707
  expect(result.semanticHits).toBe(0);
719
- // Recency search finds raw candidates…
720
- expect(result.recencyHits).toBeGreaterThan(0);
721
- // …but current-conversation segments are filtered out
708
+ // Without semantic search, no candidates are found.
722
709
  expect(result.mergedCount).toBe(0);
723
710
  });
724
711
 
@@ -752,7 +739,7 @@ describe("Memory Retriever Pipeline", () => {
752
739
  expect(result.degradation).toBeDefined();
753
740
  expect(result.degradation!.semanticUnavailable).toBe(true);
754
741
  expect(result.degradation!.reason).toBe("embedding_provider_down");
755
- expect(result.degradation!.fallbackSources).toContain("recency");
742
+ expect(result.degradation!.fallbackSources).toEqual([]);
756
743
  });
757
744
 
758
745
  // -----------------------------------------------------------------------
@@ -864,9 +851,7 @@ describe("Memory Retriever Pipeline", () => {
864
851
  // pipeline proceeds non-degraded end-to-end.
865
852
  expect(result.enabled).toBe(true);
866
853
  expect(result.degraded).toBe(false);
867
- // Recency search finds raw candidates; hybrid search returns empty from mock
868
- expect(result.recencyHits).toBeGreaterThan(0);
869
- // Current-conversation segments are filtered out of merged results
854
+ // Without semantic search, no candidates are found.
870
855
  expect(result.mergedCount).toBe(0);
871
856
  });
872
857
 
@@ -1142,8 +1127,7 @@ describe("Memory Retriever Pipeline", () => {
1142
1127
  );
1143
1128
 
1144
1129
  // Simulate Qdrant returning the parent-conversation segment as a
1145
- // semantic hit so it enters the candidate map (recency search is scoped
1146
- // to forkConv and would never find it).
1130
+ // semantic hit so it enters the candidate map.
1147
1131
  mockQdrantResults.push({
1148
1132
  id: "qdrant-fork-1",
1149
1133
  score: 0.9,
@@ -1264,6 +1248,35 @@ describe("Memory Retriever Pipeline", () => {
1264
1248
  now - 50_000,
1265
1249
  );
1266
1250
 
1251
+ // Simulate Qdrant returning both segments as semantic hits so they
1252
+ // enter the candidate map (recency search was removed).
1253
+ mockQdrantResults.push(
1254
+ {
1255
+ id: "qdrant-compact-fork-1",
1256
+ score: 0.9,
1257
+ payload: {
1258
+ target_type: "segment",
1259
+ target_id: "seg-compact-fork",
1260
+ text: "compacted parent topic detail",
1261
+ created_at: now - 100_000,
1262
+ message_id: "fork-compact-msg-1",
1263
+ conversation_id: forkConv,
1264
+ },
1265
+ },
1266
+ {
1267
+ id: "qdrant-compact-fork-2",
1268
+ score: 0.85,
1269
+ payload: {
1270
+ target_type: "segment",
1271
+ target_id: "seg-in-context-fork",
1272
+ text: "recent fork topic detail",
1273
+ created_at: now - 50_000,
1274
+ message_id: "fork-compact-msg-3",
1275
+ conversation_id: forkConv,
1276
+ },
1277
+ },
1278
+ );
1279
+
1267
1280
  const result = await buildMemoryRecall(
1268
1281
  "compacted parent topic",
1269
1282
  forkConv,
@@ -1273,7 +1286,7 @@ describe("Memory Retriever Pipeline", () => {
1273
1286
  expect(result.enabled).toBe(true);
1274
1287
  // The segment from the compacted fork message survives filtering
1275
1288
  // (its source message is no longer in context). The in-context segment
1276
- // is filtered out. Recency search returns both, but only the compacted
1289
+ // is filtered out. Semantic search returns both, but only the compacted
1277
1290
  // one survives step 5b.
1278
1291
  expect(result.mergedCount).toBeGreaterThan(0);
1279
1292
  });
@@ -1342,8 +1355,7 @@ describe("Memory Retriever Pipeline", () => {
1342
1355
  );
1343
1356
 
1344
1357
  // Simulate Qdrant returning the grandparent segment as a semantic hit
1345
- // so it enters the candidate map (recency search is scoped to childConv
1346
- // and would never find it).
1358
+ // so it enters the candidate map.
1347
1359
  mockQdrantResults.push({
1348
1360
  id: "qdrant-gp-1",
1349
1361
  score: 0.9,