@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
@@ -1,22 +1,24 @@
1
- import { and, eq, like, sql } from "drizzle-orm";
1
+ import { and, desc, eq, like, sql } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import { getConfig } from "../config/loader.js";
5
5
  import type { MemoryExtractionConfig } from "../config/types.js";
6
+ import { getAssistantName } from "../daemon/identity-helpers.js";
6
7
  import { resolveGuardianPersona } from "../prompts/persona-resolver.js";
7
8
  import { buildCoreIdentityContext } from "../prompts/system-prompt.js";
8
9
  import {
9
- createTimeout,
10
10
  extractToolUse,
11
11
  getConfiguredProvider,
12
12
  userMessage,
13
13
  } from "../providers/provider-send-message.js";
14
+ import { BackendUnavailableError } from "../util/errors.js";
14
15
  import { getLogger } from "../util/logger.js";
15
16
  import { truncate } from "../util/truncate.js";
16
17
  import { maybeEnqueueConversationStartersJob } from "./conversation-starters-cadence.js";
17
18
  import { getDb } from "./db.js";
18
19
  import { computeMemoryFingerprint } from "./fingerprint.js";
19
20
  import { enqueueMemoryJob } from "./jobs-store.js";
21
+ import { upsertJournalMemoriesFromDisk } from "./journal-memory.js";
20
22
  import { extractTextFromStoredMessageContent } from "./message-content.js";
21
23
  import { withQdrantBreaker } from "./qdrant-circuit-breaker.js";
22
24
  import { getQdrantClient } from "./qdrant-client.js";
@@ -32,7 +34,8 @@ export type MemoryItemKind =
32
34
  | "project"
33
35
  | "decision"
34
36
  | "constraint"
35
- | "event";
37
+ | "event"
38
+ | "journal";
36
39
 
37
40
  export type OverrideConfidence = "explicit" | "tentative" | "inferred";
38
41
 
@@ -56,8 +59,16 @@ const VALID_KINDS = new Set<string>([
56
59
  "decision",
57
60
  "constraint",
58
61
  "event",
62
+ "journal",
59
63
  ]);
60
64
 
65
+ /**
66
+ * Kinds the LLM is allowed to produce during extraction. Excludes "journal"
67
+ * because journal memories are created directly from disk files — any
68
+ * LLM-produced journal items would be silently dropped, wasting tokens.
69
+ */
70
+ const EXTRACTION_KINDS = [...VALID_KINDS].filter((k) => k !== "journal");
71
+
61
72
  /** Maps old kind names to their new equivalents for graceful migration. */
