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

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 (111) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/activation-early-marking.test.ts +120 -0
  4. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  5. package/src/__tests__/approval-cascade.test.ts +1 -1
  6. package/src/__tests__/compaction-direct.test.ts +32 -18
  7. package/src/__tests__/compaction-events.test.ts +2 -2
  8. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  9. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  10. package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
  11. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  12. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  13. package/src/__tests__/conversation-error.test.ts +15 -1
  14. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  15. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  16. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  17. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  18. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +1 -1
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  21. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  23. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  24. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  25. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  26. package/src/__tests__/conversation-undo.test.ts +2 -2
  27. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  30. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  31. package/src/__tests__/history-repair-hook.test.ts +2 -0
  32. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  33. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  34. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  35. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  36. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  37. package/src/__tests__/title-generate-hook.test.ts +2 -0
  38. package/src/__tests__/web-fetch.test.ts +45 -0
  39. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  40. package/src/acp/resolve-agent.test.ts +0 -56
  41. package/src/acp/resolve-agent.ts +10 -38
  42. package/src/agent/loop.ts +13 -27
  43. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  44. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  45. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  47. package/src/cli/lib/install-from-github.ts +258 -41
  48. package/src/cli/lib/plugin-details.ts +20 -13
  49. package/src/cli/lib/plugin-marketplace.ts +23 -5
  50. package/src/cli/lib/search-plugins.ts +14 -8
  51. package/src/config/acp-defaults.ts +3 -3
  52. package/src/config/acp-schema.ts +1 -7
  53. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  54. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  55. package/src/config/feature-flag-registry.json +3 -18
  56. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  57. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  58. package/src/daemon/conversation-agent-loop.ts +78 -78
  59. package/src/daemon/conversation-error.ts +31 -4
  60. package/src/daemon/conversation-history.ts +1 -1
  61. package/src/daemon/conversation-media-retry.ts +19 -6
  62. package/src/daemon/conversation-messaging.ts +17 -0
  63. package/src/daemon/conversation-process.ts +14 -5
  64. package/src/daemon/conversation-queue-manager.ts +8 -0
  65. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  66. package/src/daemon/conversation-surfaces.ts +141 -3
  67. package/src/daemon/conversation.ts +48 -13
  68. package/src/daemon/persist-unsendable-image.ts +62 -25
  69. package/src/daemon/process-message.ts +1 -1
  70. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  71. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  72. package/src/memory/activation-session-store.ts +43 -0
  73. package/src/memory/db-init.ts +4 -0
  74. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  75. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  76. package/src/memory/migrations/index.ts +2 -0
  77. package/src/memory/onboarding-events-store.ts +66 -18
  78. package/src/memory/schema/infrastructure.ts +13 -0
  79. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  80. package/src/notifications/adapters/telegram.ts +7 -1
  81. package/src/plugin-api/constants.ts +2 -2
  82. package/src/plugin-api/index.ts +2 -2
  83. package/src/plugin-api/types.ts +19 -5
  84. package/src/plugins/defaults/compaction/compact.ts +24 -15
  85. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  86. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  87. package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
  88. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  89. package/src/prompts/system-prompt.ts +61 -10
  90. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  91. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  92. package/src/providers/openai/chat-completions-provider.ts +25 -0
  93. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  94. package/src/runtime/routes/acp-routes.test.ts +3 -20
  95. package/src/runtime/routes/conversation-routes.ts +2 -0
  96. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  97. package/src/runtime/routes/stt-routes.ts +45 -12
  98. package/src/runtime/routes/workspace-routes.ts +50 -15
  99. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  100. package/src/telemetry/activation-funnel.ts +167 -0
  101. package/src/telemetry/types.ts +13 -0
  102. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  103. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  104. package/src/tools/acp/list-agents.test.ts +2 -18
  105. package/src/tools/acp/list-agents.ts +3 -15
  106. package/src/tools/acp/spawn.test.ts +0 -10
  107. package/src/tools/browser/browser-execution.ts +12 -2
  108. package/src/tools/network/web-fetch.ts +65 -24
  109. package/src/tools/ui-surface/definitions.ts +7 -0
  110. package/src/acp/feature-gate.test.ts +0 -48
  111. package/src/acp/feature-gate.ts +0 -34
@@ -2,6 +2,12 @@ import { and, asc, eq, gt, or } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import { getConfig } from "../config/loader.js";
5
+ import {
6
+ ACTIVATION_AB_VARIANT,
7
+ ACTIVATION_FUNNEL_VERSION,
8
+ activationStepIndex,
9
+ type ActivationStepName,
10
+ } from "../telemetry/activation-funnel.js";
5
11
  import { getDb } from "./db-connection.js";
