@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
@@ -29,7 +29,6 @@ import {
29
29
  IDENTITY_KINDS,
30
30
  PREFERENCE_KINDS,
31
31
  } from "./search/formatting.js";
32
- import { recencySearch } from "./search/lexical.js";
33
32
  import { isQdrantConnectionError, semanticSearch } from "./search/semantic.js";
34
33
  import { applyStaleDemotion, computeStaleness } from "./search/staleness.js";
35
34
  import {
@@ -139,7 +138,7 @@ function buildDegradationStatus(
139
138
  return {
140
139
  semanticUnavailable: true,
141
140
  reason,
142
- fallbackSources: ["recency"],
141
+ fallbackSources: [],
143
142
  };
144
143
  }
145
144
 
@@ -243,13 +242,12 @@ async function generateQueryEmbedding(
243
242
  * 1. Build query text (caller provides via buildMemoryQuery)
244
243
  * 2. Generate dense + sparse embeddings
245
244
  * 3. Hybrid search on Qdrant (dense + sparse RRF fusion)
246
- * 4. Supplement with recency search (conversation-scoped, DB only)
247
- * 5. Merge + deduplicate results
248
- * 6. Classify tiers (score > 0.8 tier 1, > 0.6 → tier 2)
249
- * 7. Enrich item candidates with metadata for staleness
250
- * 8. Compute staleness per item
251
- * 9. Demote very_stale tier 1 tier 2
252
- * 10. Build two-layer XML injection with budget allocation
245
+ * 4. Deduplicate results
246
+ * 5. Classify tiers (score > 0.6 → tier 1, > 0.4 → tier 2)
247
+ * 6. Enrich item candidates with metadata for staleness
248
+ * 7. Compute staleness per item
249
+ * 8. Demote very_stale tier 1 → tier 2
250
+ * 9. Build two-layer XML injection with budget allocation
253
251
  */
254
252
  export async function buildMemoryRecall(
255
253
  query: string,
@@ -327,21 +325,15 @@ export async function buildMemoryRecall(
327
325
  if (isQdrantConnectionError(err)) {
328
326
  log.warn({ err }, "Qdrant unavailable — hybrid search disabled");
329
327
  } else {
330
- log.warn({ err }, "Hybrid search failed, continuing with recency only");
328
+ log.warn({ err }, "Hybrid search failed");
331
329
  }
332
330
  }
333
331
  }
334
332
  const hybridSearchMs = Date.now() - hybridSearchStart;
335
333
 
336
- // ── Step 4: Recency supplement (DB only, conversation-scoped) ───
337
- const recencyLimit = 5;
338
- const recencyCandidates = conversationId
339
- ? recencySearch(conversationId, recencyLimit, excludeMessageIds, scopeIds)
340
- : [];
341
-
342
- // ── Step 5: Merge and deduplicate ──────────────────────────────
334
+ // ── Step 4: Deduplicate ────────────────────────────────────────
343
335
  const candidateMap = new Map<string, Candidate>();
344
- for (const c of [...hybridCandidates, ...recencyCandidates]) {
336
+ for (const c of [...hybridCandidates]) {
345
337
  const existing = candidateMap.get(c.key);
346
338
  if (!existing) {
347
339
  candidateMap.set(c.key, { ...c });
@@ -356,8 +348,7 @@ export async function buildMemoryRecall(
356
348
  existing.text = c.text;
357
349
  }
358
350
  // Propagate metadata that the first source may lack (e.g. legacy
359
- // Qdrant points missing conversation_id / message_id). The recency
360
- // source always has these from the DB, so merging fills the gap.
351
+ // Qdrant points missing conversation_id / message_id).
361
352
  if (c.conversationId && !existing.conversationId) {
362
353
  existing.conversationId = c.conversationId;
363
354
  }
@@ -366,7 +357,7 @@ export async function buildMemoryRecall(
366
357
  }
367
358
  }
368
359
 
369
- // ── Step 5b: Filter out current-conversation segments still in context ──
360
+ // ── Step 4b: Filter out current-conversation segments still in context ──
370
361
  // Segments whose source message is still in the conversation's context
371
362
  // window are redundant (already visible to the model). However, segments
372
363
  // from messages that were removed by context compaction should be kept —
@@ -443,39 +434,26 @@ export async function buildMemoryRecall(
443
434
  // Compute RRF-style final scores for the merged candidates
444
435
  const allCandidates = [...candidateMap.values()];
445
436
  for (const c of allCandidates) {
446
- // Simple weighted combination hybrid search already applies RRF fusion
447
- // at the Qdrant level; here we combine the fused semantic score with recency.
448
- c.finalScore = c.semantic * 0.7 + c.recency * 0.2 + c.confidence * 0.1;
437
+ // Multiplicative scoring: importance, confidence, and recency amplify semantic
438
+ // relevance but can't substitute for it. An irrelevant item (semantic 0)
439
+ // stays low regardless of metadata. Multiplier range: 0.4 (all zero) to 1.0.
440
+ const metadataMultiplier =
441
+ 0.4 + c.importance * 0.25 + c.confidence * 0.15 + c.recency * 0.2;
442
+ c.finalScore = c.semantic * metadataMultiplier;
449
443
  }
450
444
  allCandidates.sort((a, b) => b.finalScore - a.finalScore);
451
445
 
452
- // ── Step 6: Tier classification ─────────────────────────────────
453
- // Recency-only candidates (semantic=0) can never reach the tier 2 threshold
454
- // (>0.6) since their max finalScore is 0.3. Promote them directly to tier 2
455
- // so recent conversation context is preserved even without semantic signal.
456
- const recencyOnlyKeys = new Set(
457
- allCandidates
458
- .filter((c) => c.semantic === 0 && c.recency > 0)
459
- .map((c) => c.key),
460
- );
446
+ // ── Step 5: Tier classification ─────────────────────────────────
461
447
  const tiered = classifyTiers(allCandidates);
462
- if (recencyOnlyKeys.size > 0) {
463
- const alreadyTiered = new Set(tiered.map((c) => c.key));
464
- for (const c of allCandidates) {
465
- if (recencyOnlyKeys.has(c.key) && !alreadyTiered.has(c.key)) {
466
- tiered.push({ ...c, tier: 2 });
467
- }
468
- }
469
- }
470
448
 
471
- // ── Step 6b: Enrich candidates with source labels ──────────────
449
+ // ── Step 5b: Enrich candidates with source labels ──────────────
472
450
  enrichSourceLabels(tiered);
473
451
 
474
- // ── Step 7: Enrich with item metadata for staleness ─────────────
452
+ // ── Step 6: Enrich with item metadata for staleness ─────────────
475
453
  const itemIds = tiered.filter((c) => c.type === "item").map((c) => c.id);
476
454
  const itemMetadataMap = enrichItemMetadata(itemIds);
477
455
 
478
- // ── Step 8: Compute staleness per item ──────────────────────────
456
+ // ── Step 7: Compute staleness per item ──────────────────────────
479
457
  const now = Date.now();
480
458
  for (const c of tiered) {
481
459
  if (c.type !== "item") continue;
@@ -492,10 +470,10 @@ export async function buildMemoryRecall(
492
470
  c.staleness = level;
493
471
  }
494
472
 
495
- // ── Step 9: Demote very_stale tier 1 → tier 2 ──────────────────
473
+ // ── Step 8: Demote very_stale tier 1 → tier 2 ──────────────────
496
474
  const afterDemotion = applyStaleDemotion(tiered);
497
475
 
498
- // ── Step 10: Budget allocation and two-layer injection ──────────
476
+ // ── Step 9: Budget allocation and two-layer injection ──────────
499
477
  const maxInjectTokens = Math.max(
500
478
  1,
501
479
  Math.floor(
@@ -581,7 +559,6 @@ export async function buildMemoryRecall(
581
559
  {
582
560
  query: truncate(query, 120),
583
561
  hybridHits: hybridCandidates.length,
584
- recencyHits: recencyCandidates.length,
585
562
  mergedCount: allCandidates.length,
586
563
  tier1Count,
587
564
  tier2Count,
@@ -602,7 +579,6 @@ export async function buildMemoryRecall(
602
579
  provider: embeddingResult.provider,
603
580
  model: embeddingResult.model,
604
581
  semanticHits: hybridCandidates.length,
605
- recencyHits: recencyCandidates.length,
606
582
  mergedCount: allCandidates.length,
607
583
  selectedCount,
608
584
  injectedTokens: estimateTextTokens(injectedText),
@@ -887,7 +863,6 @@ function emptyResult(
887
863
  provider: init.provider,
888
864
  model: init.model,
889
865
  semanticHits: 0,
890
- recencyHits: 0,
891
866
  mergedCount: 0,
892
867
  selectedCount: 0,
893
868
  injectedTokens: 0,
@@ -56,6 +56,8 @@ export const memoryItems = sqliteTable(
56
56
  supersedes: text("supersedes"),
57
57
  supersededBy: text("superseded_by"),
58
58
  overrideConfidence: text("override_confidence").default("inferred"),
59
+ sourceType: text("source_type").notNull().default("extraction"),
60
+ sourceMessageRole: text("source_message_role"),
59
61
  },
60
62
  (table) => [
61
63
  index("idx_memory_items_scope_id").on(table.scopeId),
@@ -80,15 +80,6 @@ export const PREFERENCE_KINDS = new Set(["preference", "constraint"]);
80
80
  /** Kinds classified as capabilities for the <available_capabilities> section. */
81
81
  export const CAPABILITY_KINDS = new Set(["capability"]);
82
82
 
83
- /** Per-item token budget for tier 1 items. */
84
- const TIER1_PER_ITEM_TOKENS = 150;
85
-
86
- /** Per-item token budget for tier 2 items. */
87
- const TIER2_PER_ITEM_TOKENS = 100;
88
-
89
- /** Approximate chars-per-token for truncation (matches token-estimator). */
90
- const CHARS_PER_TOKEN = 4;
91
-
92
83
  /**
93
84
  * Build a two-layer XML injection block from tiered candidates.
94
85
  *
@@ -151,38 +142,21 @@ export function buildTwoLayerInjection(params: {
151
142
  : Infinity;
152
143
 
153
144
  // Render tier 1 items first (identity, relevant context, preferences)
154
- const identityLines = renderPlainStatements(
155
- identityItems,
156
- TIER1_PER_ITEM_TOKENS,
157
- remainingTokens,
158
- );
145
+ const identityLines = renderPlainStatements(identityItems, remainingTokens);
159
146
  remainingTokens -= estimateTextTokens(identityLines.join("\n"));
160
147
 
161
- const relevantEpisodes = renderEpisodes(
162
- tier1Candidates,
163
- TIER1_PER_ITEM_TOKENS,
164
- remainingTokens,
165
- );
148
+ const relevantEpisodes = renderEpisodes(tier1Candidates, remainingTokens);
166
149
  remainingTokens -= estimateTextTokens(relevantEpisodes.join("\n"));
167
150
 
168
- const preferenceLines = renderPlainStatements(
169
- preferences,
170
- TIER1_PER_ITEM_TOKENS,
171
- remainingTokens,
172
- );
151
+ const preferenceLines = renderPlainStatements(preferences, remainingTokens);
173
152
  remainingTokens -= estimateTextTokens(preferenceLines.join("\n"));
174
153
 
175
- const capabilityLines = renderPlainStatements(
176
- capabilities,
177
- TIER1_PER_ITEM_TOKENS,
178
- remainingTokens,
179
- );
154
+ const capabilityLines = renderPlainStatements(capabilities, remainingTokens);
180
155
  remainingTokens -= estimateTextTokens(capabilityLines.join("\n"));
181
156
 
182
157
  // Tier 2 uses remaining budget
183
158
  const possiblyRelevantEpisodes = renderEpisodesWithStaleness(
184
159
  tier2Candidates,
185
- TIER2_PER_ITEM_TOKENS,
186
160
  remainingTokens,
187
161
  );
188
162
 
@@ -229,15 +203,13 @@ export function buildTwoLayerInjection(params: {
229
203
  */
230
204
  function renderPlainStatements(
231
205
  items: TieredCandidate[],
232
- perItemBudgetTokens: number,
233
206
  remainingBudget: number,
234
207
  ): string[] {
235
208
  const lines: string[] = [];
236
209
  let used = 0;
237
210
  for (const item of items) {
238
211
  if (used >= remainingBudget) break;
239
- const maxChars = perItemBudgetTokens * CHARS_PER_TOKEN;
240
- const text = escapeXmlTags(truncate(item.text, maxChars));
212
+ const text = escapeXmlTags(item.text);
241
213
  const tokens = estimateTextTokens(text);
242
214
  if (used + tokens > remainingBudget) break;
243
215
  lines.push(text);
@@ -251,15 +223,13 @@ function renderPlainStatements(
251
223
  */
252
224
  function renderEpisodes(
253
225
  items: TieredCandidate[],
254
- perItemBudgetTokens: number,
255
226
  remainingBudget: number,
256
227
  ): string[] {
257
228
  const lines: string[] = [];
258
229
  let used = 0;
259
230
  for (const item of items) {
260
231
  if (used >= remainingBudget) break;
261
- const maxChars = perItemBudgetTokens * CHARS_PER_TOKEN;
262
- const text = escapeXmlTags(truncate(item.text, maxChars));
232
+ const text = escapeXmlTags(item.text);
263
233
  const sourceAttr = buildSourceAttr(item);
264
234
  const line = `<episode${sourceAttr}>\n${text}\n</episode>`;
265
235
  const tokens = estimateTextTokens(line);
@@ -275,15 +245,13 @@ function renderEpisodes(
275
245
  */
276
246
  function renderEpisodesWithStaleness(
277
247
  items: TieredCandidate[],
278
- perItemBudgetTokens: number,
279
248
  remainingBudget: number,
280
249
  ): string[] {
281
250
  const lines: string[] = [];
282
251
  let used = 0;
283
252
  for (const item of items) {
284
253
  if (used >= remainingBudget) break;
285
- const maxChars = perItemBudgetTokens * CHARS_PER_TOKEN;
286
- const text = escapeXmlTags(truncate(item.text, maxChars));
254
+ const text = escapeXmlTags(item.text);
287
255
  const sourceAttr = buildSourceAttr(item);
288
256
  const stalenessAttr =
289
257
  item.staleness && item.staleness !== "fresh"
@@ -347,8 +315,3 @@ function formatShortDate(epochMs: number): string {
347
315
  }
348
316
  return `${month} ${day} ${date.getFullYear()}`;
349
317
  }
350
-
351
- function truncate(text: string, max: number): string {
352
- if (text.length <= max) return text;
353
- return `${text.slice(0, max - 3)}...`;
354
- }
@@ -8,6 +8,10 @@ const BASE_LIFETIME_MS: Record<string, number> = {
8
8
  project: 14 * 86_400_000, // 2 weeks
9
9
  decision: 14 * 86_400_000, // 2 weeks
10
10
  event: 3 * 86_400_000, // 3 days
11
+ // Journals are experiential reflections and forward-looking notes — more
12
+ // durable than ephemeral events or decisions, but not as permanent as
13
+ // identity. 90 days mirrors "preference" lifetime.
14
+ journal: 90 * 86_400_000, // 3 months
11
15
  capability: Infinity,
12
16
  };
13
17
 
@@ -8,9 +8,17 @@ export interface TieredCandidate extends Candidate {
8
8
  sourceLabel?: string;
9
9
  }
10
10
 
11
+ /**
12
+ * Map a composite relevance score to an injection tier.
13
+ *
14
+ * Thresholds are intentionally set lower than raw-embedding ceilings because
15
+ * the multiplicative scoring pipeline (semantic × recency × metadata) compresses
16
+ * the effective score range. Lowering the gates lets moderately-relevant items
17
+ * surface rather than being silently dropped.
18
+ */
11
19
  export function classifyTier(score: number): Tier | null {
12
- if (score > 0.8) return 1;
13
- if (score > 0.6) return 2;
20
+ if (score > 0.6) return 1;
21
+ if (score > 0.4) return 2;
14
22
  return null;
15
23
  }
16
24
 
@@ -1,5 +1,5 @@
1
1
  export type CandidateType = "segment" | "item" | "summary" | "media";
2
- export type CandidateSource = "semantic" | "recency";
2
+ export type CandidateSource = "semantic";
3
3
 
4
4
  export type StalenessLevel = "fresh" | "aging" | "stale" | "very_stale";
5
5
 
@@ -39,12 +39,10 @@ export type DegradationReason =
39
39
  | "qdrant_unavailable"
40
40
  | "embedding_generation_failed";
41
41
 
42
- export type FallbackSource = "recency";
43
-
44
42
  export interface DegradationStatus {
45
43
  semanticUnavailable: boolean;
46
44
  reason: DegradationReason;
47
- fallbackSources: FallbackSource[];
45
+ fallbackSources: string[];
48
46
  }
49
47
 
50
48
  export interface MemoryRecallResult {
@@ -55,7 +53,6 @@ export interface MemoryRecallResult {
55
53
  provider?: string;
56
54
  model?: string;
57
55
  semanticHits: number;
58
- recencyHits: number;
59
56
  mergedCount: number;
60
57
  selectedCount: number;
61
58
  injectedTokens: number;
@@ -24,14 +24,14 @@ export function isConversationFailed(conversationId: string): boolean {
24
24
  }
25
25
 
26
26
  /**
27
- * Invalidate `assistant_inferred` memory items sourced *exclusively* from
27
+ * Invalidate assistant-extracted memory items sourced *exclusively* from
28
28
  * messages in the given conversation. Called when a background task or
29
29
  * schedule fails — the assistant's optimistic claims (e.g., "I booked an
30
30
  * appointment") are not trustworthy if the task didn't complete.
31
31
  *
32
32
  * The failed state is derived from durable storage (task_runs / cron_runs),
33
33
  * so any pending or future extraction jobs for this conversation are blocked
34
- * from creating new `assistant_inferred` items — even after daemon restarts.
34
+ * from creating new assistant-extracted items — even after daemon restarts.
35
35
  *
36
36
  * Items that also have sources from other conversations are left alone
37
37
  * only when those conversations come from non-failed task/schedule runs
@@ -55,7 +55,8 @@ export function invalidateAssistantInferredItemsForConversation(
55
55
  `UPDATE memory_items
56
56
  SET status = 'invalidated',
57
57
  invalid_at = ?
58
- WHERE verification_state = 'assistant_inferred'
58
+ WHERE source_type = 'extraction'
59
+ AND source_message_role = 'assistant'
59
60
  AND status = 'active'
60
61
  AND id IN (
61
62
  SELECT mis.memory_item_id
@@ -13,7 +13,12 @@ import { mintDaemonDeliveryToken } from "../../runtime/auth/token-service.js";
13
13
  import { deliverChannelReply } from "../../runtime/gateway-client.js";
14
14
  import { getLogger } from "../../util/logger.js";
15
15
  import { isConversationSeedSane } from "../conversation-seed-composer.js";
16
- import { nonEmpty } from "../copy-composer.js";
16
+ import {
17
+ buildAccessRequestIdentityLine,
18
+ buildAccessRequestInviteDirective,
19
+ nonEmpty,
20
+ sanitizeIdentityField,
21
+ } from "../copy-composer.js";
17
22
  import type {
18
23
  ChannelAdapter,
19
24
  ChannelDeliveryPayload,
@@ -41,6 +46,149 @@ function resolveSlackMessageText(payload: ChannelDeliveryPayload): string {
41
46
  return payload.sourceEventName.replace(/[._]/g, " ");
42
47
  }
43
48
 
49
+ // ---------------------------------------------------------------------------
50
+ // Block Kit helpers for access request notifications
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Build Block Kit blocks for an access request notification.
55
+ *
56
+ * Returns an array of Slack Block Kit block objects with structured layout:
57
+ * - Header: "New access request"
58
+ * - Section: requester identity details
59
+ * - Optional context: message preview
60
+ * - Context: approval code instructions + invite directive
61
+ */
62
+ export function buildAccessRequestBlocks(
63
+ payload: Record<string, unknown>,
64
+ ): unknown[] {
65
+ const blocks: unknown[] = [];
66
+
67
+ // Header
68
+ blocks.push({
69
+ type: "header",
70
+ text: { type: "plain_text", text: "New access request", emoji: true },
71
+ });
72
+
73
+ // Requester identity section
74
+ const identityLine = buildAccessRequestIdentityLine(payload);
75
+ blocks.push({
76
+ type: "section",
77
+ text: { type: "mrkdwn", text: identityLine },
78
+ });
79
+
80
+ // Build fields for structured requester details
81
+ const fields: Array<{ type: "mrkdwn"; text: string }> = [];
82
+
83
+ const senderIdentifier = nonEmpty(
84
+ typeof payload.senderIdentifier === "string"
85
+ ? sanitizeIdentityField(payload.senderIdentifier)
86
+ : undefined,
87
+ );
88
+ if (senderIdentifier) {
89
+ fields.push({ type: "mrkdwn", text: `*Name:*\n${senderIdentifier}` });
90
+ }
91
+
92
+ const actorUsername = nonEmpty(
93
+ typeof payload.actorUsername === "string"
94
+ ? sanitizeIdentityField(payload.actorUsername)
95
+ : undefined,
96
+ );
97
+ if (actorUsername) {
98
+ fields.push({ type: "mrkdwn", text: `*Username:*\n@${actorUsername}` });
99
+ }
100
+
101
+ const sourceChannel = nonEmpty(
102
+ typeof payload.sourceChannel === "string"
103
+ ? payload.sourceChannel
104
+ : undefined,
105
+ );
106
+ if (sourceChannel) {
107
+ fields.push({ type: "mrkdwn", text: `*Channel:*\n${sourceChannel}` });
108
+ }
109
+
110
+ const actorExternalId = nonEmpty(
111
+ typeof payload.actorExternalId === "string"
112
+ ? sanitizeIdentityField(payload.actorExternalId)
113
+ : undefined,
114
+ );
115
+ if (actorExternalId && actorExternalId !== senderIdentifier) {
116
+ fields.push({ type: "mrkdwn", text: `*ID:*\n${actorExternalId}` });
117
+ }
118
+
119
+ if (fields.length > 0) {
120
+ blocks.push({
121
+ type: "section",
122
+ fields,
123
+ });
124
+ }
125
+
126
+ // Previously revoked warning
127
+ const previousMemberStatus =
128
+ typeof payload.previousMemberStatus === "string"
129
+ ? payload.previousMemberStatus
130
+ : undefined;
131
+ if (previousMemberStatus === "revoked") {
132
+ blocks.push({
133
+ type: "context",
134
+ elements: [
135
+ {
136
+ type: "mrkdwn",
137
+ text: ":warning: This user was previously revoked.",
138
+ },
139
+ ],
140
+ });
141
+ }
142
+
143
+ // Divider before instructions
144
+ blocks.push({ type: "divider" });
145
+
146
+ // Approval code instructions
147
+ const requestCode = nonEmpty(
148
+ typeof payload.requestCode === "string" ? payload.requestCode : undefined,
149
+ );
150
+ if (requestCode) {
151
+ const code = requestCode.toUpperCase();
152
+ blocks.push({
153
+ type: "section",
154
+ text: {
155
+ type: "mrkdwn",
156
+ text: `Reply *\`${code} approve\`* to grant access or *\`${code} reject\`* to deny.`,
157
+ },
158
+ });
159
+ }
160
+
161
+ // Invite directive
162
+ const inviteDirective = buildAccessRequestInviteDirective();
163
+ blocks.push({
164
+ type: "context",
165
+ elements: [{ type: "mrkdwn", text: inviteDirective }],
166
+ });
167
+
168
+ // Guardian verification note
169
+ const guardianResolutionSource =
170
+ typeof payload.guardianResolutionSource === "string"
171
+ ? payload.guardianResolutionSource
172
+ : undefined;
173
+ if (
174
+ (guardianResolutionSource === "vellum-anchor" ||
175
+ guardianResolutionSource === "none") &&
176
+ sourceChannel
177
+ ) {
178
+ blocks.push({
179
+ type: "context",
180
+ elements: [
181
+ {
182
+ type: "mrkdwn",
183
+ text: `_You haven't verified your identity on ${sourceChannel} yet. If this was you trying to message your assistant, say "help me verify as guardian on ${sourceChannel}" to set up direct access._`,
184
+ },
185
+ ],
186
+ });
187
+ }
188
+
189
+ return blocks;
190
+ }
191
+
44
192
  export class SlackAdapter implements ChannelAdapter {
45
193
  readonly channel: NotificationChannel = "slack";
46
194
 
@@ -65,12 +213,26 @@ export class SlackAdapter implements ChannelAdapter {
65
213
 
66
214
  const messageText = resolveSlackMessageText(payload);
67
215
 
216
+ // Build Block Kit blocks for access request notifications
217
+ const isAccessRequest =
218
+ payload.sourceEventName === "ingress.access_request" &&
219
+ payload.contextPayload != null;
220
+
68
221
  try {
69
- await deliverChannelReply(
70
- deliverUrl,
71
- { chatId, text: messageText, useBlocks: true },
72
- mintDaemonDeliveryToken(),
73
- );
222
+ if (isAccessRequest) {
223
+ const blocks = buildAccessRequestBlocks(payload.contextPayload!);
224
+ await deliverChannelReply(
225
+ deliverUrl,
226
+ { chatId, text: messageText, blocks },
227
+ mintDaemonDeliveryToken(),
228
+ );
229
+ } else {
230
+ await deliverChannelReply(
231
+ deliverUrl,
232
+ { chatId, text: messageText, useBlocks: true },
233
+ mintDaemonDeliveryToken(),
234
+ );
235
+ }
74
236
 
75
237
  log.info(
76
238
  { sourceEventName: payload.sourceEventName, chatId },
@@ -271,6 +271,7 @@ export class NotificationBroadcaster {
271
271
  sourceEventName: signal.sourceEventName,
272
272
  copy,
273
273
  deepLinkTarget,
274
+ contextPayload: signal.contextPayload,
274
275
  };
275
276
 
276
277
  // Compute conversation decision audit fields for the delivery record
@@ -99,7 +99,14 @@ export function buildAccessRequestIdentityLine(
99
99
  const sanitizedExternalId = actorExternalId
100
100
  ? sanitizeIdentityField(actorExternalId)
101
101
  : undefined;
102
- const parts = [requester];
102
+ // When the requester is a raw Slack user ID (e.g. the fallback path in
103
+ // access-request-helper sets senderIdentifier to the raw actorExternalId),
104
+ // format it as a Slack mention so it renders as a clickable display name.
105
+ const formattedRequester =
106
+ sourceChannel === "slack" && /^U[A-Z0-9]+$/i.test(requester)
107
+ ? `<@${requester}>`
108
+ : requester;
109
+ const parts = [formattedRequester];
103
110
  if (sanitizedUsername && sanitizedUsername !== requester) {
104
111
  parts.push(`@${sanitizedUsername}`);
105
112
  }
@@ -108,7 +115,13 @@ export function buildAccessRequestIdentityLine(
108
115
  sanitizedExternalId !== requester &&
109
116
  sanitizedExternalId !== sanitizedUsername
110
117
  ) {
111
- parts.push(`[${sanitizedExternalId}]`);
118
+ // For Slack, use the <@U...> mention format so Slack auto-renders
119
+ // the user ID as a clickable display name.
120
+ const formattedId =
121
+ sourceChannel === "slack" && /^U[A-Z0-9]+$/i.test(sanitizedExternalId)
122
+ ? `<@${sanitizedExternalId}>`
123
+ : `[${sanitizedExternalId}]`;
124
+ parts.push(formattedId);
112
125
  }
113
126
  if (sourceChannel) {
114
127
  parts.push(`via ${sourceChannel}`);
@@ -117,6 +130,46 @@ export function buildAccessRequestIdentityLine(
117
130
  return `${parts.join(" ")} is requesting access to the assistant.`;
118
131
  }
119
132
 
133
+ export const MESSAGE_PREVIEW_MAX_LENGTH = 200;
134
+
135
+ /**
136
+ * Sanitize an untrusted message preview for inclusion in notification copy.
137
+ *
138
+ * Like {@link sanitizeIdentityField} but uses the higher
139
+ * MESSAGE_PREVIEW_MAX_LENGTH limit (200 chars) instead of the identity
140
+ * field limit (120 chars).
141
+ */
142
+ export function sanitizeMessagePreview(value: string): string {
143
+ const stripped = value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ").trim();
144
+ const clamped =
145
+ stripped.length > MESSAGE_PREVIEW_MAX_LENGTH
146
+ ? stripped.slice(0, MESSAGE_PREVIEW_MAX_LENGTH) + "…"
147
+ : stripped;
148
+ return clamped;
149
+ }
150
+
151
+ /**
152
+ * Build a quoted preview of the requester's original message for inclusion
153
+ * in guardian-facing access-request copy. Sanitizes and truncates to keep
154
+ * the notification concise.
155
+ *
156
+ * Returns `undefined` when no usable preview is available.
157
+ */
158
+ export function buildAccessRequestMessagePreview(
159
+ payload: Record<string, unknown>,
160
+ ): string | undefined {
161
+ const raw =
162
+ typeof payload.messagePreview === "string"
163
+ ? payload.messagePreview
164
+ : undefined;
165
+ if (!raw) return undefined;
166
+
167
+ const sanitized = sanitizeMessagePreview(raw);
168
+ if (sanitized.length === 0) return undefined;
169
+
170
+ return `> Their message: "${sanitized}"`;
171
+ }
172
+
120
173
  export function buildAccessRequestInviteDirective(): string {
121
174
  return 'Reply "open invite flow" to start Trusted Contacts invite flow.';
122
175
  }
@@ -227,6 +280,10 @@ export function buildAccessRequestContractText(
227
280
 
228
281
  const lines: string[] = [];
229
282
  lines.push(buildAccessRequestIdentityLine(payload));
283
+ const preview = buildAccessRequestMessagePreview(payload);
284
+ if (preview) {
285
+ lines.push(preview);
286
+ }
230
287
  if (previousMemberStatus === "revoked") {
231
288
  lines.push("Note: this user was previously revoked.");
232
289
  }