@vellumai/assistant 0.8.11 → 0.8.12-staging.2

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 (244) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +633 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  37. package/src/__tests__/llm-usage-store.test.ts +11 -0
  38. package/src/__tests__/log-export-workspace.test.ts +468 -3
  39. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  40. package/src/__tests__/model-intents.test.ts +1 -1
  41. package/src/__tests__/oauth-cli.test.ts +19 -8
  42. package/src/__tests__/openai-provider.test.ts +34 -0
  43. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  44. package/src/__tests__/recurrence-engine.test.ts +45 -0
  45. package/src/__tests__/schedule-routes.test.ts +34 -0
  46. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  47. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  48. package/src/__tests__/secret-fixtures.ts +20 -0
  49. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  50. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  51. package/src/__tests__/subagent-tools.test.ts +1 -0
  52. package/src/__tests__/system-prompt.test.ts +109 -11
  53. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  54. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  55. package/src/__tests__/tool-error-hook.test.ts +1 -0
  56. package/src/__tests__/tool-result-spool.test.ts +337 -0
  57. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  58. package/src/__tests__/validate-input.test.ts +95 -1
  59. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  60. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  61. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  62. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  63. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  64. package/src/acp/__tests__/agent-process.test.ts +315 -2
  65. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  66. package/src/acp/agent-process.ts +163 -34
  67. package/src/acp/prepare-agent-env.ts +55 -15
  68. package/src/agent/loop.ts +81 -24
  69. package/src/api/events/usage-progress.ts +28 -0
  70. package/src/api/index.ts +6 -0
  71. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  72. package/src/bundler/app-bundler.ts +25 -42
  73. package/src/bundler/app-compiler.ts +8 -0
  74. package/src/calls/call-controller.ts +1 -1
  75. package/src/cli/commands/plugins.ts +248 -15
  76. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  77. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  78. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  79. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  80. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  81. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
  82. package/src/cli/lib/inspect-plugin.ts +252 -0
  83. package/src/cli/lib/install-from-github.ts +214 -21
  84. package/src/cli/lib/list-installed-plugins.ts +17 -6
  85. package/src/cli/lib/plugin-artifact.ts +103 -0
  86. package/src/cli/lib/plugin-details.ts +18 -1
  87. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  88. package/src/cli/lib/upgrade-plugin.ts +225 -0
  89. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  90. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  91. package/src/config/call-site-defaults.ts +13 -2
  92. package/src/config/feature-flag-registry.json +8 -16
  93. package/src/config/loader.ts +52 -59
  94. package/src/config/schema.ts +0 -2
  95. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  96. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  97. package/src/config/schemas/llm.ts +10 -0
  98. package/src/config/schemas/memory-v2.ts +13 -0
  99. package/src/config/schemas/memory-v3.ts +92 -0
  100. package/src/config/seed-inference-profiles.ts +4 -8
  101. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  102. package/src/context/tool-result-spool.ts +104 -0
  103. package/src/credential-execution/feature-gates.ts +0 -1
  104. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  105. package/src/daemon/conversation-error.ts +6 -15
  106. package/src/daemon/conversation.ts +9 -0
  107. package/src/daemon/disk-pressure-policy.ts +0 -1
  108. package/src/daemon/lifecycle.ts +1 -20
  109. package/src/daemon/message-types/conversations.ts +2 -15
  110. package/src/daemon/trust-context.ts +1 -1
  111. package/src/events/tool-audit-listener.ts +40 -9
  112. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  113. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  114. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  115. package/src/home/home-content-refresh.ts +43 -31
  116. package/src/home/home-greeting-cache.ts +8 -1
  117. package/src/home/home-greeting.ts +13 -9
  118. package/src/home/suggested-prompts.ts +77 -24
  119. package/src/ipc/routes/trust-rules.test.ts +66 -72
  120. package/src/media/image-credentials.ts +2 -2
  121. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  122. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  123. package/src/memory/conversation-attention-store.ts +1 -0
  124. package/src/memory/conversation-bootstrap.ts +18 -9
  125. package/src/memory/conversation-crud.ts +12 -2
  126. package/src/memory/conversation-title-service.ts +53 -9
  127. package/src/memory/delivery-channels.ts +0 -69
  128. package/src/memory/graph/extraction-job.ts +0 -15
  129. package/src/memory/guardian-action-store.ts +1 -376
  130. package/src/memory/llm-usage-store.ts +5 -1
  131. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  132. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  133. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  134. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  135. package/src/memory/v2/consolidation-job.ts +93 -9
  136. package/src/memory/v2/injection.ts +53 -0
  137. package/src/memory/v2/prompts/consolidation.ts +1 -0
  138. package/src/memory/v2/static-context.ts +13 -1
  139. package/src/memory/v2/sweep-job.ts +1 -1
  140. package/src/memory/v2/types.ts +5 -0
  141. package/src/plugin-api/types.ts +7 -0
  142. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  143. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  144. package/src/plugins/defaults/index.ts +25 -0
  145. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  146. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  147. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  148. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  149. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  150. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  151. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  152. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  153. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  154. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  155. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  156. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  157. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  158. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  159. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  160. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  161. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  162. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  163. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  164. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  165. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  166. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  167. package/src/prompts/cache-boundary.ts +17 -0
  168. package/src/prompts/sections.ts +50 -17
  169. package/src/prompts/system-prompt.ts +12 -4
  170. package/src/prompts/templates/system-sections.ts +22 -0
  171. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  172. package/src/providers/anthropic/client.ts +74 -28
  173. package/src/providers/gemini/client.ts +5 -1
  174. package/src/providers/minimax/client.ts +9 -0
  175. package/src/providers/model-catalog.ts +28 -0
  176. package/src/providers/model-intents.ts +3 -3
  177. package/src/providers/openai/chat-completions-provider.ts +4 -2
  178. package/src/providers/openai/responses-provider.ts +7 -2
  179. package/src/providers/retry.ts +8 -0
  180. package/src/providers/types.ts +11 -0
  181. package/src/providers/unparseable-tool-args.ts +56 -0
  182. package/src/runtime/AGENTS.md +6 -0
  183. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  184. package/src/runtime/agent-wake.ts +5 -5
  185. package/src/runtime/background-job-runner.ts +2 -2
  186. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  187. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  188. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  189. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  190. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  191. package/src/runtime/pre-first-message-gate.ts +1 -1
  192. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  193. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  194. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  195. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  196. package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
  197. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  198. package/src/runtime/routes/btw-routes.ts +0 -14
  199. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  200. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  201. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  202. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  203. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  204. package/src/runtime/routes/home-feed-routes.ts +10 -0
  205. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  206. package/src/runtime/routes/identity-routes.ts +76 -20
  207. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  208. package/src/runtime/routes/log-export-routes.ts +143 -96
  209. package/src/runtime/routes/plugins-routes.ts +380 -0
  210. package/src/runtime/routes/redact-staged-export.ts +259 -0
  211. package/src/runtime/routes/schedule-routes.ts +19 -2
  212. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  213. package/src/schedule/recurrence-engine.ts +34 -0
  214. package/src/schedule/scheduler.ts +1 -0
  215. package/src/security/redact-json.ts +61 -0
  216. package/src/skills/validate-input.ts +41 -1
  217. package/src/subagent/types.ts +26 -1
  218. package/src/telemetry/types.ts +15 -1
  219. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  220. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  221. package/src/tools/apps/executors.ts +1 -1
  222. package/src/tools/skills/skill-tool-factory.ts +19 -8
  223. package/src/tools/tool-approval-handler.ts +31 -0
  224. package/src/usage/types.ts +8 -1
  225. package/src/util/platform.ts +16 -0
  226. package/src/watcher/engine.ts +1 -0
  227. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  228. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  229. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  230. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  231. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  232. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  233. package/src/workspace/migrations/registry.ts +8 -0
  234. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  235. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  236. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  237. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  238. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  239. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  240. package/src/config/schemas/updates.ts +0 -14
  241. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  242. package/src/memory/conversation-starters-cadence.ts +0 -78
  243. package/src/prompts/update-bulletin-job.ts +0 -180
  244. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -16,11 +16,12 @@ import type {
16
16
  } from "../channels/types.js";