62
73
  const KIND_MIGRATION_MAP: Record<string, MemoryItemKind> = {
63
74
  profile: "identity",
@@ -184,17 +195,57 @@ For each item, provide:
184
195
  Rules:
185
196
  - Only extract genuinely memorable information. Skip pleasantries, filler, and transient discussion.
186
197
  - Do NOT extract information about what tools the assistant used or what files it read — only extract substantive facts about the user, their projects, and their preferences.
187
- - Do NOT extract claims about actions the assistant performed, outcomes it achieved, or progress it reported (e.g., "I booked an appointment", "I sent the email"). Only extract facts stated by the user or from external sources — the assistant's self-reports are not reliable memory material.
188
198
  - Do NOT extract raw code snippets, JSON fragments, YAML, configuration values, log output, or data structures. Only extract the human-readable meaning or intent behind such content, not the literal syntax.
189
199
  - Prefer fewer high-quality items over many low-quality ones.
190
- - If the message contains no memorable information, return an empty array.`;
200
+ - If the message contains no memorable information, return an empty array.
201
+ - The preceding conversation context (if provided) is for disambiguation only. Extract items ONLY from the final message after the --- separator, not from the context messages.`;
202
+
203
+ // Try to extract user name from persona text
204
+ let userName = "the user";
205
+ if (userPersona) {
206
+ const nameMatch = userPersona.match(/\*\*Name:\*\*\s*(.+)/);
207
+ if (nameMatch) {
208
+ userName = nameMatch[1].trim();
209
+ }
210
+ }
191
211
 
192
212
  if (messageRole === "assistant") {
193
213
  instructions += `
194
214
 
195
- IMPORTANT: The message below is from the ASSISTANT, not the user. Do NOT attribute the assistant's own statements, feelings, self-descriptions, or introspection to the user. Only extract facts about the user, the world, or the project that the assistant is referencing or relaying — NOT the assistant's own identity, uncertainty, or behavior. If the assistant is simply talking about itself (e.g., introducing itself, expressing uncertainty about its own purpose), extract nothing.`;
215
+ IMPORTANT: The message below is from the ASSISTANT. You may extract facts about actions taken, decisions made, and outcomes achieved. However, do NOT attribute the assistant's own identity, personality, or self-descriptions to the user. If the assistant is just introducing itself or expressing uncertainty about its own nature, extract nothing.`;
196
216
  }
197
217
 
218
+ instructions += `
219
+
220
+ ## Examples
221
+
222
+ Good extractions from user messages:
223
+ - "I'm a backend engineer at Acme Corp, mostly working with Go and PostgreSQL"
224
+ → kind: identity, subject: "Role at Acme Corp", statement: "${userName} is a backend engineer at Acme Corp, works primarily with Go and PostgreSQL"
225
+
226
+ - "Always use semantic commits in this repo. I hate squash merges."
227
+ → kind: constraint, subject: "Git conventions", statement: "${userName} requires semantic commit messages. Strongly dislikes squash merges."
228
+
229
+ - "We decided to go with Redis for the cache layer because DynamoDB was too expensive at our read volume"
230
+ → kind: decision, subject: "Cache layer choice", statement: "${userName} chose Redis over DynamoDB for caching due to cost at high read volumes"
231
+
232
+ Good extractions from assistant messages:
233
+ - "Based on your earlier mention, I see you're using Next.js 14 with the app router for the dashboard project."
234
+ → kind: project, subject: "Dashboard tech stack", statement: "${userName}'s dashboard project uses Next.js 14 with the app router"
235
+
236
+ - "Since you mentioned your team follows trunk-based development, I'll keep the changes in a single commit."
237
+ → kind: constraint, subject: "Team branching strategy", statement: "${userName}'s team follows trunk-based development"
238
+
239
+ - "I've refactored the auth middleware to use JWT validation and added rate limiting to the login endpoint."
240
+ → kind: project, subject: "Auth middleware changes", statement: "Auth middleware was refactored to use JWT validation with rate limiting on the login endpoint"
241
+
242
+ Do NOT extract:
243
+ - "I'll check that file for you" → assistant operational statement with no lasting information
244
+ - "I think the best approach would be to refactor this" → speculative, no action taken yet
245
+ - "The tests passed" → transient status
246
+ - "Sure, sounds good" → filler
247
+ - "\`\`\`json {"key": "val"} \`\`\`" → raw code/data, extract meaning not syntax`;
248
+
198
249
  if (existingItems.length > 0) {
199
250
  instructions += `\n\nExisting memory items (use these to identify supersession targets — set \`supersedes\` to the item ID if the new information replaces one of these):\n`;
200
251
  for (const item of existingItems) {
@@ -206,8 +257,7 @@ IMPORTANT: The message below is from the ASSISTANT, not the user. Do NOT attribu
206
257
  // generic "User ..." labels. Budget is dynamically computed: whatever
207
258
  // remains after the fixed instructions fits within the system prompt
208
259
  // ceiling, preventing oversized prompts from exceeding the provider input
209
- // window (which would cause sendMessage to error and fall back to
210
- // lower-quality pattern-based extraction).
260
+ // window (which would cause sendMessage to error).
211
261
  const rawIdentityContext = buildCoreIdentityContext(
212
262
  userPersona ? { userPersona } : undefined,
213
263
  );
@@ -320,188 +370,200 @@ async function extractItemsWithLLM(
320
370
  extractionConfig: MemoryExtractionConfig,
321
371
  scopeId: string,
322
372
  messageRole: string,
373
+ precedingMessages: Array<{ role: string; content: string }>,
323
374
  userPersona?: string | null,
324
375
  ): Promise<ExtractedItem[]> {
325
376
  const provider = await getConfiguredProvider();
326
377
  if (!provider) {
327
- log.debug(
328
- "Configured provider unavailable for LLM extraction, falling back to pattern-based",
378
+ throw new BackendUnavailableError(
379
+ "Provider unavailable for memory extraction",
329
380
  );
330
- return extractItemsPatternBased(text, scopeId);
331
381
  }
332
382
 
333
- try {
334
- const { signal, cleanup } = createTimeout(15000);
335
-
336
- try {
337
- // Query existing items to give the LLM supersession context
338
- const existingItems = queryExistingItemsForContext(scopeId, text);
339
- const systemPrompt = buildExtractionSystemPrompt(
340
- existingItems,
341
- messageRole,
342
- userPersona,
343
- );
383
+ // Query existing items to give the LLM supersession context
384
+ const existingItems = queryExistingItemsForContext(scopeId, text);
385
+ const systemPrompt = buildExtractionSystemPrompt(
386
+ existingItems,
387
+ messageRole,
388
+ userPersona,
389
+ );
390
+
391
+ const assistantName = getAssistantName() ?? "the assistant";
392
+ const messagePrefix =
393
+ messageRole === "assistant"
394
+ ? `[This message is from ${assistantName}]\n\n`
395
+ : `[This message is from the user]\n\n`;
396
+
397
+ // Build user content with optional preceding conversation context
398
+ const contextParts: string[] = [];
399
+ for (const msg of precedingMessages) {
400
+ const msgText = extractTextFromStoredMessageContent(msg.content);
401
+ if (msgText.length === 0) continue;
402
+ const roleLabel =
403
+ msg.role === "assistant"
404
+ ? (getAssistantName() ?? "assistant")
405
+ : "user";
406
+ contextParts.push(`[${roleLabel}]: ${msgText}`);
407
+ }
408
+ let userContent = `${messagePrefix}${text}`;
409
+ if (contextParts.length > 0) {
410
+ userContent = `Preceding conversation context:\n${contextParts.join("\n\n")}\n\n---\n\nMessage to extract from:\n${messagePrefix}${text}`;
411
+ }
344
412
 
345
- const messagePrefix =
346
- messageRole === "assistant"
347
- ? "[This message is from the assistant]\n\n"
348
- : "";
349
- const response = await provider.sendMessage(
350
- [userMessage(`${messagePrefix}${text}`)],
351
- [
352
- {
353
- name: "store_memory_items",
354
- description: "Store extracted memory items from the message",
355
- input_schema: {
356
- type: "object" as const,
357
- properties: {
358
- items: {
359
- type: "array",
360
- items: {
361
- type: "object",
362
- properties: {
363
- kind: {
364
- type: "string",
365
- enum: [...VALID_KINDS],
366
- description: "Category of memory item",
367
- },
368
- subject: {
369
- type: "string",
370
- description:
371
- "Short label (2-8 words) for what this is about",
372
- },
373
- statement: {
374
- type: "string",
375
- description:
376
- "Relationship-rich factual statement to remember (1-2 sentences). Include relational context.",
377
- },
378
- confidence: {
379
- type: "number",
380
- description:
381
- "Confidence that this is accurate (0.0-1.0)",
382
- },
383
- importance: {
384
- type: "number",
385
- description:
386
- "How valuable this is to remember (0.0-1.0)",
387
- },
388
- supersedes: {
389
- type: ["string", "null"],
390
- description:
391
- "ID of the existing memory item this replaces, or null if not replacing anything",
392
- },
393
- overrideConfidence: {
394
- type: "string",
395
- enum: ["explicit", "tentative", "inferred"],
396
- description:
397
- "How confident you are that this overrides an existing item: explicit (clear override), tentative (ambiguous), inferred (weak signal)",
398
- },
399
- },
400
- required: [
401
- "kind",
402
- "subject",
403
- "statement",
404
- "confidence",
405
- "importance",
406
- "supersedes",
407
- "overrideConfidence",
408
- ],
413
+ const response = await provider.sendMessage(
414
+ [userMessage(userContent)],
415
+ [
416
+ {
417
+ name: "store_memory_items",
418
+ description: "Store extracted memory items from the message",
419
+ input_schema: {
420
+ type: "object" as const,
421
+ properties: {
422
+ items: {
423
+ type: "array",
424
+ items: {
425
+ type: "object",
426
+ properties: {
427
+ kind: {
428
+ type: "string",
429
+ enum: EXTRACTION_KINDS,
430
+ description: "Category of memory item",
431
+ },
432
+ subject: {
433
+ type: "string",
434
+ description:
435
+ "Short label (2-8 words) for what this is about",
436
+ },
437
+ statement: {
438
+ type: "string",
439
+ description:
440
+ "Relationship-rich factual statement to remember (1-2 sentences). Include relational context.",
441
+ },
442
+ confidence: {
443
+ type: "number",
444
+ description: "Confidence that this is accurate (0.0-1.0)",
445
+ },
446
+ importance: {
447
+ type: "number",
448
+ description: "How valuable this is to remember (0.0-1.0)",
449
+ },
450
+ supersedes: {
451
+ type: ["string", "null"],
452
+ description:
453
+ "ID of the existing memory item this replaces, or null if not replacing anything",
454
+ },
455
+ overrideConfidence: {
456
+ type: "string",
457
+ enum: ["explicit", "tentative", "inferred"],
458
+ description:
459
+ "How confident you are that this overrides an existing item: explicit (clear override), tentative (ambiguous), inferred (weak signal)",
409
460
  },
410
461
  },
462
+ required: [
463
+ "kind",
464
+ "subject",
465
+ "statement",
466
+ "confidence",
467
+ "importance",
468
+ "supersedes",
469
+ "overrideConfidence",
470
+ ],
411
471
  },
412
- required: ["items"],
413
472
  },
414
473
  },
415
- ],
416
- systemPrompt,
417
- {
418
- config: {
419
- modelIntent: extractionConfig.modelIntent,
420
- max_tokens: 1024,
421
- tool_choice: { type: "tool" as const, name: "store_memory_items" },
422
- },
423
- signal,
474
+ required: ["items"],
424
475
  },
425
- );
426
- cleanup();
476
+ },
477
+ ],
478
+ systemPrompt,
479
+ {
480
+ config: {
481
+ modelIntent: extractionConfig.modelIntent,
482
+ tool_choice: { type: "tool" as const, name: "store_memory_items" },
483
+ },
484
+ },
485
+ );
427
486
 
428
- const toolBlock = extractToolUse(response);
429
- if (!toolBlock) {
430
- log.warn(
431
- "No tool_use block in LLM extraction response, falling back to pattern-based",
432
- );
433
- return extractItemsPatternBased(text, scopeId);
434
- }
487
+ const toolBlock = extractToolUse(response);
488
+ if (!toolBlock) {
489
+ throw new Error("No tool_use block in LLM extraction response");
490
+ }
435
491
 
436
- const input = toolBlock.input as { items?: LLMExtractedItem[] };
437
- if (!Array.isArray(input.items)) {
438
- log.warn(
439
- "Invalid items in LLM extraction response, falling back to pattern-based",
440
- );
441
- return extractItemsPatternBased(text, scopeId);
442
- }
492
+ const input = toolBlock.input as { items?: LLMExtractedItem[] };
493
+ if (!Array.isArray(input.items)) {
494
+ throw new Error("Invalid items structure in LLM extraction response");
495
+ }
443
496
 
444
- // Build set of known existing item IDs for supersession validation
445
- const existingItemIds = new Set(existingItems.map((e) => e.id));
446
-
447
- const items: ExtractedItem[] = [];
448
- for (const raw of input.items) {
449
- // Apply kind migration map for old kind names, then validate
450
- const resolvedKind = KIND_MIGRATION_MAP[raw.kind] ?? raw.kind;
451
- if (!VALID_KINDS.has(resolvedKind)) continue;
452
- if (!raw.subject || !raw.statement) continue;
453
- const subject = truncate(String(raw.subject), 80, "");
454
- const statement = truncate(String(raw.statement), 500, "");
455
- const confidence = clampUnitInterval(parseScore(raw.confidence, 0.5));
456
- const importance = clampUnitInterval(parseScore(raw.importance, 0.5));
457
- const fingerprint = computeMemoryFingerprint(
458
- scopeId,
459
- resolvedKind,
460
- subject,
461
- statement,
462
- );
497
+ // Build set of known existing item IDs for supersession validation
498
+ const existingItemIds = new Set(existingItems.map((e) => e.id));
463
499
 
464
- // Validate supersedes: must reference a known existing item ID.
465
- // Reject hallucinated IDs that don't match any item we showed the LLM.
466
- const rawSupersedes =
467
- typeof raw.supersedes === "string" && raw.supersedes.length > 0
468
- ? raw.supersedes
469
- : null;
470
- const supersedes =
471
- rawSupersedes && existingItemIds.has(rawSupersedes)
472
- ? rawSupersedes
473
- : null;
474
- const supersedesRejected = !!rawSupersedes && !supersedes;
475
- const overrideConfidence = VALID_OVERRIDE_CONFIDENCES.has(
476
- raw.overrideConfidence,
477
- )
478
- ? (raw.overrideConfidence as OverrideConfidence)
479
- : "inferred";
480
-
481
- items.push({
482
- kind: resolvedKind as MemoryItemKind,
483
- subject,
484
- statement,
485
- confidence,
486
- importance,
487
- fingerprint,
488
- supersedes,
489
- overrideConfidence,
490
- supersedesRejected,
491
- });
492
- }
500
+ const items: ExtractedItem[] = [];
501
+ for (const raw of input.items) {
502
+ // Apply kind migration map for old kind names, then validate
503
+ const resolvedKind = KIND_MIGRATION_MAP[raw.kind] ?? raw.kind;
504
+ if (resolvedKind === "journal") continue; // journal memories created directly from disk
505
+ if (!VALID_KINDS.has(resolvedKind)) continue;
506
+ if (!raw.subject || !raw.statement) continue;
507
+ const subject = String(raw.subject).trim();
508
+ const statement = String(raw.statement).trim();
509
+ const confidence = clampUnitInterval(parseScore(raw.confidence, 0.5));
510
+ const importance = clampUnitInterval(parseScore(raw.importance, 0.5));
511
+ const fingerprint = computeMemoryFingerprint(
512
+ scopeId,
513
+ resolvedKind,
514
+ subject,
515
+ statement,
516
+ );
493
517
 
494
- return deduplicateItems(items);
495
- } finally {
496
- cleanup();
497
- }
518
+ // Validate supersedes: must reference a known existing item ID.
519
+ // Reject hallucinated IDs that don't match any item we showed the LLM.
520
+ const rawSupersedes =
521
+ typeof raw.supersedes === "string" && raw.supersedes.length > 0
522
+ ? raw.supersedes
523
+ : null;
524
+ const supersedes =
525
+ rawSupersedes && existingItemIds.has(rawSupersedes)
526
+ ? rawSupersedes
527
+ : null;
528
+ const supersedesRejected = !!rawSupersedes && !supersedes;
529
+ const overrideConfidence = VALID_OVERRIDE_CONFIDENCES.has(
530
+ raw.overrideConfidence,
531
+ )
532
+ ? (raw.overrideConfidence as OverrideConfidence)
533
+ : "inferred";
534
+
535
+ items.push({
536
+ kind: resolvedKind as MemoryItemKind,
537
+ subject,
538
+ statement,
539
+ confidence,
540
+ importance,
541
+ fingerprint,
542
+ supersedes,
543
+ overrideConfidence,
544
+ supersedesRejected,
545
+ });
546
+ }
547
+
548
+ return deduplicateItems(items);
549
+ }
550
+
551
+ /**
552
+ * Fire conversation starters generation when journal memories were created.
553
+ * Wrapped in try/catch so failures never propagate to the caller.
554
+ */
555
+ function triggerConversationStartersIfNeeded(
556
+ count: number,
557
+ scopeId: string,
558
+ ): void {
559
+ if (count <= 0) return;
560
+ try {
561
+ maybeEnqueueConversationStartersJob(scopeId);
498
562
  } catch (err) {
499
- const message = err instanceof Error ? err.message : String(err);
500
563
  log.warn(
501
- { err: message },
502
- "LLM extraction failed, falling back to pattern-based",
564
+ { err: err instanceof Error ? err.message : String(err) },
565
+ "Failed to check conversation starters cadence",
503
566
  );
504
- return extractItemsPatternBased(text, scopeId);
505
567
  }
506
568
  }
507
569
 
@@ -519,6 +581,7 @@ export async function extractAndUpsertMemoryItemsForMessage(
519
581
  role: messages.role,
520
582
  content: messages.content,
521
583
  createdAt: messages.createdAt,
584
+ conversationId: messages.conversationId,
522
585
  })
523
586
  .from(messages)
524
587
  .where(eq(messages.id, messageId))
@@ -526,18 +589,52 @@ export async function extractAndUpsertMemoryItemsForMessage(
526
589
 
527
590
  if (!message) return 0;
528
591
 
592
+ // Fetch up to 6 preceding messages from the same conversation for
593
+ // disambiguation context (e.g. resolving "that framework" or "yes, do it").
594
+ const effectiveConversationId = conversationId ?? message.conversationId;
595
+ const precedingMessages = effectiveConversationId
596
+ ? db
597
+ .select({ role: messages.role, content: messages.content })
598
+ .from(messages)
599
+ .where(
600
+ and(
601
+ eq(messages.conversationId, effectiveConversationId),
602
+ sql`${messages.createdAt} < ${message.createdAt}`,
603
+ ),
604
+ )
605
+ .orderBy(desc(messages.createdAt))
606
+ .limit(6)
607
+ .all()
608
+ .reverse()
609
+ : [];
610
+
611
+ const effectiveScopeId = scopeId ?? "default";
612
+
613
+ // Directly create journal memories from any journal files written during
614
+ // this message, bypassing LLM extraction (which would summarize/rewrite them).
615
+ // This must run before the extraction guards (semantic density, useLLM, etc.)
616
+ // because journal disk scanning is independent of LLM extraction.
617
+ let journalUpserted = 0;
618
+ if (message.role === "assistant") {
619
+ journalUpserted = upsertJournalMemoriesFromDisk(
620
+ message.createdAt,
621
+ effectiveScopeId,
622
+ messageId,
623
+ );
624
+ }
625
+
529
626
  const text = extractTextFromStoredMessageContent(message.content);
530
627
  if (!hasSemanticDensity(text)) {
531
628
  log.debug(
532
629
  { messageId },
533
630
  "Skipping extraction — message lacks semantic density",
534
631
  );
535
- return 0;
632
+ triggerConversationStartersIfNeeded(journalUpserted, effectiveScopeId);
633
+ return journalUpserted;
536
634
  }
537
635
 
538
636
  const config = getConfig();
539
637
  const extractionConfig = config.memory.extraction;
540
- const effectiveScopeId = scopeId ?? "default";
541
638
 
542
639
  // Resolve the guardian's persona to provide personality-aware extraction
543
640
  // context. Currently uses the guardian persona for all conversations —
@@ -545,17 +642,24 @@ export async function extractAndUpsertMemoryItemsForMessage(
545
642
  // better extraction context than none.
546
643
  const userPersona = resolveGuardianPersona();
547
644
 
548
- const extracted = extractionConfig.useLLM
549
- ? await extractItemsWithLLM(
550
- text,
551
- extractionConfig,
552
- effectiveScopeId,
553
- message.role,
554
- userPersona,
555
- )
556
- : extractItemsPatternBased(text, effectiveScopeId);
645
+ if (!extractionConfig.useLLM) {
646
+ triggerConversationStartersIfNeeded(journalUpserted, effectiveScopeId);
647
+ return journalUpserted;
648
+ }
557
649
 
558
- if (extracted.length === 0) return 0;
650
+ const extracted = await extractItemsWithLLM(
651
+ text,
652
+ extractionConfig,
653
+ effectiveScopeId,
654
+ message.role,
655
+ precedingMessages,
656
+ userPersona,
657
+ );
658
+
659
+ if (extracted.length === 0) {
660
+ triggerConversationStartersIfNeeded(journalUpserted, effectiveScopeId);
661
+ return journalUpserted;
662
+ }
559
663
 
560
664
  // Guard: re-check after the async LLM call. The event loop yields during
561
665
  // extractItemsWithLLM, so another task could have marked the conversation
@@ -565,13 +669,10 @@ export async function extractAndUpsertMemoryItemsForMessage(
565
669
  { messageId, conversationId },
566
670
  "Skipping upsert — conversation marked failed during extraction",
567
671
  );
568
- return 0;
672
+ triggerConversationStartersIfNeeded(journalUpserted, effectiveScopeId);
673
+ return journalUpserted;
569
674
  }
570
675
 
571
- // Determine verification state from message role
572
- const verificationState =
573
- message.role === "user" ? "user_reported" : "assistant_inferred";
574
-
575
676
  let upserted = 0;
576
677
  for (const item of extracted) {
577
678
  const now = Date.now();
@@ -591,13 +692,21 @@ export async function extractAndUpsertMemoryItemsForMessage(
591
692
  let effectiveStatus: string = "active";
592
693
  if (existing) {
593
694
  memoryItemId = existing.id;
594
- // Promote verification state if re-seen from a more trusted source
595
- const promotedState =
596
- existing.verificationState === "assistant_inferred" &&
597
- verificationState === "user_reported"
598
- ? "user_reported"
599
- : existing.verificationState;
600
695
  effectiveStatus = "active";
696
+ // Preserve sourceType for tool-sourced items — extraction should not
697
+ // demote items the user explicitly saved.
698
+ const effectiveSourceType =
699
+ existing.sourceType === "tool" ? "tool" : "extraction";
700
+
701
+ // Dual-write verificationState alongside sourceType for client compat.
702
+ // Promote from assistant_inferred → user_reported when re-seen from user.
703
+ const effectiveVerificationState =
704
+ message.role === "user" || existing.verificationState === "user_reported"
705
+ ? "user_reported"
706
+ : existing.verificationState === "user_confirmed"
707
+ ? "user_confirmed"
708
+ : "assistant_inferred";
709
+
601
710
  db.update(memoryItems)
602
711
  .set({
603
712
  status: effectiveStatus,
@@ -608,7 +717,9 @@ export async function extractAndUpsertMemoryItemsForMessage(
608
717
  Math.max(existing.importance ?? 0, item.importance),
609
718
  ),
610
719
  lastSeenAt: Math.max(existing.lastSeenAt, seenAt),
611
- verificationState: promotedState,
720
+ sourceType: effectiveSourceType,
721
+ sourceMessageRole: message.role,
722
+ verificationState: effectiveVerificationState,
612
723
  })
613
724
  .where(eq(memoryItems.id, existing.id))
614
725
  .run();
@@ -624,7 +735,11 @@ export async function extractAndUpsertMemoryItemsForMessage(
624
735
  confidence: item.confidence,
625
736
  importance: item.importance,
626
737
  fingerprint: item.fingerprint,
627
- verificationState,
738
+ sourceType: "extraction",
739
+ sourceMessageRole: message.role,
740
+ // Dual-write verificationState for client compat
741
+ verificationState:
742
+ message.role === "user" ? "user_reported" : "assistant_inferred",
628
743
  scopeId: effectiveScopeId,
629
744
  firstSeenAt: message.createdAt,
630
745
  lastSeenAt: seenAt,
@@ -715,11 +830,9 @@ export async function extractAndUpsertMemoryItemsForMessage(
715
830
  }
716
831
 
717
832
  // Fallback subject-match supersession: only when the LLM did not
718
- // explicitly handle supersession for this item. This preserves the
719
- // original behavior for pattern-based extraction and items without
720
- // LLM-directed supersession. Skip items whose supersedes ID was
721
- // rejected (hallucinated) — they should coexist, not trigger
722
- // subject-based replacement.
833
+ // explicitly handle supersession for this item. Skip items whose
834
+ // supersedes ID was rejected (hallucinated) they should coexist,
835
+ // not trigger subject-based replacement.
723
836
  if (
724
837
  !item.supersedes &&
725
838
  !item.supersedesRejected &&
@@ -744,7 +857,7 @@ export async function extractAndUpsertMemoryItemsForMessage(
744
857
  .values({
745
858
  memoryItemId,
746
859
  messageId,
747
- evidence: truncate(item.statement, 500, ""),
860
+ evidence: item.statement,
748
861
  createdAt: now,
749
862
  })
750
863
  .onConflictDoNothing()
@@ -753,139 +866,19 @@ export async function extractAndUpsertMemoryItemsForMessage(
753
866
  enqueueMemoryJob("embed_item", { itemId: memoryItemId });
754
867
  }
755
868
 
869
+ upserted += journalUpserted;
870
+
756
871
  log.debug(
757
872
  { messageId, extracted: extracted.length, upserted },
758
873
  "Extracted memory items from message",
759
874
  );
760
875
 
761
876
  // Trigger conversation starters generation when new items are upserted
762
- if (upserted > 0) {
763
- try {
764
- maybeEnqueueConversationStartersJob(effectiveScopeId);
765
- } catch (err) {
766
- log.warn(
767
- { err: err instanceof Error ? err.message : String(err) },
768
- "Failed to check conversation starters cadence",
769
- );
770
- }
771
- }
877
+ triggerConversationStartersIfNeeded(upserted, effectiveScopeId);
772
878
 
773
879
  return upserted;
774
880
  }
775
881
 
776
- // ── Pattern-based extraction (fallback) ────────────────────────────────
777
-
778
- function extractItemsPatternBased(
779
- text: string,
780
- scopeId: string = "default",
781
- ): ExtractedItem[] {
782
- const sentences = text
783
- .split(/[\n\r]+|(?<=[.!?])\s+/)
784
- .map((s) => s.trim())
785
- .filter((s) => s.length >= 20 && s.length <= 500);
786
-
787
- const items: ExtractedItem[] = [];
788
- for (const sentence of sentences) {
789
- const lower = sentence.toLowerCase();
790
- const classification = classifySentence(lower);
791
- if (!classification) continue;
792
- const subject = inferSubject(sentence, classification.kind);
793
- const statement = sentence.replace(/\s+/g, " ").trim();
794
- const fingerprint = computeMemoryFingerprint(
795
- scopeId,
796
- classification.kind,
797
- subject,
798
- statement,
799
- );
800
- items.push({
801
- kind: classification.kind,
802
- subject,
803
- statement,
804
- confidence: classification.confidence,
805
- importance: classification.importance,
806
- fingerprint,
807
- supersedes: null,
808
- overrideConfidence: "inferred" as OverrideConfidence,
809
- });
810
- }
811
-
812
- return deduplicateItems(items);
813
- }
814
-
815
- function classifySentence(
816
- lower: string,
817
- ): { kind: MemoryItemKind; confidence: number; importance: number } | null {
818
- if (
819
- includesAny(lower, [
820
- "i prefer",
821
- "prefer to",
822
- "favorite",
823
- "i like",
824
- "i dislike",
825
- ])
826
- ) {
827
- return { kind: "preference", confidence: 0.78, importance: 0.7 };
828
- }
829
- if (
830
- includesAny(lower, [
831
- "my name is",
832
- "i am ",
833
- "i work as",
834
- "i live in",
835
- "timezone",
836
- ])
837
- ) {
838
- return { kind: "identity", confidence: 0.72, importance: 0.8 };
839
- }
840
- if (includesAny(lower, ["project", "repository", "repo", "codebase"])) {
841
- return { kind: "project", confidence: 0.68, importance: 0.6 };
842
- }
843
- if (
844
- includesAny(lower, ["we decided", "decision", "chosen approach", "we will"])
845
- ) {
846
- return { kind: "decision", confidence: 0.75, importance: 0.7 };
847
- }
848
- if (
849
- includesAny(lower, ["todo", "to do", "next step", "follow up", "need to"])
850
- ) {
851
- return { kind: "project", confidence: 0.74, importance: 0.6 };
852
- }
853
- if (
854
- includesAny(lower, [
855
- "must",
856
- "cannot",
857
- "should not",
858
- "constraint",
859
- "requirement",
860
- ])
861
- ) {
862
- return { kind: "constraint", confidence: 0.7, importance: 0.7 };
863
- }
864
- if (includesAny(lower, ["remember", "important", "fact", "noted"])) {
865
- return { kind: "identity", confidence: 0.62, importance: 0.5 };
866
- }
867
- return null;
868
- }
869
-
870
- function inferSubject(sentence: string, kind: MemoryItemKind): string {
871
- const trimmed = sentence.trim();
872
- if (kind === "project") {
873
- const match = trimmed.match(
874
- /(?:project|repo(?:sitory)?)\s+([A-Za-z0-9._/-]{2,80})/i,
875
- );
876
- if (match) return match[1];
877
- }
878
- const words = trimmed.split(/\s+/).slice(0, 6).join(" ");
879
- return truncate(words, 80, "");
880
- }
881
-
882
- function includesAny(text: string, needles: string[]): boolean {
883
- for (const needle of needles) {
884
- if (text.includes(needle)) return true;
885
- }
886
- return false;
887
- }
888
-
889
882
  // ── Helpers ────────────────────────────────────────────────────────────
890
883
 
891
884
  function deduplicateItems(items: ExtractedItem[]): ExtractedItem[] {