@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.4

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 (196) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/openapi.yaml +16 -115
  3. package/package.json +1 -1
  4. package/src/__tests__/activation-early-marking.test.ts +120 -0
  5. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  6. package/src/__tests__/anthropic-provider.test.ts +23 -8
  7. package/src/__tests__/approval-cascade.test.ts +1 -1
  8. package/src/__tests__/compaction-direct.test.ts +32 -18
  9. package/src/__tests__/compaction-events.test.ts +2 -2
  10. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  11. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  12. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  13. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  14. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  15. package/src/__tests__/conversation-error.test.ts +15 -1
  16. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  17. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  18. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  19. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  20. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  21. package/src/__tests__/conversation-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  23. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  25. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  29. package/src/__tests__/conversation-undo.test.ts +2 -2
  30. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  31. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  32. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  33. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  34. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  35. package/src/__tests__/history-repair-hook.test.ts +2 -0
  36. package/src/__tests__/llm-resolver.test.ts +73 -0
  37. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  38. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  39. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  40. package/src/__tests__/plugin-external-api.test.ts +68 -0
  41. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  42. package/src/__tests__/published-app-updater.test.ts +138 -0
  43. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  44. package/src/__tests__/title-generate-hook.test.ts +2 -0
  45. package/src/__tests__/web-fetch.test.ts +45 -0
  46. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  47. package/src/acp/resolve-agent.test.ts +0 -56
  48. package/src/acp/resolve-agent.ts +10 -38
  49. package/src/agent/loop.ts +13 -27
  50. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  51. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  52. package/src/cli/commands/memory-v3.ts +57 -199
  53. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  54. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  55. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  56. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  57. package/src/cli/lib/install-from-github.ts +258 -41
  58. package/src/cli/lib/plugin-details.ts +20 -13
  59. package/src/cli/lib/plugin-marketplace.ts +23 -5
  60. package/src/cli/lib/search-plugins.ts +14 -8
  61. package/src/config/acp-defaults.ts +3 -3
  62. package/src/config/acp-schema.ts +1 -7
  63. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  64. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  65. package/src/config/call-site-defaults.ts +0 -1
  66. package/src/config/feature-flag-registry.json +3 -18
  67. package/src/config/llm-resolver.ts +39 -7
  68. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  69. package/src/config/schemas/call-site-catalog.ts +0 -7
  70. package/src/config/schemas/llm.ts +17 -1
  71. package/src/config/schemas/memory-v3.ts +58 -8
  72. package/src/config/seed-inference-profiles.ts +18 -0
  73. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  74. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  75. package/src/daemon/conversation-agent-loop.ts +87 -95
  76. package/src/daemon/conversation-error.ts +31 -4
  77. package/src/daemon/conversation-history.ts +1 -1
  78. package/src/daemon/conversation-media-retry.ts +19 -6
  79. package/src/daemon/conversation-messaging.ts +17 -0
  80. package/src/daemon/conversation-process.ts +14 -5
  81. package/src/daemon/conversation-queue-manager.ts +8 -0
  82. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  83. package/src/daemon/conversation-surfaces.ts +141 -3
  84. package/src/daemon/conversation.ts +48 -13
  85. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  86. package/src/daemon/persist-unsendable-image.ts +62 -25
  87. package/src/daemon/process-message.ts +1 -1
  88. package/src/daemon/tool-side-effects.ts +15 -0
  89. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  90. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  91. package/src/memory/activation-session-store.ts +43 -0
  92. package/src/memory/db-init.ts +4 -0
  93. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  94. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  95. package/src/memory/migrations/index.ts +2 -0
  96. package/src/memory/onboarding-events-store.ts +66 -18
  97. package/src/memory/schema/infrastructure.ts +13 -0
  98. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  99. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  100. package/src/memory/v2/consolidation-job.ts +2 -72
  101. package/src/memory/v2/types.ts +5 -0
  102. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  103. package/src/notifications/adapters/telegram.ts +7 -1
  104. package/src/plugin-api/constants.ts +2 -2
  105. package/src/plugin-api/index.ts +2 -2
  106. package/src/plugin-api/types.ts +19 -5
  107. package/src/plugins/defaults/compaction/compact.ts +24 -15
  108. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  109. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  110. package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
  111. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  112. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  113. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  114. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  115. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  116. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  117. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  118. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  119. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  120. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  121. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  122. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  123. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  124. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  125. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  126. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  127. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  128. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  129. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  130. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  131. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  132. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  133. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  134. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  135. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  136. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  137. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  138. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  139. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  140. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  141. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  142. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  143. package/src/plugins/external-api.ts +114 -0
  144. package/src/prompts/system-prompt.ts +61 -10
  145. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  146. package/src/providers/anthropic/client.ts +9 -10
  147. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  148. package/src/providers/inference/logit-bias.ts +55 -0
  149. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  150. package/src/providers/openai/chat-completions-provider.ts +44 -1
  151. package/src/providers/retry.ts +22 -0
  152. package/src/providers/types.ts +6 -0
  153. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  154. package/src/runtime/routes/acp-routes.test.ts +3 -20
  155. package/src/runtime/routes/app-management-routes.ts +3 -0
  156. package/src/runtime/routes/conversation-routes.ts +2 -0
  157. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  158. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  159. package/src/runtime/routes/stt-routes.ts +45 -12
  160. package/src/runtime/routes/workspace-routes.ts +50 -15
  161. package/src/services/published-app-updater.ts +30 -8
  162. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  163. package/src/telemetry/activation-funnel.ts +167 -0
  164. package/src/telemetry/types.ts +13 -0
  165. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  166. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  167. package/src/tools/acp/list-agents.test.ts +2 -18
  168. package/src/tools/acp/list-agents.ts +3 -15
  169. package/src/tools/acp/spawn.test.ts +0 -10
  170. package/src/tools/browser/browser-execution.ts +12 -2
  171. package/src/tools/network/web-fetch.ts +65 -24
  172. package/src/tools/skills/load.ts +1 -1
  173. package/src/tools/ui-surface/definitions.ts +7 -0
  174. package/src/acp/feature-gate.test.ts +0 -48
  175. package/src/acp/feature-gate.ts +0 -34
  176. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  177. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  178. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  179. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  180. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  181. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  182. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  183. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  184. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  185. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  186. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  187. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  188. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  189. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  190. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  191. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  192. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  193. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  194. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  195. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  196. package/src/util/map-limit.ts +0 -27