17
17
  import { getConfig } from "../config/loader.js";
18
18
  import { recordEstimate } from "../context/estimator-calibration.js";
19
+ import { stripInjectionsForCompaction } from "../context/strip-injections.js";
19
20
  import { getCalibrationProviderKey } from "../context/token-estimator.js";
20
21
  import {
21
22
  recordCompactionEndBestEffort,
22
23
  recordCompactionStartBestEffort,
23
- } from "../memory/compaction-log-writer-clickhouse.js";
24
+ } from "../memory/compaction-log-store-clickhouse.js";
24
25
  import { projectAssistantMessage } from "../memory/conversation-attention-store.js";
25
26
  import {
26
27
  deleteMessageById,
@@ -286,6 +287,14 @@ export interface EventHandlerState {
286
287
  * never claims content a flush has not yet written.
287
288
  */
288
289
  lastPersistedContentSeq: number | undefined;
290
+ /**
291
+ * Pre-compaction history buffered from `context_compacting` start events,
292
+ * keyed by `compactionId`. The paired `compaction_completed` event no
293
+ * longer carries the pre-compaction history, so the dispatcher re-derives
294
+ * the stripped durable base from the buffered start messages. Entries are
295
+ * consumed (deleted) when the end event dispatches.
296
+ */
297
+ readonly compactionStartMessages: Map<string, Message[]>;
289
298
  }
290
299
 
291
300
  /** Immutable context shared across event handlers within a single agent loop run. */
@@ -301,8 +310,8 @@ export interface EventHandlerDeps {
301
310
  readonly turnInterfaceContext: TurnInterfaceContext;
302
311
  /**
303
312
  * Commit a successful inline compaction to durable state. Invoked from the
304
- * `compaction_completed` dispatch case (when `result.compacted`) with the
305
- * loop's compaction result and the stripped pre-compaction `messages`. Supplied
313
+ * `compaction_completed` dispatch case (when `compacted`) with the
314
+ * loop's compaction result and the stripped pre-compaction history. Supplied
306
315
  * by the orchestrator because the body writes Conversation DB-record fields,
307
316
  * projects Slack provenance, and emits transport the loop is intentionally
308
317
  * blind to.
@@ -361,6 +370,7 @@ export function createEventHandlerState(): EventHandlerState {
361
370
  currentMessageContent: [],
362
371
  currentThinkingTimestamps: [],
363
372
  lastPersistedContentSeq: undefined,
373
+ compactionStartMessages: new Map(),
364
374
  };
365
375
  }
366
376
 
@@ -2316,6 +2326,9 @@ export async function dispatchAgentEvent(
2316
2326
  }
2317
2327
  case "context_compacting":
2318
2328
  recordCompactionStartBestEffort(deps.ctx.conversationId, event);
2329
+ // Buffer the pre-compaction history so the paired end event can
2330
+ // re-derive the stripped durable base.
2331
+ state.compactionStartMessages.set(event.compactionId, event.messages);
2319
2332
  deps.ctx.emitActivityState("thinking", "context_compacting", {
2320
2333
  requestId: deps.reqId,
2321
2334
  statusText: "Compacting context",
@@ -2329,22 +2342,34 @@ export async function dispatchAgentEvent(
2329
2342
  // banner.
2330
2343
  deps.onEvent(event);
2331
2344
  break;
2332
- case "compaction_completed":
2333
- // Always commit the loop-stripped `messages` as the durable message base
2334
- // so re-injection re-applies onto the stripped history even when the
2335
- // pipeline ran but did not compact. When it did compact, commit the
2336
- // durable result (DB-record fields, Slack provenance, SSE) — which
2337
- // overwrites `ctx.messages` with the compacted history. This runs
2338
- // before the loop's `reinject` hook (the loop awaits this dispatch),
2339
- // so the committed history is in place in time. A failed durable
2340
- // commit re-throws below to abort the turn rather than re-injecting
2341
- // against half-applied state.
2345
+ case "compaction_completed": {
2346
+ // Always commit the stripped pre-compaction history as the durable
2347
+ // message base so re-injection re-applies onto the stripped history
2348
+ // even when the pipeline ran but did not compact. The base is
2349
+ // re-derived from the buffered start event's messages (the end event
2350
+ // carries only the pipeline's output). When the pipeline did compact,
2351
+ // commit the durable result (DB-record fields, Slack provenance,
2352
+ // SSE) — which overwrites `ctx.messages` with the compacted history.
2353
+ // This runs before the loop's `reinject` hook (the loop awaits this
2354
+ // dispatch), so the committed history is in place in time. A failed
2355
+ // durable commit re-throws below to abort the turn rather than
2356
+ // re-injecting against half-applied state.
2342
2357
  recordCompactionEndBestEffort(deps.ctx.conversationId, event);
2343
- deps.ctx.messages = event.messages;
2344
- if (event.result.compacted) {
2345
- await deps.applyCompaction(event.result, event.messages);
2358
+ const startMessages = state.compactionStartMessages.get(
2359
+ event.compactionId,
2360
+ );
2361
+ state.compactionStartMessages.delete(event.compactionId);
2362
+ // Fall back to the pipeline's output when the start event was never
2363
+ // buffered — on the no-compaction path it is the stripped input.
2364
+ const strippedBase = startMessages
2365
+ ? stripInjectionsForCompaction(startMessages)
2366
+ : event.messages;
2367
+ deps.ctx.messages = strippedBase;
2368
+ if (event.compacted) {
2369
+ await deps.applyCompaction(event, strippedBase);
2346
2370
  }
2347
2371
  break;
2372
+ }
2348
2373
  case "history_stripped":
2349
2374
  // Record the history-stripped DB marker right after the loop strips
2350
2375
  // injections (before the pipeline). Best-effort: a transient marker
@@ -255,7 +255,7 @@ export function classifyConversationError(
255
255
  return {
256
256
  code: "PROVIDER_NOT_CONFIGURED",
257
257
  userMessage:
258
- "No compatible provider connection found for this profile. Check your provider connections in Settings.",
258
+ "No compatible provider connection found for this profile. Check your provider connections in Settings → Models & Services.",
259
259
  retryable: true,
260
260
  debugDetails,
261
261
  errorCategory: "provider_not_configured",
@@ -334,15 +334,6 @@ function classifyCore(
334
334
  }
335
335
  return invalidApiKeyClassification(attribution);
336
336
  }
337
- if (error.statusCode === 401) {
338
- return {
339
- code: "PROVIDER_INVALID_KEY",
340
- userMessage:
341
- "Your API key is invalid or expired. Update it in Settings or switch to managed mode.",
342
- retryable: false,
343
- errorCategory: "provider_invalid_key",
344
- };
345
- }
346
337
  if (error.statusCode === 402) {
347
338
  if (isManagedBalanceError(error)) {
348
339
  return managedBalanceClassification();
@@ -543,7 +534,7 @@ function providerBillingClassification(): Omit<
543
534
  return {
544
535
  code: "PROVIDER_BILLING",
545
536
  userMessage:
546
- "Your API provider account or key needs credits. Add funds with the provider or update the key in Settings.",
537
+ "Your API provider account or key needs credits. Add funds with the provider or update the key in Settings → Models & Services.",
547
538
  retryable: false,
548
539
  errorCategory: "provider_billing",
549
540
  };
@@ -583,8 +574,8 @@ function invalidApiKeyClassification(
583
574
  return {
584
575
  code: "PROVIDER_INVALID_KEY",
585
576
  userMessage: target
586
- ? `The API key${target} was rejected by the provider. Update it in Settings.`
587
- : "Your API key was rejected by the provider. Update it in Settings.",
577
+ ? `The API key${target} was rejected by the provider. Update it in Settings → Models & Services.`
578
+ : "Your API key was rejected by the provider. Update it in Settings → Models & Services.",
588
579
  retryable: false,
589
580
  errorCategory: "provider_invalid_key",
590
581
  ...(attribution?.connectionName
@@ -610,8 +601,8 @@ function providerNotConfiguredClassification(
610
601
  return {
611
602
  code: "PROVIDER_NOT_CONFIGURED",
612
603
  userMessage: target
613
- ? `No API key configured${target}. Add one in Settings to start chatting.`
614
- : "No API key configured for inference. Add one in Settings to start chatting.",
604
+ ? `No API key configured${target}. Add one in Settings → Models & Services to start chatting.`
605
+ : "No API key configured for inference. Add one in Settings → Models & Services to start chatting.",
615
606
  retryable: true,
616
607
  errorCategory: "provider_not_configured",
617
608
  ...(attribution?.connectionName
@@ -51,6 +51,7 @@ import {
51
51
  resolveOverrideProfile,
52
52
  setConversationHistoryStrippedAt,
53
53
  } from "../memory/conversation-crud.js";
54
+ import { getResolvedConversationDirPath } from "../memory/conversation-directories.js";
54
55
  import { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
55
56
  import { unwrapMemoryBlock, wrapMemoryBlock } from "../memory/memory-marker.js";
56
57
  import { PermissionPrompter } from "../permissions/prompter.js";
@@ -638,6 +639,14 @@ export class Conversation {
638
639
  toolExecutor: toolDefs.length > 0 ? toolExecutor : undefined,
639
640
  resolveTools,
640
641
  resolveSystemPrompt: resolveSystemPromptCallback,
642
+ resolveConversationDir: () => {
643
+ const conv = getConversation(this.conversationId);
644
+ if (!conv) return null;
645
+ return getResolvedConversationDirPath(
646
+ this.conversationId,
647
+ conv.createdAt,
648
+ );
649
+ },
641
650
  });
642
651
  createContextWindowManager({
643
652
  provider,
@@ -54,7 +54,6 @@ const BACKGROUND_SOURCES = new Set([
54
54
  "reminder",
55
55
  "schedule",
56
56
  "task",
57
- "update-bulletin",
58
57
  ]);
59
58
  const LOCAL_OWNER_INTERFACES = new Set(["macos", "web", "vellum", "cli"]);
60
59
 
@@ -35,7 +35,6 @@ import {
35
35
  } from "../credential-execution/startup-timeout.js";
36
36
  import { FilingService } from "../filing/filing-service.js";
37
37
  import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
38
- import { startHomeContentRefresh } from "../home/home-content-refresh.js";
39
38
  import { backfillRelationshipStateIfMissing } from "../home/relationship-state-writer.js";
40
39
  import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
41
40
  import {
@@ -98,8 +97,8 @@ import {
98
97
  listWorkItems,
99
98
  updateWorkItem,
100
99
  } from "../work-items/work-item-store.js";
100
+ import { repairAdaptiveThinkingOnManagedProfiles } from "../workspace/adaptive-thinking-repair.js";
101
101
  import { WorkspaceHeartbeatService } from "../workspace/heartbeat-service.js";
102
- import { repairAdaptiveThinkingOnManagedProfiles } from "../workspace/migrations/097-enable-adaptive-thinking-managed-profiles.js";
103
102
  import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
104
103
  import { runWorkspaceMigrations } from "../workspace/migrations/runner.js";
105
104
  import {
@@ -480,10 +479,6 @@ export async function runDaemon(): Promise<void> {
480
479
  );
481
480
  });
482
481
 
483
- // Pre-warm LLM-generated home page content (greeting + suggestion
484
- // prompts) so the GET handler never triggers LLM calls.
485
- startHomeContentRefresh();
486
-
487
482
  // Backfill injection templates on Slack bot token credentials so the
488
483
  // credential proxy can inject Authorization headers. Safe on every startup.
489
484
  try {
@@ -780,20 +775,6 @@ export async function runDaemon(): Promise<void> {
780
775
  startDiskPressureGuardForLifecycle();
781
776
  startOrphanReaper();
782
777
 
783
- // Kick off the update bulletin background job AFTER `server.start()`
784
- // resolves. The conversation store must be initialized before wake
785
- // calls can resolve targets.
786
- //
787
- // Kept fire-and-forget (`void import(...).then(...).catch(...)`) so the
788
- // daemon never blocks startup on it.
789
- if (dbReady) {
790
- void import("../prompts/update-bulletin-job.js")
791
- .then((m) => m.runUpdateBulletinJobIfNeeded())
792
- .catch((err) =>
793
- log.warn({ err }, "Update bulletin job failed — continuing startup"),
794
- );
795
- }
796
-
797
778
  // Mutable refs for Qdrant and memory worker so background
798
779
  // init can assign them and the shutdown handler always sees the latest value.
799
780
  const bgRefs: {
@@ -7,6 +7,7 @@ import type { ConversationListInvalidatedEvent } from "../../api/events/conversa
7
7
  import type { ConversationTitleUpdatedEvent } from "../../api/events/conversation-title-updated.js";
8
8
  import type { GenerationCancelledEvent } from "../../api/events/generation-cancelled.js";
9
9
  import type { GenerationHandoffEvent } from "../../api/events/generation-handoff.js";
10
+ import type { UsageProgressEvent } from "../../api/events/usage-progress.js";
10
11
  import type { UsageUpdateEvent } from "../../api/events/usage-update.js";
11
12
  import type {
12
13
  ChannelId,
@@ -425,20 +426,6 @@ export interface UndoComplete {
425
426
  conversationId?: string;
426
427
  }
427
428
 
428
- /**
429
- * Emitted after each LLM call with per-call token deltas and estimated cost.
430
- * Clients accumulate these additively for live-updating usage metrics.
431
- * This is a UI-only hint — it does not persist to DB or affect billing.
432
- */
433
- export interface UsageProgress {
434
- type: "usage_progress";
435
- conversationId: string;
436
- inputTokens: number;
437
- outputTokens: number;
438
- estimatedCost: number;
439
- model: string;
440
- }
441
-
442
429
  export interface UsageResponse {
443
430
  type: "usage_response";
444
431
  totalInputTokens: number;
@@ -550,7 +537,7 @@ export type _ConversationsServerMessages =
550
537
  | HistoryResponse
551
538
  | UndoComplete
552
539
  | UsageUpdateEvent
553
- | UsageProgress
540
+ | UsageProgressEvent
554
541
  | UsageResponse
555
542
  | ContextCompacted
556
543
  | CompactionCircuitOpenEvent
@@ -42,7 +42,7 @@ export interface TrustContext {
42
42
 
43
43
  /**
44
44
  * Trust context used by internal background jobs (memory consolidation,
45
- * update bulletin, scheduled tasks) when invoking the agent loop without
45
+ * scheduled tasks) when invoking the agent loop without
46
46
  * an inbound actor identity. The assistant is the guardian over its own
47
47
  * internal state, so self-maintenance flows clear the side-effect
48
48
  * approval gate. Inbound message conversations resolve trust per-actor
@@ -3,6 +3,7 @@ import {
3
3
  recordToolInvocation,
4
4
  type ToolInvocationRecord,
5
5
  } from "../memory/tool-usage-store.js";
6
+ import { redactJsonStringLeaves } from "../security/redact-json.js";
6
7
  import { redactSecrets } from "../security/secret-scanner.js";
7
8
  import {
8
9
  stringifyToolInput,
@@ -43,11 +44,14 @@ function toInvocationRecord(
43
44
  ): ToolInvocationRecord | null {
44
45
  switch (event.type) {
45
46
  case "executed": {
46
- const input = stringifyToolInput(event.input);
47
+ const rawInput = stringifyToolInput(event.input);
47
48
  return {
48
49
  conversationId: event.conversationId,
49
50
  toolName: event.toolName,
50
- input,
51
+ // Inputs can carry secrets the model typed verbatim (e.g.
52
+ // `export OPENAI_API_KEY=...` in a bash command) — redact before
53
+ // the row reaches the audit store, like results below.
54
+ input: redactToolInput(event.input, rawInput),
51
55
  result: redactSecrets(event.result.content).slice(
52
56
  0,
53
57
  RESULT_PREVIEW_LIMIT,
@@ -63,18 +67,18 @@ function toInvocationRecord(
63
67
  // don't stamp.
64
68
  ...telemetryColumns(
65
69
  event,
66
- input,
70
+ rawInput,
67
71
  event.resultBytes ?? Buffer.byteLength(event.result.content, "utf8"),
68
72
  ),
69
73
  };
70
74
  }
71
75
  case "error": {
72
- const input = stringifyToolInput(event.input);
76
+ const rawInput = stringifyToolInput(event.input);
73
77
  const result = `error: ${event.errorMessage}`;
74
78
  return {
75
79
  conversationId: event.conversationId,
76
80
  toolName: event.toolName,
77
- input,
81
+ input: redactToolInput(event.input, rawInput),
78
82
  result,
79
83
  decision: "error",
80
84
  riskLevel: event.riskLevel,
@@ -83,7 +87,7 @@ function toInvocationRecord(
83
87
  // The error result string is built right here and never goes
84
88
  // through sensitive-output sanitization, so sizing it directly is
85
89
  // already raw — no executor stamp exists or is needed.
86
- ...telemetryColumns(event, input, Buffer.byteLength(result, "utf8")),
90
+ ...telemetryColumns(event, rawInput, Buffer.byteLength(result, "utf8")),
87
91
  };
88
92
  }
89
93
  case "permission_denied":
@@ -92,7 +96,7 @@ function toInvocationRecord(
92
96
  return {
93
97
  conversationId: event.conversationId,
94
98
  toolName: event.toolName,
95
- input: stringifyToolInput(event.input),
99
+ input: redactToolInput(event.input, stringifyToolInput(event.input)),
96
100
  result: formatDeniedResult(event.reason),
97
101
  decision: "denied",
98
102
  riskLevel: event.riskLevel,
@@ -105,6 +109,33 @@ function toInvocationRecord(
105
109
  }
106
110
  }
107
111
 
112
+ /**
113
+ * Redact secrets from a tool input while keeping the stored audit string
114
+ * parseable JSON. The redaction marker (`<redacted type="..." />`) contains
115
+ * double quotes, so redacting the serialized string would corrupt it —
116
+ * instead, walk the input's string leaves BEFORE stringification so the
117
+ * marker lands inside a JSON string value (with its quotes escaped).
118
+ *
119
+ * `rawInput` is the canonical pre-redaction serialization (also used for
120
+ * the `argBytes` telemetry fallback — byte sizes must reflect the full
121
+ * payload before truncation and redaction). It is returned untouched when
122
+ * nothing matched, keeping benign inputs byte-identical, and is redacted as
123
+ * plain text if the input can't be walked or re-serialized (e.g. cyclic
124
+ * structures).
125
+ */
126
+ function redactToolInput(
127
+ input: Record<string, unknown>,
128
+ rawInput: string,
129
+ ): string {
130
+ try {
131
+ const { value, changed } = redactJsonStringLeaves(input);
132
+ if (!changed) return rawInput;
133
+ return JSON.stringify(value);
134
+ } catch {
135
+ return redactSecrets(rawInput);
136
+ }
137
+ }
138
+
108
139
  type TelemetryColumns = Pick<ToolInvocationRecord, "argBytes" | "resultBytes"> &
109
140
  UsageAttributionColumns;
110
141
 
@@ -134,12 +165,12 @@ const NULL_TELEMETRY_COLUMNS: TelemetryColumns = {
134
165
  */
135
166
  function telemetryColumns(
136
167
  event: Extract<ToolLifecycleEvent, { type: "executed" | "error" }>,
137
- input: string,
168
+ rawInput: string,
138
169
  resultBytes: number,
139
170
  ): TelemetryColumns {
140
171
  if (!getConfig().collectUsageData) return NULL_TELEMETRY_COLUMNS;
141
172
  return {
142
- argBytes: event.inputBytes ?? Buffer.byteLength(input, "utf8"),
173
+ argBytes: event.inputBytes ?? Buffer.byteLength(rawInput, "utf8"),
143
174
  resultBytes,
144
175
  ...toAttributionColumns(event.attribution),
145
176
  };
@@ -114,7 +114,7 @@ mock.module("../../config/loader.js", () => ({
114
114
  getNestedValue: () => undefined,
115
115
  setNestedValue: () => {},
116
116
  API_KEY_PROVIDERS: [],
117
- _appendQuarantineBulletin: () => {},
117
+ _writeQuarantineNotice: () => {},
118
118
  }));
119
119
 
120
120
  // Stub prompt helpers.
@@ -0,0 +1,114 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // ─── Mocks ─────────────────────────────────────────────────────────────
4
+
5
+ let greetingRefreshed = false;
6
+ let promptsRefreshed = false;
7
+ let greetingCalls = 0;
8
+ let promptsCalls = 0;
9
+ let greetingGate: Promise<void> = Promise.resolve();
10
+
11
+ mock.module("../home-greeting.js", () => ({
12
+ refreshPersonalizedGreeting: async () => {
13
+ greetingCalls++;
14
+ await greetingGate;
15
+ return greetingRefreshed;
16
+ },
17
+ }));
18
+
19
+ mock.module("../suggested-prompts.js", () => ({
20
+ refreshAssistantSuggestedPrompts: async () => {
21
+ promptsCalls++;
22
+ return promptsRefreshed;
23
+ },
24
+ }));
25
+
26
+ const publishSpy = mock<(event: unknown) => Promise<void>>(async () => {});
27
+ mock.module("../../runtime/assistant-event-hub.js", () => ({
28
+ assistantEventHub: { publish: publishSpy },
29
+ }));
30
+
31
+ mock.module("../../runtime/assistant-event.js", () => ({
32
+ buildAssistantEvent: (e: unknown) => e,
33
+ }));
34
+
35
+ mock.module("../../util/logger.js", () => ({
36
+ getLogger: () =>
37
+ new Proxy({} as Record<string, unknown>, {
38
+ get: () => () => {},
39
+ }),
40
+ }));
41
+
42
+ const { revalidateHomeContentInBackground } =
43
+ await import("../home-content-refresh.js");
44
+
45
+ async function settle(): Promise<void> {
46
+ // Let the fire-and-forget revalidation chain run to completion.
47
+ await new Promise((resolve) => setTimeout(resolve, 0));
48
+ await new Promise((resolve) => setTimeout(resolve, 0));
49
+ }
50
+
51
+ // ─── Tests ─────────────────────────────────────────────────────────────
52
+
53
+ describe("revalidateHomeContentInBackground", () => {
54
+ test("publishes home_feed_updated when content was refreshed", async () => {
55
+ publishSpy.mockClear();
56
+ greetingRefreshed = true;
57
+ promptsRefreshed = false;
58
+
59
+ revalidateHomeContentInBackground();
60
+ await settle();
61
+
62
+ expect(publishSpy).toHaveBeenCalledTimes(1);
63
+ expect(publishSpy.mock.calls[0]?.[0]).toMatchObject({
64
+ type: "home_feed_updated",
65
+ });
66
+ });
67
+
68
+ test("does not publish when both caches were fresh", async () => {
69
+ publishSpy.mockClear();
70
+ greetingRefreshed = false;
71
+ promptsRefreshed = false;
72
+
73
+ revalidateHomeContentInBackground();
74
+ await settle();
75
+
76
+ expect(publishSpy).not.toHaveBeenCalled();
77
+ });
78
+
79
+ test("concurrent calls share a single in-flight revalidation", async () => {
80
+ greetingCalls = 0;
81
+ promptsCalls = 0;
82
+ greetingRefreshed = false;
83
+ promptsRefreshed = false;
84
+
85
+ let release!: () => void;
86
+ greetingGate = new Promise((resolve) => {
87
+ release = resolve;
88
+ });
89
+
90
+ revalidateHomeContentInBackground();
91
+ revalidateHomeContentInBackground();
92
+ revalidateHomeContentInBackground();
93
+
94
+ release();
95
+ greetingGate = Promise.resolve();
96
+ await settle();
97
+
98
+ expect(greetingCalls).toBe(1);
99
+ expect(promptsCalls).toBe(1);
100
+ });
101
+
102
+ test("a completed run allows a later revalidation", async () => {
103
+ greetingCalls = 0;
104
+ greetingRefreshed = false;
105
+ promptsRefreshed = false;
106
+
107
+ revalidateHomeContentInBackground();
108
+ await settle();
109
+ revalidateHomeContentInBackground();
110
+ await settle();
111
+
112
+ expect(greetingCalls).toBe(2);
113
+ });
114
+ });
@@ -4,6 +4,25 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
5
  const mockIntegrationSummary = "Gmail ✓ | Slack ✓ | Twilio ✗ | Telegram ✗";
6
6
  let mockSidechainText = "";
7
+ let sidechainCalls = 0;
8
+
9
+ // In-memory stand-in for the memory_checkpoints table so the cache
10
+ // round-trips without a real database.
11
+ const checkpointStore = new Map<string, string>();
12
+ let checkpointWritesShouldThrow = false;
13
+
14
+ mock.module("../../memory/checkpoints.js", () => ({
15
+ getMemoryCheckpoint: (key: string) => checkpointStore.get(key) ?? null,
16
+ setMemoryCheckpoint: (key: string, value: string) => {
17
+ if (checkpointWritesShouldThrow) {
18
+ throw new Error("synthetic checkpoint write failure");
19
+ }
20
+ checkpointStore.set(key, value);
21
+ },
22
+ deleteMemoryCheckpoint: (key: string) => {
23
+ checkpointStore.delete(key);
24
+ },
25
+ }));
7
26
 
8
27
  mock.module("../../schedule/integration-status.js", () => ({
9
28
  formatIntegrationSummary: async () => mockIntegrationSummary,
@@ -41,7 +60,10 @@ mock.module("../../prompts/system-prompt.js", () => ({
41
60
  }));
42
61
 
43
62
  mock.module("../../runtime/btw-sidechain.js", () => ({
44
- runBtwSidechain: async () => ({ text: mockSidechainText }),
63
+ runBtwSidechain: async () => {
64
+ sidechainCalls++;
65
+ return { text: mockSidechainText };
66
+ },
45
67
  }));
46
68
 
47
69
  mock.module("../../runtime/assistant-event.js", () => ({
@@ -62,8 +84,10 @@ const {
62
84
 
63
85
  describe("getSuggestedPrompts", () => {
64
86
  afterEach(() => {
87
+ checkpointWritesShouldThrow = false;
65
88
  invalidateAssistantSuggestedPromptsCache();
66
89
  mockSidechainText = "";
90
+ sidechainCalls = 0;
67
91
  });
68
92
 
69
93
  test("returns empty array before cache is populated", async () => {
@@ -98,12 +122,17 @@ describe("getSuggestedPrompts", () => {
98
122
  expect(await getSuggestedPrompts()).toEqual([]);
99
123
  });
100
124
 
101
- test("handles empty LLM response gracefully", async () => {
125
+ test("handles empty LLM response gracefully and caches the empty result", async () => {
102
126
  mockSidechainText = "";
103
127
 
104
- await refreshAssistantSuggestedPrompts();
105
- const prompts = await getSuggestedPrompts();
106
- expect(prompts).toEqual([]);
128
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
129
+ expect(await getSuggestedPrompts()).toEqual([]);
130
+
131
+ // The empty result is cached: the next refresh within the TTL must
132
+ // not hit the LLM again (prevents a generation loop on every Home
133
+ // feed GET while the model returns nothing usable).
134
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
135
+ expect(sidechainCalls).toBe(1);
107
136
  });
108
137
 
109
138
  test("handles malformed LLM response gracefully", async () => {
@@ -113,4 +142,56 @@ describe("getSuggestedPrompts", () => {
113
142
  const prompts = await getSuggestedPrompts();
114
143
  expect(prompts).toEqual([]);
115
144
  });
145
+
146
+ test("refresh reports whether new content was generated", async () => {
147
+ mockSidechainText = JSON.stringify([
148
+ { label: "Do stuff", prompt: "Do stuff for me" },
149
+ ]);
150
+
151
+ expect(await refreshAssistantSuggestedPrompts()).toBe(true);
152
+ // Cache is fresh — second refresh must not hit the LLM again.
153
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
154
+ expect(sidechainCalls).toBe(1);
155
+ });
156
+
157
+ test("refresh reports false when the cache write fails", async () => {
158
+ mockSidechainText = JSON.stringify([
159
+ { label: "Do stuff", prompt: "Do stuff for me" },
160
+ ]);
161
+ checkpointWritesShouldThrow = true;
162
+
163
+ // The generation succeeded but nothing was cached, so the caller must
164
+ // not announce fresh content (clients would refetch into a cache miss
165
+ // and re-trigger generation on every Home load).
166
+ expect(await refreshAssistantSuggestedPrompts()).toBe(false);
167
+ expect(await getSuggestedPrompts()).toEqual([]);
168
+ });
169
+
170
+ test("refresh regenerates after invalidation", async () => {
171
+ mockSidechainText = JSON.stringify([
172
+ { label: "Do stuff", prompt: "Do stuff for me" },
173
+ ]);
174
+
175
+ await refreshAssistantSuggestedPrompts();
176
+ invalidateAssistantSuggestedPromptsCache();
177
+
178
+ expect(await refreshAssistantSuggestedPrompts()).toBe(true);
179
+ expect(sidechainCalls).toBe(2);
180
+ });
181
+
182
+ test("cache survives across module state (checkpoint-backed)", async () => {
183
+ mockSidechainText = JSON.stringify([
184
+ { label: "Persisted", prompt: "I came from the checkpoint store" },
185
+ ]);
186
+
187
+ await refreshAssistantSuggestedPrompts();
188
+ // Simulate the read path a fresh daemon would take: the prompts come
189
+ // back from the checkpoint store, not module-local memory.
190
+ expect(checkpointStore.get("home:suggested_prompts:json")).toContain(
191
+ "Persisted",
192
+ );
193
+ const prompts = await getSuggestedPrompts();
194
+ expect(prompts).toHaveLength(1);
195
+ expect(prompts[0]!.label).toBe("Persisted");
196
+ });
116
197
  });