@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) 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 +126 -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-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -63,6 +63,86 @@ export const MemoryV3HotSetSchema = z
63
63
  })
64
64
  .describe("Memory v3 hot-set lane (decayed selection frequency) tuning.");
65
65
 
66
+ /**
67
+ * Fresh-set lane tuning: the top-K pages by most-recent on-disk modification
68
+ * folded into the candidate pool as a stable-prefix lane. Recency covers the
69
+ * window before the other lanes can reach a just-written page (no selection
70
+ * history for the hot set; nothing lexical for the finders on summary-shaped
71
+ * messages).
72
+ */
73
+ export const MemoryV3FreshSetSchema = z
74
+ .object({
75
+ k: z
76
+ .number({ error: "memory.v3.freshSet.k must be a number" })
77
+ .int("memory.v3.freshSet.k must be an integer")
78
+ .nonnegative("memory.v3.freshSet.k must be a non-negative integer")
79
+ .default(100)
80
+ .describe(
81
+ "Number of most-recently-modified pages included in the fresh-set lane (0 disables the lane). Sized to cover roughly the last day or two of page writes — the recency window conversations reference most.",
82
+ ),
83
+ })
84
+ .describe("Memory v3 fresh-set lane (page-modification recency) tuning.");
85
+
86
+ /**
87
+ * Learned-edge lane tuning: a co-selection NPMI association graph over the
88
+ * selection log, expanded alongside the static link graph. Behavioral edges
89
+ * reach association-relevant pages no lexical, semantic, or authored-link
90
+ * lane can surface.
91
+ */
92
+ export const MemoryV3LearnedEdgesSchema = z
93
+ .object({
94
+ halfLifeDays: z
95
+ .number({ error: "memory.v3.learnedEdges.halfLifeDays must be a number" })
96
+ .positive("memory.v3.learnedEdges.halfLifeDays must be a positive number")
97
+ .default(30)
98
+ .describe(
99
+ "Co-selection decay half-life in days: a selector call this old contributes half the weight of one made now.",
100
+ ),
101
+ minCount: z
102
+ .number({ error: "memory.v3.learnedEdges.minCount must be a number" })
103
+ .positive("memory.v3.learnedEdges.minCount must be a positive number")
104
+ .default(3)
105
+ .describe(
106
+ "Minimum decayed co-occurrence mass for a pair to form an edge (the rare-pair noise floor).",
107
+ ),
108
+ npmiFloor: z
109
+ .number({ error: "memory.v3.learnedEdges.npmiFloor must be a number" })
110
+ .nonnegative(
111
+ "memory.v3.learnedEdges.npmiFloor must be a non-negative number",
112
+ )
113
+ .default(0.2)
114
+ .describe("Minimum NPMI for a pair to form an edge."),
115
+ maxPerPage: z
116
+ .number({ error: "memory.v3.learnedEdges.maxPerPage must be a number" })
117
+ .int("memory.v3.learnedEdges.maxPerPage must be an integer")
118
+ .nonnegative(
119
+ "memory.v3.learnedEdges.maxPerPage must be a non-negative integer",
120
+ )
121
+ .default(6)
122
+ .describe(
123
+ "Maximum learned out-edges kept per page, strongest NPMI first (0 disables the lane).",
124
+ ),
125
+ perSeed: z
126
+ .number({ error: "memory.v3.learnedEdges.perSeed must be a number" })
127
+ .int("memory.v3.learnedEdges.perSeed must be an integer")
128
+ .positive("memory.v3.learnedEdges.perSeed must be a positive integer")
129
+ .default(3)
130
+ .describe(
131
+ "Maximum learned neighbours surfaced per expanded seed each turn.",
132
+ ),
133
+ cap: z
134
+ .number({ error: "memory.v3.learnedEdges.cap must be a number" })
135
+ .int("memory.v3.learnedEdges.cap must be an integer")
136
+ .nonnegative("memory.v3.learnedEdges.cap must be a non-negative integer")
137
+ .default(20)
138
+ .describe(
139
+ "Hard cap on total learned-lane surfaced articles per turn (0 disables the pass).",
140
+ ),
141
+ })
142
+ .describe(
143
+ "Memory v3 learned-edge lane (co-selection NPMI association graph) tuning.",
144
+ );
145
+
66
146
  /**
67
147
  * Ephemeral section-spotlight tuning: how many of the current turn's selected
68
148
  * finder hits render their matched section into the `<memory_spotlight>`
@@ -142,6 +222,10 @@ export const MemoryV3ConfigSchema = z
142
222
  .object({
143
223
  prune: MemoryV3PruneSchema.default(MemoryV3PruneSchema.parse({})),
144
224
  hotSet: MemoryV3HotSetSchema.default(MemoryV3HotSetSchema.parse({})),
225
+ freshSet: MemoryV3FreshSetSchema.default(MemoryV3FreshSetSchema.parse({})),
226
+ learnedEdges: MemoryV3LearnedEdgesSchema.default(
227
+ MemoryV3LearnedEdgesSchema.parse({}),
228
+ ),
145
229
  spotlight: MemoryV3SpotlightSchema.default(
146
230
  MemoryV3SpotlightSchema.parse({}),
147
231
  ),
@@ -161,6 +245,14 @@ export const MemoryV3ConfigSchema = z
161
245
  .describe(
162
246
  "Number of dense-lane articles folded into the candidate pool each turn.",
163
247
  ),
248
+ replyQueryK: z
249
+ .number({ error: "memory.v3.replyQueryK must be a number" })
250
+ .int("memory.v3.replyQueryK must be an integer")
251
+ .nonnegative("memory.v3.replyQueryK must be a non-negative integer")
252
+ .default(12)
253
+ .describe(
254
+ "Per-lane article budget for the reply-query finder pass: needle and dense each re-run over the assistant's previous message as separate queries (never concatenated with the user's message). 0 disables the pass. Deliberately small next to needleK/denseK — the pass adds the assistant-side retrieval signal, not a second full sweep.",
255
+ ),
164
256
  edge: MemoryV3EdgeSchema.default(MemoryV3EdgeSchema.parse({})),
165
257
  })
166
258
  .describe("Memory v3 — section-grain lane retrieval");
@@ -50,6 +50,27 @@ function buildToolNameById(messages: Message[]): Map<string, string> {
50
50
  return byId;
51
51
  }
52
52
 
53
+ /**
54
+ * Shared eligibility rules for replacing an oversized tool result with the
55
+ * on-disk stub: string content over the size threshold, not an error result,
56
+ * not from an exempt tool (durable instructions like skill bodies), and not
57
+ * already truncated (idempotency marker). Used by both the post-turn pass and
58
+ * the result-time spool pass so the two converge on the same results.
59
+ */
60
+ export function isTruncationEligible(
61
+ tr: ToolResultContent,
62
+ toolName: string | undefined,
63
+ ): boolean {
64
+ if (typeof tr.content !== "string") return false;
65
+ if (tr.content.length <= THRESHOLD_CHARS) return false;
66
+ if (tr.is_error) return false;
67
+ if (toolName !== undefined && TRUNCATION_EXEMPT_TOOLS.has(toolName)) {
68
+ return false;
69
+ }
70
+ if (tr.content.includes(TRUNCATION_MARKER)) return false;
71
+ return true;
72
+ }
73
+
53
74
  /**
54
75
  * Deterministic file path for a tool result's full content on disk.
55
76
  * Uses the first 12 hex chars of the SHA-256 of the tool_use_id.
@@ -58,7 +79,10 @@ export function getToolResultFilePath(
58
79
  conversationDir: string,
59
80
  toolUseId: string,
60
81
  ): string {
61
- const hash = createHash("sha256").update(toolUseId).digest("hex").slice(0, 12);
82
+ const hash = createHash("sha256")
83
+ .update(toolUseId)
84
+ .digest("hex")
85
+ .slice(0, 12);
62
86
  return join(conversationDir, TOOL_RESULT_DIR, `${hash}.txt`);
63
87
  }
64
88
 
@@ -73,7 +97,11 @@ export function buildTruncatedContent(
73
97
  ): string {
74
98
  const half = Math.floor(TARGET_CHARS / 2);
75
99
  const prefix = safeStringSlice(original, 0, half);
76
- const suffix = safeStringSlice(original, original.length - half, original.length);
100
+ const suffix = safeStringSlice(
101
+ original,
102
+ original.length - half,
103
+ original.length,
104
+ );
77
105
  const omittedChars = original.length - TARGET_CHARS;
78
106
  const estimatedTokens = Math.round(omittedChars / 4);
79
107
  return `${prefix}\n\n...(${estimatedTokens} tokens omitted ${TRUNCATION_MARKER} ${filePath})\n\n${suffix}`;
@@ -86,9 +114,7 @@ export function buildTruncatedContent(
86
114
  * - The full content is persisted to a deterministic file path on disk.
87
115
  * - The in-context content is replaced with a prefix/suffix stub.
88
116
  *
89
- * Results are skipped if they are below threshold, are error results,
90
- * have already been truncated (contain `TRUNCATION_MARKER`), or were produced
91
- * by a tool in `TRUNCATION_EXEMPT_TOOLS` (durable instructions like skill bodies).
117
+ * Results are skipped unless {@link isTruncationEligible} allows them.
92
118
  *
93
119
  * Returns a shallow-copied messages array (only modified messages are cloned)
94
120
  * and the count of results that were truncated.
@@ -107,22 +133,10 @@ export function postTurnTruncateToolResults(
107
133
  if (block.type !== "tool_result") return block;
108
134
  const tr = block as ToolResultContent;
109
135
 
110
- // Skip short results.
111
- if (tr.content.length <= THRESHOLD_CHARS) return block;
112
-
113
- // Skip error results.
114
- if (tr.is_error) return block;
115
-
116
- // Skip results from tools whose output is durable operating instructions
117
- // (e.g. skill_load); middle-truncating them strips the workflow.
118
- const toolName = toolNameById.get(tr.tool_use_id);
119
- if (toolName !== undefined && TRUNCATION_EXEMPT_TOOLS.has(toolName)) {
136
+ if (!isTruncationEligible(tr, toolNameById.get(tr.tool_use_id))) {
120
137
  return block;
121
138
  }
122
139
 
123
- // Skip already-truncated results (idempotency).
124
- if (tr.content.includes(TRUNCATION_MARKER)) return block;
125
-
126
140
  const filePath = getToolResultFilePath(
127
141
  options.conversationDir,
128
142
  tr.tool_use_id,
@@ -0,0 +1,104 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import type { ContentBlock, ToolResultContent } from "../providers/types.js";
5
+ import {
6
+ buildTruncatedContent,
7
+ getToolResultFilePath,
8
+ isTruncationEligible,
9
+ TOOL_RESULT_DIR,
10
+ } from "./post-turn-tool-result-truncation.js";
11
+
12
+ /**
13
+ * AX-tree snapshots have their own dedicated history compactor
14
+ * (`compactAxTreeHistory`) with placeholder semantics tuned for computer-use
15
+ * sessions, and the model needs the freshest tree inline to act on it, so the
16
+ * result-time pass leaves them alone.
17
+ */
18
+ const AX_TREE_TAG = "<ax-tree>";
19
+
20
+ /**
21
+ * Tools whose results keep their full content at result time even when
22
+ * oversized. `file_read` is the model's only way to page spooled content back
23
+ * into context: stubbing a read of a `.tool-results/` file would spool a
24
+ * fresh copy and hand back another stub, so oversized content could never be
25
+ * read at all. Explicit reads are therefore honored in full; the post-turn
26
+ * pass still truncates them at turn end, after the model has consumed the
27
+ * content.
28
+ */
29
+ const RESULT_TIME_STUB_EXEMPT_TOOLS = new Set<string>(["file_read"]);
30
+
31
+ /**
32
+ * Whether a tool result is eligible for the result-time spool/stub pass:
33
+ * the post-turn pass's shared rules plus the AX-tree and `file_read`
34
+ * exemptions above.
35
+ */
36
+ function isSpoolEligible(
37
+ tr: ToolResultContent,
38
+ toolName: string | undefined,
39
+ ): boolean {
40
+ if (!isTruncationEligible(tr, toolName)) return false;
41
+ if (toolName !== undefined && RESULT_TIME_STUB_EXEMPT_TOOLS.has(toolName)) {
42
+ return false;
43
+ }
44
+ if (tr.content.includes(AX_TREE_TAG)) return false;
45
+ return true;
46
+ }
47
+
48
+ /**
49
+ * Spool every oversized tool result in `blocks` to its deterministic
50
+ * `.tool-results/` file and replace the inline content with the post-turn
51
+ * pass's prefix/suffix stub — at result time, before the blocks join history.
52
+ *
53
+ * Because the swap happens before the content is ever sent, the
54
+ * provider-bound history stays strictly append-only across the turn's model
55
+ * calls, which is what keeps the provider's prompt-cache prefix valid:
56
+ * rewriting an earlier message between calls would invalidate the cache from
57
+ * that point on every iteration. The model still gets the head/tail preview
58
+ * plus the on-disk path, so it can page the full content back in with
59
+ * `file_read` (whose results are exempt from this pass) when it actually
60
+ * needs it.
61
+ *
62
+ * Uses the same file paths, stub bytes, and eligibility rules as
63
+ * `postTurnTruncateToolResults`, whose `TRUNCATION_MARKER` guard then skips
64
+ * these results at turn end. Eligible elements of `blocks` are replaced in
65
+ * place (the block objects themselves are not mutated). A result is only
66
+ * stubbed after its file is written, so a stub never points at a missing
67
+ * file; on filesystem errors the remaining blocks keep their full content and
68
+ * the post-turn pass covers them.
69
+ *
70
+ * Returns the number of results spooled and stubbed.
71
+ */
72
+ export function spoolAndStubOversizedToolResults(
73
+ blocks: ContentBlock[],
74
+ options: {
75
+ conversationDir: string;
76
+ toolNameById: (toolUseId: string) => string | undefined;
77
+ },
78
+ ): number {
79
+ let stubbedCount = 0;
80
+ for (let i = 0; i < blocks.length; i++) {
81
+ const block = blocks[i];
82
+ if (block.type !== "tool_result") continue;
83
+ if (!isSpoolEligible(block, options.toolNameById(block.tool_use_id))) {
84
+ continue;
85
+ }
86
+
87
+ if (stubbedCount === 0) {
88
+ mkdirSync(join(options.conversationDir, TOOL_RESULT_DIR), {
89
+ recursive: true,
90
+ });
91
+ }
92
+ const filePath = getToolResultFilePath(
93
+ options.conversationDir,
94
+ block.tool_use_id,
95
+ );
96
+ writeFileSync(filePath, block.content, "utf-8");
97
+ blocks[i] = {
98
+ ...block,
99
+ content: buildTruncatedContent(block.content, filePath),
100
+ };
101
+ stubbedCount++;
102
+ }
103
+ return stubbedCount;
104
+ }
@@ -59,4 +59,3 @@ export function isCesSecureInstallEnabled(config: AssistantConfig): boolean {
59
59
  export function isCesGrantAuditEnabled(config: AssistantConfig): boolean {
60
60
  return isAssistantFeatureFlagEnabled(CES_GRANT_AUDIT_FLAG_KEY, config);
61
61
  }
62
-
@@ -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
@@ -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.