@@ -63,19 +63,6 @@ import { dirname, join } from "node:path";
63
63
 
64
64
  import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
65
65
  import type { AssistantConfig } from "../../config/types.js";
66
- import { loadCore } from "../../plugins/defaults/memory-v3-shadow/core.js";
67
- import {
68
- computeV3Health,
69
- renderV3Health,
70
- } from "../../plugins/defaults/memory-v3-shadow/health.js";
71
- import {
72
- loadLeafTree,
73
- resolveDataDir,
74
- } from "../../plugins/defaults/memory-v3-shadow/tree.js";
75
- import type {
76
- LeafPath,
77
- Slug,
78
- } from "../../plugins/defaults/memory-v3-shadow/types.js";
79
66
  import { runBackgroundJob } from "../../runtime/background-job-runner.js";
80
67
  import { getLogger } from "../../util/logger.js";
81
68
  import { getWorkspaceDir } from "../../util/platform.js";
@@ -86,7 +73,6 @@ import {
86
73
  type MemoryJob,
87
74
  type MemoryJobType,
88
75
  } from "../jobs-store.js";
89
- import { getPageIndex } from "../v2/page-index.js";
90
76
  import { MEMORY_V2_CONSOLIDATION_SOURCE } from "./constants.js";
91
77
  import { resolveConsolidationPrompt } from "./prompts/consolidation.js";
92
78
 
@@ -96,8 +82,7 @@ const log = getLogger("memory-v2-consolidate");
96
82
  const JOB_NAME = "memory.consolidate";
97
83
 
98
84
  /**
99
- * v3 plugin flags. Either being on (a) prepends a v3 health block to the
100
- * consolidation prompt and (b) enqueues `memory_v3_maintain` as a
85
+ * v3 plugin flags. Either being on enqueues `memory_v3_maintain` as a
101
86
  * post-consolidation follow-up. These gate the v3 plugin itself.
102
87
  */
103
88
  const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