6
12
  import { onboardingEvents } from "./schema.js";
7
13
 
@@ -16,6 +22,11 @@ export interface OnboardingEvent {
16
22
  googleScopesJson: string | null;
17
23
  priorAssistantsJson: string | null;
18
24
  abVariant: string | null;
25
+ sessionId: string | null;
26
+ stepName: string | null;
27
+ stepIndex: number | null;
28
+ completedAt: string | null;
29
+ funnelVersion: string | null;
19
30
  }
20
31
 
21
32
  export interface RecordOnboardingEventParams {
@@ -27,6 +38,17 @@ export interface RecordOnboardingEventParams {
27
38
  googleScopes?: string[];
28
39
  priorAssistants?: string[];
29
40
  abVariant?: string;
41
+ sessionId?: string | null;
42
+ stepName?: string | null;
43
+ stepIndex?: number | null;
44
+ completedAt?: string | null;
45
+ funnelVersion?: string | null;
46
+ }
47
+
48
+ /** Insert a fully-built event row. Shared by all record* entry points. */
49
+ function insertOnboardingEvent(event: OnboardingEvent): OnboardingEvent {
50
+ getDb().insert(onboardingEvents).values(event).run();
51
+ return event;
30
52
  }
31
53
 
32
54
  /**
@@ -37,8 +59,7 @@ export function recordOnboardingEvent(
37
59
  params: RecordOnboardingEventParams,
38
60
  ): OnboardingEvent | null {
39
61
  if (!getConfig().collectUsageData) return null;
40
- const db = getDb();
41
- const event: OnboardingEvent = {
62
+ return insertOnboardingEvent({
42
63
  id: uuid(),
43
64
  createdAt: Date.now(),
44
65
  screen: params.screen,
@@ -53,22 +74,44 @@ export function recordOnboardingEvent(
53
74
  ? JSON.stringify(params.priorAssistants)
54
75
  : null,
55
76
  abVariant: params.abVariant ?? null,
56
- };
57
- db.insert(onboardingEvents)
58
- .values({
59
- id: event.id,
60
- createdAt: event.createdAt,
61
- screen: event.screen,
62
- toolsJson: event.toolsJson,
63
- tasksJson: event.tasksJson,
64
- tone: event.tone,
65
- googleConnected: event.googleConnected,
66
- googleScopesJson: event.googleScopesJson,
67
- priorAssistantsJson: event.priorAssistantsJson,
68
- abVariant: event.abVariant,
69
- })
70
- .run();
71
- return event;
77
+ sessionId: params.sessionId ?? null,
78
+ stepName: params.stepName ?? null,
79
+ stepIndex: params.stepIndex ?? null,
80
+ completedAt: params.completedAt ?? null,
81
+ funnelVersion: params.funnelVersion ?? null,
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Record an activation-funnel milestone event. Reuses the onboarding telemetry
87
+ * substrate (`screen` carries the step name to satisfy the NOT NULL column).
88
+ * Returns null when usage data collection is disabled.
89
+ */
90
+ export function recordActivationEvent(params: {
91
+ stepName: ActivationStepName;
92
+ sessionId: string;
93
+ userId?: string | null;
94
+ abVariant?: string;
95
+ }): OnboardingEvent | null {
96
+ if (!getConfig().collectUsageData) return null;
97
+ const createdAt = Date.now();
98
+ return insertOnboardingEvent({
99
+ id: uuid(),
100
+ createdAt,
101
+ screen: params.stepName,
102
+ toolsJson: null,
103
+ tasksJson: null,
104
+ tone: null,
105
+ googleConnected: null,
106
+ googleScopesJson: null,
107
+ priorAssistantsJson: null,
108
+ abVariant: params.abVariant ?? ACTIVATION_AB_VARIANT,
109
+ sessionId: params.sessionId,
110
+ stepName: params.stepName,
111
+ stepIndex: activationStepIndex(params.stepName),
112
+ completedAt: new Date(createdAt).toISOString(),
113
+ funnelVersion: ACTIVATION_FUNNEL_VERSION,
114
+ });
72
115
  }
73
116
 
74
117
  /**
@@ -93,6 +136,11 @@ export function queryUnreportedOnboardingEvents(
93
136
  googleScopesJson: onboardingEvents.googleScopesJson,
94
137
  priorAssistantsJson: onboardingEvents.priorAssistantsJson,
95
138
  abVariant: onboardingEvents.abVariant,
139
+ sessionId: onboardingEvents.sessionId,
140
+ stepName: onboardingEvents.stepName,
141
+ stepIndex: onboardingEvents.stepIndex,
142
+ completedAt: onboardingEvents.completedAt,
143
+ funnelVersion: onboardingEvents.funnelVersion,
96
144
  })
97
145
  .from(onboardingEvents)
98
146
  .where(
@@ -277,6 +277,11 @@ export const onboardingEvents = sqliteTable("onboarding_events", {
277
277
  googleScopesJson: text("google_scopes_json"),
278
278
  priorAssistantsJson: text("prior_assistants_json"),
279
279
  abVariant: text("ab_variant"),
280
+ sessionId: text("session_id"),
281
+ stepName: text("step_name"),
282
+ stepIndex: integer("step_index"),
283
+ completedAt: text("completed_at"),
284
+ funnelVersion: text("funnel_version"),
280
285
  });
281
286
 
282
287
  // Aggregated legacy-loopback auth-fallback counts forwarded by the gateway.
@@ -294,6 +299,14 @@ export const authFallbackEvents = sqliteTable("auth_fallback_events", {
294
299
  windowEnd: integer("window_end").notNull(),
295
300
  });
296
301
 
302
+ // One row per conversation started on the activation-rail bootstrap template.
303
+ // Lets the activation funnel telemetry scope its events to activation
304
+ // conversations without inspecting the bootstrap template at emit time.
305
+ export const activationSessions = sqliteTable("activation_sessions", {
306
+ conversationId: text("conversation_id").primaryKey(),
307
+ createdAt: integer("created_at").notNull(),
308
+ });
309
+
297
310
  export const traceEvents = sqliteTable(
298
311
  "trace_events",
299
312
  {
@@ -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,23 @@
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
+ runAssistantDrivenCompaction,
24
+ } from "../../../context/compactor.js";
25
+ import { estimatePromptTokens } from "../../../context/token-estimator.js";
21
26
  import type {
22
27
  ContentBlock,
23
28
  Message,
24
29
  Provider,
25
30
  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";
31
+ } from "../../../providers/types.js";
32
+ import type { TrustClass } from "../../../runtime/actor-trust-resolver.js";
33
+ import { getLogger } from "../../../util/logger.js";
34
34
 
35
35
  const log = getLogger("context-window");
36
36
 
@@ -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
  });
@@ -11,6 +11,8 @@ import { join } from "node:path";
11
11
  import { getIsContainerized } from "../config/env-registry.js";
12
12
  import type { ChannelCapabilities } from "../daemon/conversation-runtime-assembly.js";
13
13
  import type { TrustContext } from "../daemon/trust-context.js";
14
+ import { markActivationSession } from "../memory/activation-session-store.js";
15
+ import { ACTIVATION_RAIL_BOOTSTRAP_TEMPLATE } from "../telemetry/activation-funnel.js";
14
16
  import type { OnboardingContext } from "../types/onboarding-context.js";
15
17
  import { resolveBundledDir } from "../util/bundled-asset.js";
16
18
  import { getLogger } from "../util/logger.js";
@@ -206,7 +208,7 @@ export function ensurePromptFiles(): void {
206
208
  * overwrite the workspace file with the specified variant. No-op when
207
209
  * BOOTSTRAP.md has been deleted, modified, or the template file is missing.
208
210
  */
209
- export function maybeReseedBootstrap(templateFileName: string): void {
211
+ export function maybeReseedBootstrap(templateFileName: string): boolean {
210
212
  // Path traversal guard: reject filenames containing directory separators or
211
213
  // parent-directory references, and require a `.md` extension.
212
214
  if (
@@ -218,11 +220,11 @@ export function maybeReseedBootstrap(templateFileName: string): void {
218
220
  { templateFileName },
219
221
  "Rejected bootstrap template filename: invalid characters or extension",
220
222
  );
221
- return;
223
+ return false;
222
224
  }
223
225
 
224
226
  const bootstrapPath = getWorkspacePromptPath("BOOTSTRAP.md");
225
- if (!existsSync(bootstrapPath)) return;
227
+ if (!existsSync(bootstrapPath)) return false;
226
228
 
227
229
  const currentContent = readPromptFile(bootstrapPath);
228
230
  // Compare against the GENERIC "BOOTSTRAP.md" template, not the specified
@@ -230,7 +232,7 @@ export function maybeReseedBootstrap(templateFileName: string): void {
230
232
  // template, so this guard returns false on subsequent calls — making the
231
233
  // swap idempotent. Do NOT change the comparison target to the provided
232
234
  // template filename; that would re-swap on every prompt build.
233
- if (!isTemplateContent(currentContent, "BOOTSTRAP.md")) return;
235
+ if (!isTemplateContent(currentContent, "BOOTSTRAP.md")) return false;
234
236
 
235
237
  const templatesDir = resolveBundledDir(
236
238
  import.meta.dirname ?? __dirname,
@@ -243,7 +245,7 @@ export function maybeReseedBootstrap(templateFileName: string): void {
243
245
  { templateFileName },
244
246
  "Bootstrap template not found, keeping generic BOOTSTRAP.md",
245
247
  );
246
- return;
248
+ return false;
247
249
  }
248
250
 
249
251
  try {
@@ -253,11 +255,51 @@ export function maybeReseedBootstrap(templateFileName: string): void {
253
255
  { templateFileName },
254
256
  "Replaced generic BOOTSTRAP.md with specified template",
255
257
  );
258
+ return true;
256
259
  } catch (err) {
257
260
  log.warn(
258
261
  { err, templateFileName },
259
262
  "Failed to reseed BOOTSTRAP.md with template",
260
263
  );
264
+ return false;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Reseed BOOTSTRAP.md from the requested onboarding template (one-shot,
270
+ * idempotent) and, when that template is the activation rail AND the rail is
271
+ * now the active bootstrap, mark the conversation as an activation session.
272
+ *
273
+ * Marking happens here — at the single point where the bootstrap selection is
274
+ * known — so it lands BEFORE the agent loop resolves tools and BEFORE the model
275
+ * can call the emit tool on the first activation-rail turn. (`resolveTools`
276
+ * runs before `resolveSystemPrompt` in the loop, so the system-prompt build is
277
+ * too late to be the *only* marking site.) The marker write is best-effort and
278
+ * idempotent (`markActivationSession` swallows errors and dedups on the PK), so
279
+ * calling this from both `setOnboardingContext` and `buildSystemPrompt` is safe.
280
+ *
281
+ * The activation mark is gated on the rail actually being the active bootstrap —
282
+ * either this call just installed it, OR BOOTSTRAP.md already holds the
283
+ * activation-rail template. When the reseed no-ops because BOOTSTRAP.md is
284
+ * missing or customized to something else, the rail is NOT active, so we must
285
+ * NOT mark (otherwise non-rail conversations would pollute activation
286
+ * telemetry).
287
+ */
288
+ export function applyBootstrapTemplate(
289
+ bootstrapTemplate: string,
290
+ conversationId?: string,
291
+ ): void {
292
+ const installedActivationRail = maybeReseedBootstrap(bootstrapTemplate);
293
+ if (
294
+ bootstrapTemplate === ACTIVATION_RAIL_BOOTSTRAP_TEMPLATE &&
295
+ conversationId &&
296
+ (installedActivationRail ||
297
+ isTemplateContent(
298
+ readPromptFile(getWorkspacePromptPath("BOOTSTRAP.md")),
299
+ ACTIVATION_RAIL_BOOTSTRAP_TEMPLATE,
300
+ ))
301
+ ) {
302
+ markActivationSession(conversationId);
261
303
  }
262
304
  }
263
305
 
@@ -268,6 +310,13 @@ export interface BuildSystemPromptOptions {
268
310
  trustContext?: TrustContext;
269
311
  channelCapabilities?: ChannelCapabilities;
270
312
  onboardingContext?: OnboardingContext;
313
+ /**
314
+ * Conversation this prompt is being built for. Optional because several
315
+ * callers build a prompt outside a conversation (e.g. home greeting,
316
+ * suggested prompts). When present and the activation-rail bootstrap template
317
+ * is selected, the conversation is marked as an activation session.
318
+ */
319
+ conversationId?: string;
271
320
  }
272
321
 
273
322
  /**
@@ -278,11 +327,13 @@ export interface BuildSystemPromptOptions {
278
327
  * file-backed bodies, and runtime-computed transforms.
279
328
  */
280
329
  export function buildSystemPrompt(options?: BuildSystemPromptOptions): string {
281
- // One-shot bootstrap swap: if the onboarding context specifies a bootstrap
282
- // template and BOOTSTRAP.md is still the generic template, replace it with
283
- // the specified variant before the prompt reads the file.
284
- if (options?.onboardingContext?.bootstrapTemplate) {
285
- maybeReseedBootstrap(options.onboardingContext.bootstrapTemplate);
330
+ // One-shot bootstrap reseed + activation-rail marking. The marking also runs
331
+ // earlier, at `setOnboardingContext`, so the activation session is recorded
332
+ // before the agent loop resolves tools on the first turn; this call is a
333
+ // harmless idempotent backstop for prompt builds outside that path.
334
+ const bootstrapTemplate = options?.onboardingContext?.bootstrapTemplate;
335
+ if (bootstrapTemplate) {
336
+ applyBootstrapTemplate(bootstrapTemplate, options?.conversationId);
286
337
  }
287
338
 
288
339
  // Slugs used by the persona sections (`10-user-persona`,