@@ -191,15 +176,10 @@ export async function memoryV2ConsolidateJob(
191
176
  // it to a regular file under 1 MiB before substitution so a stray path
192
177
  // (or a `/dev/zero`-style pseudo-file) cannot exfiltrate megabytes of
193
178
  // bytes through the wake hint.
194
- //
195
- // That resolved prompt is the base; `maybePrependV3Health` prepends a
196
- // freshly computed v3 health block when a v3 flag is on and the tree has
197
- // actionable drift, leaving it untouched otherwise.
198
- const basePrompt = resolveConsolidationPrompt(
179
+ const prompt = resolveConsolidationPrompt(
199
180
  config.memory.v2.consolidation_prompt_path,
200
181
  cutoff,
201
182
  );
202
- const prompt = await maybePrependV3Health(basePrompt, config);
203
183
 
204
184
  const runResult = await runBackgroundJob({
205
185
  jobName: JOB_NAME,
@@ -270,56 +250,6 @@ export async function memoryV2ConsolidateJob(
270
250
  }
271
251
  }
272
252
 
273
- /**
274
- * When a v3 flag is enabled and the v3 tree has actionable structural drift,
275
- * prepend a rendered health block to `basePrompt` so the consolidation run can
276
- * fold tree maintenance into its pass. Returns `basePrompt` UNCHANGED when:
277
- * - neither `memory-v3-shadow` nor `memory-v3-live` is enabled,
278
- * - the health report is all-green (`renderV3Health` returns ""), or
279
- * - computing the report throws for any reason.
280
- *
281
- * The block is computed here (not baked into the prompt template) so an
282
- * operator's custom consolidation prompt stays untouched and the block reflects
283
- * the tree state at run time. Health computation is wrapped so a load/compute
284
- * failure can never break consolidation — the run proceeds on the base prompt.
285
- */
286
- async function maybePrependV3Health(
287
- basePrompt: string,
288
- config: AssistantConfig,
289
- ): Promise<string> {
290
- const v3Enabled =
291
- isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config) ||
292
- isAssistantFeatureFlagEnabled(MEMORY_V3_LIVE, config);
293
- if (!v3Enabled) return basePrompt;
294
-
295
- try {
296
- const pageIndex = await getPageIndex(getWorkspaceDir());
297
- const pageLeaves = new Map<Slug, LeafPath[]>();
298
- const allSlugs: Slug[] = [];
299
- for (const entry of pageIndex.entries) {
300
- pageLeaves.set(entry.slug, entry.leaves);
301
- allSlugs.push(entry.slug);
302
- }
303
-
304
- const dataDir = resolveDataDir();
305
- const [tree, core] = await Promise.all([
306
- loadLeafTree(dataDir, pageLeaves),
307
- loadCore(dataDir),
308
- ]);
309
-
310
- const report = computeV3Health({ tree, allSlugs, core });
311
- const healthBlock = renderV3Health(report);
312
- if (healthBlock.length === 0) return basePrompt;
313
- return `${healthBlock}\n\n${basePrompt}`;
314
- } catch (err) {
315
- log.warn(
316
- { err: err instanceof Error ? err.message : String(err) },
317
- "consolidation: v3 health computation failed; using base prompt",
318
- );
319
- return basePrompt;
320
- }
321
- }
322
-
323
253
  /**
324
254
  * Read `memory/buffer.md`. Missing file → empty string so the skip-on-empty
325
255
  * branch doesn't have to distinguish "no file" from "blank file".
@@ -41,6 +41,11 @@ export const ConceptPageFrontmatterSchema = z
41
41
  ref_urls: z.array(z.string().url()).default([]),
42
42
  summary: z.string().optional(),
43
43
  leaves: z.array(z.string()).optional(),
44
+ // Optional authored `"<target-slug> — <why>"` cross-links. Curated, first-class
45
+ // edges for the memory-v3 edge lane. Declared here so `.strict()` does not
46
+ // reject a page that uses `links:`, and so `renderPageContent` round-trips the
47
+ // field (the edge graph reads it back from the rendered frontmatter).
48
+ links: z.array(z.string()).optional(),
44
49
  })
45
50
  .strict();
46
51
 
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { credentialKey } from "../../../security/credential-key.js";
11
- import { getSecureKeyAsync } from "../../../security/secure-keys.js";
11
+ import { getSecureKeyResultAsync } from "../../../security/secure-keys.js";
12
+ import { BackendUnavailableError, ConfigError } from "../../../util/errors.js";
12
13
  import { getLogger } from "../../../util/logger.js";
13
14
 
14
15
  const log = getLogger("telegram-api");
@@ -197,13 +198,21 @@ async function retryableFetch<T>(
197
198
  // ---------------------------------------------------------------------------
198
199
 
199
200
  async function resolveBotToken(): Promise<string> {
200
- const botToken = await getSecureKeyAsync(
201
+ const result = await getSecureKeyResultAsync(
201
202
  credentialKey("telegram", "bot_token"),
202
203
  );
203
- if (!botToken) {
204
- throw new Error("Telegram bot token not configured");
204
+ if (result.value) {
205
+ return result.value;
205
206
  }
206
- return botToken;
207
+ // Distinguish a transient credential-store outage from a token that was
208
+ // genuinely never set. Both surface as an absent value, but only the
209
+ // latter is an operator misconfiguration; the former is retryable
210
+ // infrastructure unavailability that callers should not treat as
211
+ // "not configured".
212
+ if (result.unreachable) {
213
+ throw new BackendUnavailableError("Telegram credential store unreachable");
214
+ }
215
+ throw new ConfigError("Telegram bot token not configured");
207
216
  }
208
217
 
209
218
  // ---------------------------------------------------------------------------
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { sendTelegramReply } from "../../messaging/providers/telegram-bot/send.js";
12
12
  import type { ApprovalUIMetadata } from "../../runtime/channel-approval-types.js";
13
+ import { ConfigError } from "../../util/errors.js";
13
14
  import { getLogger } from "../../util/logger.js";
14
15
  import { isConversationSeedSane } from "../conversation-seed-composer.js";
15
16
  import { buildAccessRequestContractText, nonEmpty } from "../copy-composer.js";
@@ -136,7 +137,12 @@ export class TelegramAdapter implements ChannelAdapter {
136
137
  return { success: true };
137
138
  } catch (err) {
138
139
  const message = err instanceof Error ? err.message : String(err);
139
- log.error(
140
+ // A missing bot token means the operator simply hasn't configured
141
+ // Telegram; it is not a code fault, so log it at warn to keep it out
142
+ // of Sentry. Genuine and transient failures (e.g. an unreachable
143
+ // credential store) stay at error so they remain visible.
144
+ const logFn = err instanceof ConfigError ? log.warn : log.error;
145
+ logFn(
140
146
  { err, sourceEventName: payload.sourceEventName, chatId },
141
147
  "Failed to deliver Telegram notification",
142
148
  );
@@ -26,8 +26,8 @@ export const HOOKS = {
26
26
  POST_TOOL_USE: "post-tool-use",
27
27
  /** Fires when the model yields a response with no tool calls — the run's stop boundary. Decides whether to stop or continue with a follow-up turn. */
28
28
  STOP: "stop",
29
- /** Fires for each finalized assistant message, before it is persisted/streamed-final. A hook may transform the message content. */
30
- ASSISTANT_MESSAGE: "assistant-message",
29
+ /** Fires for each finalized assistant message, once per model call, before it is persisted/streamed-final. A hook may transform the message content. */
30
+ POST_MODEL_CALL: "post-model-call",
31
31
  } as const;
32
32
 
33
33
  /** Union of every hook name declared in {@link HOOKS}. */
@@ -30,7 +30,7 @@
30
30
  * per tool result before it joins the provider-bound history
31
31
  * - {@link StopContext} — passed to `stop` hook, fired when the model yields
32
32
  * a response with no tool calls
33
- * - {@link AssistantMessageContext} — passed to `assistant-message` hook,
33
+ * - {@link PostModelCallContext} — passed to `post-model-call` hook,
34
34
  * fired for each finalized assistant message to transform its content
35
35
  * - {@link PluginHookFn} — signature every lifecycle hook implements
36
36
  * - {@link PluginLogger} — pino-compatible logger shape on the contexts
@@ -43,11 +43,11 @@
43
43
  export type { HookName } from "./constants.js";
44
44
  export { HOOKS } from "./constants.js";
45
45
  export type {
46
- AssistantMessageContext,
47
46
  PluginHookFn,
48
47
  PluginInitContext,
49
48
  PluginLogger,
50
49
  PluginShutdownContext,
50
+ PostModelCallContext,
51
51
  PostToolUseContext,
52
52
  PreModelCallContext,
53
53
  StopContext,
@@ -55,7 +55,7 @@ export interface PluginLogger {
55
55
  * - `pre-model-call` — {@link PreModelCallContext}
56
56
  * - `post-tool-use` — {@link PostToolUseContext}
57
57
  * - `stop` — {@link StopContext}
58
- * - `assistant-message` — {@link AssistantMessageContext}
58
+ * - `post-model-call` — {@link PostModelCallContext}
59
59
  */
60
60
  export type PluginHookFn<TCtx = unknown> = (ctx: TCtx) => Promise<TCtx | void>;
61
61
 
@@ -130,6 +130,20 @@ export interface PluginShutdownContext {
130
130
  export interface UserPromptSubmitContext {
131
131
  /** Conversation ID the user prompt was submitted on. */
132
132
  readonly conversationId: string;
133
+ /**
134
+ * Persisted ID of the user message that triggered this turn. Hooks that
135
+ * attach turn-scoped metadata to the originating message (e.g. recording an
136
+ * injected memory block so it survives a conversation reload) key off this
137
+ * row id rather than the in-memory message arrays, whose entries carry no id.
138
+ */
139
+ readonly userMessageId: string;
140
+ /**
141
+ * Stable ID for the request that drives this turn. Hooks that perform
142
+ * runtime injection forward it onto the injector turn context so the
143
+ * assembled blocks are attributed to the originating request; it is fixed
144
+ * for the turn and cannot be recovered from the message arrays.
145
+ */
146
+ readonly requestId: string;
133
147
  /**
134
148
  * The text of the user prompt that triggered this turn — the resolved
135
149
  * user message (after slash-command expansion), independent of any
@@ -321,7 +335,7 @@ export interface PreModelCallContext {
321
335
  systemPrompt: string | undefined;
322
336
  /**
323
337
  * Seeded `false`. When a hook sets it `true`, the loop suppresses this turn's
324
- * live assistant `text_delta` stream; an `assistant-message` hook is then
338
+ * live assistant `text_delta` stream; a `post-model-call` hook is then
325
339
  * expected to produce the text the client sees (emitted once, after the reply
326
340
  * is finalized). Lets a plugin replace streamed output wholesale — e.g.
327
341
  * redaction that needs the full message — instead of leaking the raw stream.
@@ -331,10 +345,10 @@ export interface PreModelCallContext {
331
345
  readonly logger: PluginLogger;
332
346
  }
333
347
 
334
- // ─── Assistant-message hook context ──────────────────────────────────────────
348
+ // ─── Post-model-call hook context ────────────────────────────────────────────
335
349
 
336
350
  /**
337
- * Context passed to the `assistant-message` hook. Fires for each finalized
351
+ * Context passed to the `post-model-call` hook. Fires for each finalized
338
352
  * assistant message — once per model call, at the message-complete boundary —
339
353
  * before the message is persisted and (if deferred) streamed-final. Unlike
340
354
  * {@link StopContext}'s read-only `responseContent` (which exists for the stop
@@ -348,7 +362,7 @@ export interface PreModelCallContext {
348
362
  * place or return a new context; throwing is contained by the loop (the original
349
363
  * content is kept).
350
364
  */
351
- export interface AssistantMessageContext {
365
+ export interface PostModelCallContext {
352
366
  /** Conversation ID the message belongs to. */
353
367
  readonly conversationId: string;
354
368
  /** The call site this message serves — `"mainAgent"` for the user-facing reply. */
@@ -2,19 +2,19 @@
2
2
  * Default compaction implementation.
3
3
  *
4
4
  * Summarizes conversation history when the context window fills up by
5
- * delegating to a {@link ContextWindowManager}. The agent loop calls
6
- * {@link defaultCompact} directly with a {@link CompactionContext}; the request
7
- * carries the conversational inputs while the manager performs the work.
5
+ * delegating to the conversation's {@link ContextWindowManager}. The agent loop
6
+ * calls {@link defaultCompact} directly with a {@link CompactionContext}; the
7
+ * request carries the conversational inputs while the compaction module resolves
8
+ * (and, if needed, builds) the manager from its per-conversation store.
8
9
  *
9
10
  * This module is side-effect free: importing it does not register any plugin.
10
11
  */
11
12
 
12
- import type {
13
- ContextWindowManager,
14
- ContextWindowResult,
15
- } from "../../../context/window-manager.js";
16
13
  import type { Message } from "../../../providers/types.js";
17
14
  import type { TrustClass } from "../../../runtime/actor-trust-resolver.js";
15
+ import { PluginExecutionError } from "../../types.js";
16
+ import { getContextWindowManager } from "./manager-store.js";
17
+ import type { ContextWindowResult } from "./window-manager.js";
18
18
 
19
19
  /**
20
20
  * Name under which the default compaction plugin registers. Exposed so call
@@ -24,13 +24,14 @@ export const DEFAULT_COMPACTION_PLUGIN_NAME = "default-compaction";
24
24
 
25
25
  /**
26
26
  * Self-describing compaction request handed to {@link defaultCompact}. Carries
27
- * the conversational inputs plus the {@link ContextWindowManager} that runs the
28
- * summary, so the request is the part that can grow toward a model-facing
29
- * compaction-method shape without coupling callers to the manager's option bag.
27
+ * the conversational inputs plus the `conversationId` the compaction module
28
+ * resolves its manager from, so callers never hold or pass a manager handle and
29
+ * the request can grow toward a model-facing compaction-method shape without
30
+ * coupling callers to the manager's option bag.
30
31
  */
31
32
  export interface CompactionContext {
32
- /** Context window manager that performs the compaction. */
33
- manager: ContextWindowManager;
33
+ /** Conversation whose manager performs the compaction. */
34
+ conversationId: string;
34
35
  /** Message history to consider for compaction. */
35
36
  messages: Message[];
36
37
  /** Abort signal forwarded to the compaction summary call. */
@@ -48,12 +49,20 @@ export interface CompactionContext {
48
49
  }
49
50
 
50
51
  /**
51
- * Run compaction for the turn: delegates to the request's context window
52
- * manager and returns the (possibly summarized) message history.
52
+ * Run compaction for the turn: resolves the conversation's context window
53
+ * manager from the compaction store (building it on first access) and returns
54
+ * the (possibly summarized) message history.
53
55
  */
54
56
  export async function defaultCompact(
55
57
  context: CompactionContext,
56
58
  ): Promise<ContextWindowResult> {
57
- const { manager, messages, signal, ...options } = context;
59
+ const { conversationId, messages, signal, ...options } = context;
60
+ const manager = getContextWindowManager(conversationId);
61
+ if (manager == null) {
62
+ throw new PluginExecutionError(
63
+ `default-compaction: no ContextWindowManager registered for conversation ${conversationId} — the conversation must register one before compaction runs`,
64
+ DEFAULT_COMPACTION_PLUGIN_NAME,
65
+ );
66
+ }
58
67
  return manager.maybeCompact(messages, signal, options);
59
68
  }
@@ -22,10 +22,6 @@ import {
22
22
  estimatePromptTokens,
23
23
  } from "../../../context/token-estimator.js";
24
24
  import { truncateToolResultsAcrossHistory } from "../../../context/tool-result-truncation.js";
25
- import type {
26
- ContextWindowCompactOptions,
27
- ContextWindowResult,
28
- } from "../../../context/window-manager.js";
29
25
  import {
30
26
  countMediaBlocks,
31
27
  estimateUnconditionalStubTokens,
@@ -33,6 +29,10 @@ import {
33
29
  } from "../../../daemon/conversation-media-retry.js";
34
30
  import type { InjectionMode } from "../../../daemon/conversation-runtime-assembly.js";
35
31
  import type { Message } from "../../../providers/types.js";
32
+ import type {
33
+ ContextWindowCompactOptions,
34
+ ContextWindowResult,
35
+ } from "./window-manager.js";
36
36
 
37
37
  /**
38
38
  * Identifies which reduction tier was applied in a given step.
@@ -15,7 +15,7 @@
15
15
  import {
16
16
  ContextWindowManager,
17
17
  type ContextWindowManagerOptions,
18
- } from "../../../context/window-manager.js";
18
+ } from "./window-manager.js";
19
19
 
20
20
  /** Live managers keyed by conversation id. */
21
21
  const managersByConversation = new Map<string, ContextWindowManager>();
@@ -14,23 +14,25 @@
14
14
  * marker is wrapped around the assistant-role memory message we emit on
15
15
  * successful compaction so those code paths keep working unchanged.
16
16
  */
17
- import { getConfig } from "../config/loader.js";
18
- import type { CompactionConfig } from "../config/schemas/compaction.js";
19
- import type { LLMCallSite } from "../config/schemas/llm.js";
20
- import type { ContextWindowConfig } from "../config/types.js";
17
+ import { getConfig } from "../../../config/loader.js";
18
+ import type { CompactionConfig } from "../../../config/schemas/compaction.js";
19
+ import type { LLMCallSite } from "../../../config/schemas/llm.js";
20
+ import type { ContextWindowConfig } from "../../../config/types.js";
21
+ import {
22
+ type CompactionRunArgs,
23
+ type CompactionRunResult,
24
+ runAssistantDrivenCompaction,
25
+ runEmergencyCompaction,
26
+ } from "../../../context/compactor.js";
27
+ import { estimatePromptTokens } from "../../../context/token-estimator.js";
21
28
  import type {
22
29
  ContentBlock,
23
30
  Message,
24
31
  Provider,
25
32
  ToolDefinition,
26
- } from "../providers/types.js";
27
- import type { TrustClass } from "../runtime/actor-trust-resolver.js";
28
- import { getLogger } from "../util/logger.js";
29
- import {
30
- type CompactionRunArgs,
31
- runAssistantDrivenCompaction,
32
- } from "./compactor.js";
33
- import { estimatePromptTokens } from "./token-estimator.js";
33
+ } from "../../../providers/types.js";
34
+ import type { TrustClass } from "../../../runtime/actor-trust-resolver.js";
35
+ import { getLogger } from "../../../util/logger.js";
34
36
 
35
37
  const log = getLogger("context-window");
36
38
 
@@ -118,6 +120,21 @@ export interface ContextWindowCompactOptions {
118
120
  actorTrustClass?: TrustClass;
119
121
  }
120
122
 
123
+ export interface EmergencyCompactOptions {
124
+ /**
125
+ * The provider-reported estimate at the overflow that triggered recovery.
126
+ * Sizing the emergency summary needs the actual rejected token count, not
127
+ * the manager's pre-send estimate (which under-counted, hence the
128
+ * rejection).
129
+ */
130
+ previousEstimatedInputTokens: number;
131
+ /**
132
+ * Per-conversation inference-profile override forwarded to the summary
133
+ * call.
134
+ */
135
+ overrideProfile?: string | null;
136
+ }
137
+
121
138
  export interface ContextWindowManagerOptions {
122
139
  provider: Provider;
123
140
  systemPrompt: string | (() => string);
@@ -428,6 +445,45 @@ export class ContextWindowManager {
428
445
  return { ...result, estimatedInputTokens, exhausted: true };
429
446
  }
430
447
 
448
+ /**
449
+ * Emergency mid-turn compaction — summarize everything before the last
450
+ * tool_use/tool_result pair and let the agent continue with
451
+ * `[summary, last_tool_call, last_tool_result]`. Used as a recovery rung
452
+ * after the provider rejects a turn for context overflow. The manager
453
+ * supplies the provider, system prompt, token budget, conversation id, and
454
+ * non-persisted prefix count it already owns; the caller provides only the
455
+ * overflow-specific inputs.
456
+ */
457
+ async emergencyCompact(
458
+ messages: Message[],
459
+ options: EmergencyCompactOptions,
460
+ signal?: AbortSignal,
461
+ ): Promise<CompactionRunResult> {
462
+ if (this.conversationId == null) {
463
+ throw new Error(
464
+ "ContextWindowManager has no conversationId — cannot run emergency compaction",
465
+ );
466
+ }
467
+ try {
468
+ return await runEmergencyCompaction({
469
+ conversationId: this.conversationId,
470
+ messages,
471
+ provider: this.provider,
472
+ systemPrompt: this.systemPrompt,
473
+ tools: undefined,
474
+ compaction: this.resolveCompactionConfig(),
475
+ maxInputTokens: this.config.maxInputTokens,
476
+ previousEstimatedInputTokens: options.previousEstimatedInputTokens,
477
+ force: true,
478
+ signal,
479
+ overrideProfile: options.overrideProfile ?? null,
480
+ nonPersistedPrefixCount: this._nonPersistedPrefixCount,
481
+ });
482
+ } finally {
483
+ this.clearSystemPromptCache();
484
+ }
485
+ }
486
+
431
487
  /**
432
488
  * Recompute the prompt-token estimate against the post-compaction
433
489
  * message array. The compactor returns a conservative placeholder; the
@@ -37,8 +37,7 @@ import { getConfig } from "../../../../config/loader.js";
37
37
  import { findConversationOrSubagent } from "../../../../daemon/conversation-registry.js";
38
38
  import {
39
39
  applyRuntimeInjections,
40
- type InboundActorContext,
41
- type InjectionMode,
40
+ resolveTurnInboundActorContext,
42
41
  type RuntimeInjectionResult,
43
42
  } from "../../../../daemon/conversation-runtime-assembly.js";
44
43
  import type { ServerMessage } from "../../../../daemon/message-protocol.js";
@@ -78,12 +77,6 @@ export interface MemoryRetrievalHookContext {
78
77
  * context. The one turn-identity field the live conversation can't recover.
79
78
  */
80
79
  readonly requestId: string | undefined;
81
- /**
82
- * Injection volume for the assembled blocks. `"full"` on the first call;
83
- * overflow reduction downgrades subsequent re-injection, but the first-call
84
- * assembly this hook performs runs at the loop's committed mode.
85
- */
86
- readonly mode: InjectionMode;
87
80
  /**
88
81
  * Whether the in-flight turn has no human present to answer clarification
89
82
  * questions. Resolved once at turn start and threaded in so injection uses
@@ -96,12 +89,6 @@ export interface MemoryRetrievalHookContext {
96
89
  * at turn start, so it is threaded in rather than re-derived.
97
90
  */
98
91
  readonly modelProfile: string | null;
99
- /**
100
- * Inbound actor identity and trust fields for the unified `<turn_context>`
101
- * block, or `null` on guardian turns (which suppress the actor section).
102
- * Resolved once at turn start from the actor-trust resolver and threaded in.
103
- */
104
- readonly actorContext: InboundActorContext | null;
105
92
  }
106
93
 
107
94
  /**
@@ -266,6 +253,10 @@ const userPromptSubmitMemoryRetrieval: PluginHookFn<
266
253
  const abortSignal = conversation?.abortController?.signal;
267
254
  const isTrustedActor =
268
255
  resolveTrustClass(conversation?.trustContext) === "guardian";
256
+ const actorContext = resolveTurnInboundActorContext(
257
+ conversation?.trustContext,
258
+ conversation?.assistantId,
259
+ );
269
260
 
270
261
  if (conversation && isTrustedActor && abortSignal) {
271
262
  const graphResult = await conversation.graphMemory.prepareMemory(
@@ -309,13 +300,16 @@ const userPromptSubmitMemoryRetrieval: PluginHookFn<
309
300
  // input — the Slack chronological transcript, the unified `<turn_context>`
310
301
  // block, channel/voice/transport hints, and the turn's trust/index/call-site
311
302
  // — from the live conversation, so we hand in only the request id and
312
- // conversation id plus the fields resolved once at turn start (`mode`,
313
- // `isNonInteractive`, `modelProfile`, `actorContext`).
303
+ // conversation id plus the fields resolved once at turn start
304
+ // (`isNonInteractive`, `modelProfile`). The unified `<turn_context>` actor
305
+ // input is self-resolved from the live conversation's trust context. This
306
+ // first-call assembly always runs at `"full"` volume; overflow reduction only
307
+ // downgrades the mode on later re-injection.
314
308
  const injection = await applyRuntimeInjections(ctx.latestMessages, {
315
309
  isNonInteractive: ctx.isNonInteractive,
316
310
  modelProfile: ctx.modelProfile,
317
- actorContext: ctx.actorContext,
318
- mode: ctx.mode,
311
+ actorContext,
312
+ mode: "full",
319
313
  requestId: ctx.requestId,
320
314
  conversationId: ctx.conversationId,
321
315
  });
@@ -1,18 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
- CAPABILITIES_LEAF_PATH,
4
+ capabilityOrDiskBody,
5
5
  type CapabilityResolvers,
6
- injectCapabilitiesLeaf,
7
6
  isCapabilitySlug,
8
7
  renderCapabilityContent,
9
8
  } from "../capabilities.js";
10
- import { buildNeedleIndex } from "../needle.js";
11
- import type { LeafPath, LeafTree, Slug } from "../types.js";
12
-
13
- function emptyTree(): LeafTree {
14
- return { leaves: new Map(), byPage: new Map<Slug, LeafPath[]>() };
15
- }
16
9
 
17
10
  describe("isCapabilitySlug", () => {
18
11
  test("true for skill and CLI-command slugs, false otherwise", () => {
@@ -23,52 +16,6 @@ describe("isCapabilitySlug", () => {
23
16
  });
24
17
  });
25
18
 
26
- describe("injectCapabilitiesLeaf", () => {
27
- test("registers an always-on leaf with the synthetic slugs as members", () => {
28
- const tree = emptyTree();
29
- const core = new Set<LeafPath>();
30
- injectCapabilitiesLeaf(tree, core, ["skills/foo", "cli-commands/bar"]);
31
-
32
- const leaf = tree.leaves.get(CAPABILITIES_LEAF_PATH);
33
- expect(leaf?.frontmatter.in_core).toBe(true);
34
- expect(leaf?.members).toEqual(["skills/foo", "cli-commands/bar"]);
35
- expect(core.has(CAPABILITIES_LEAF_PATH)).toBe(true);
36
- });
37
-
38
- test("unions the leaf into each member's byPage entry without dropping existing leaves", () => {
39
- const tree = emptyTree();
40
- tree.byPage.set("skills/foo", ["other/leaf"]);
41
- injectCapabilitiesLeaf(tree, new Set<LeafPath>(), [
42
- "skills/foo",
43
- "cli-commands/bar",
44
- ]);
45
-
46
- expect(tree.byPage.get("skills/foo")).toEqual([
47
- "other/leaf",
48
- CAPABILITIES_LEAF_PATH,
49
- ]);
50
- expect(tree.byPage.get("cli-commands/bar")).toEqual([
51
- CAPABILITIES_LEAF_PATH,
52
- ]);
53
- });
54
-
55
- test("is idempotent — re-injecting does not duplicate the byPage entry", () => {
56
- const tree = emptyTree();
57
- const core = new Set<LeafPath>();
58
- injectCapabilitiesLeaf(tree, core, ["skills/foo"]);
59
- injectCapabilitiesLeaf(tree, core, ["skills/foo"]);
60
- expect(tree.byPage.get("skills/foo")).toEqual([CAPABILITIES_LEAF_PATH]);
61
- });
62
-
63
- test("stays always-on even with no installed skills/commands", () => {
64
- const tree = emptyTree();
65
- const core = new Set<LeafPath>();
66
- injectCapabilitiesLeaf(tree, core, []);
67
- expect(tree.leaves.get(CAPABILITIES_LEAF_PATH)?.members).toEqual([]);
68
- expect(core.has(CAPABILITIES_LEAF_PATH)).toBe(true);
69
- });
70
- });
71
-
72
19
  describe("renderCapabilityContent", () => {
73
20
  const resolvers: CapabilityResolvers = {
74
21
  skill: (slug) =>
@@ -100,19 +47,25 @@ describe("renderCapabilityContent", () => {
100
47
  });
101
48
  });
102
49
 
103
- describe("needle indexes injected capability members", () => {
104
- test("a skill slug is lexically retrievable by its title and summary", async () => {
105
- const tree = emptyTree();
106
- injectCapabilitiesLeaf(tree, new Set<LeafPath>(), ["skills/meet-join"]);
50
+ describe("capabilityOrDiskBody", () => {
51
+ test("a capability slug routes through renderCapabilityContent and never reads disk", async () => {
52
+ let diskReads = 0;
53
+ const readDiskBody = async (): Promise<string> => {
54
+ diskReads += 1;
55
+ return "disk body — should not be read";
56
+ };
57
+ // A capability slug whose production cache has no entry degrades to "" (the
58
+ // `renderCapabilityContent` contract) rather than falling through to the
59
+ // injected disk reader, which must stay untouched.
60
+ expect(await capabilityOrDiskBody("skills/example", readDiskBody)).toBe("");
61
+ expect(diskReads).toBe(0);
62
+ });
107
63
 
108
- const needle = await buildNeedleIndex(
109
- tree,
110
- async () => "Join and transcribe a video meeting",
64
+ test("a non-capability slug returns the injected disk reader's result", async () => {
65
+ const readDiskBody = async (slug: string): Promise<string> =>
66
+ `disk body for ${slug}`;
67
+ expect(await capabilityOrDiskBody("relationship/vows", readDiskBody)).toBe(
68
+ "disk body for relationship/vows",
111
69
  );
112
-
113
- // By slug-derived title token.
114
- expect(needle.query("meet", 5)).toContain("skills/meet-join");
115
- // By summary token.
116
- expect(needle.query("transcribe", 5)).toContain("skills/meet-join");
117
70
  });
118
71
  });