@capekai/core 1.0.0

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 (148) hide show
  1. package/README.md +12 -0
  2. package/package.json +105 -0
  3. package/src/adapters/ai-sdk.ts +84 -0
  4. package/src/compaction/contracts.ts +82 -0
  5. package/src/compaction/executor.ts +161 -0
  6. package/src/compaction/policy.ts +318 -0
  7. package/src/compaction/recovery.ts +139 -0
  8. package/src/compaction/task.ts +540 -0
  9. package/src/configuration/contracts.ts +58 -0
  10. package/src/configuration/defaults.ts +27 -0
  11. package/src/configuration/runtime.ts +42 -0
  12. package/src/configuration/single-model.ts +75 -0
  13. package/src/context/assembler.ts +112 -0
  14. package/src/context/index.ts +2 -0
  15. package/src/context/sources.ts +119 -0
  16. package/src/context/workspace.ts +63 -0
  17. package/src/core/agent.ts +401 -0
  18. package/src/core/build-tools.ts +139 -0
  19. package/src/core/chat-handler.ts +858 -0
  20. package/src/core/error-handling.ts +18 -0
  21. package/src/core/fork.ts +103 -0
  22. package/src/core/interrupt.ts +192 -0
  23. package/src/core/message-utils.ts +261 -0
  24. package/src/core/model-utils.ts +149 -0
  25. package/src/core/part-utils.ts +88 -0
  26. package/src/core/provider-utils.ts +67 -0
  27. package/src/core/revert.ts +46 -0
  28. package/src/core/step-handlers.ts +157 -0
  29. package/src/core/stream/finalization.ts +65 -0
  30. package/src/core/stream/stream-config.ts +82 -0
  31. package/src/core/stream-handlers.ts +242 -0
  32. package/src/core/structured-output.ts +68 -0
  33. package/src/core/tool-builders/agent-tools.ts +71 -0
  34. package/src/core/tool-builders/external-tools.ts +179 -0
  35. package/src/core/tool-builders/types.ts +16 -0
  36. package/src/core/tool-builders/workspace-tools.ts +293 -0
  37. package/src/core/tool-capabilities.ts +65 -0
  38. package/src/goals/evaluator.ts +171 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/loop.ts +167 -0
  41. package/src/goals/service.ts +39 -0
  42. package/src/index.ts +10 -0
  43. package/src/internal/ask-authority.ts +29 -0
  44. package/src/internal/composition.ts +44 -0
  45. package/src/internal/configuration.ts +22 -0
  46. package/src/internal/execution.ts +108 -0
  47. package/src/internal/hosts.ts +64 -0
  48. package/src/internal/plugins.ts +71 -0
  49. package/src/internal/providers.ts +32 -0
  50. package/src/internal/sandbox.ts +19 -0
  51. package/src/internal/tools.ts +48 -0
  52. package/src/internal/workspace.ts +25 -0
  53. package/src/kernel/diagnostics.ts +249 -0
  54. package/src/kernel/errors.ts +120 -0
  55. package/src/kernel/events.ts +82 -0
  56. package/src/kernel/index.ts +72 -0
  57. package/src/kernel/kernel.ts +62 -0
  58. package/src/kernel/lifecycle.ts +72 -0
  59. package/src/kernel/plugin.ts +218 -0
  60. package/src/kernel/registry.ts +493 -0
  61. package/src/kernel/scope.ts +776 -0
  62. package/src/kernel/service-key.ts +19 -0
  63. package/src/kernel/types.ts +317 -0
  64. package/src/memory/index.ts +2 -0
  65. package/src/memory/memory-tool.ts +75 -0
  66. package/src/memory/registry.ts +172 -0
  67. package/src/permission/ask-user-api.ts +70 -0
  68. package/src/permission/contracts.ts +135 -0
  69. package/src/permission/permission-request-manager.ts +58 -0
  70. package/src/permission/policy.ts +277 -0
  71. package/src/permission/runtime.ts +612 -0
  72. package/src/plugins/compaction-policy.ts +46 -0
  73. package/src/plugins/compose.ts +171 -0
  74. package/src/plugins/context-sections.ts +246 -0
  75. package/src/plugins/default-agent-driver.ts +14 -0
  76. package/src/plugins/facade-plugins.ts +129 -0
  77. package/src/plugins/goal-domain.ts +82 -0
  78. package/src/plugins/legacy-system-message.ts +152 -0
  79. package/src/plugins/loaded-tools.ts +23 -0
  80. package/src/plugins/memory-domain.ts +264 -0
  81. package/src/plugins/orchestrator-session.ts +29 -0
  82. package/src/plugins/permission-policy.ts +49 -0
  83. package/src/plugins/retry-policy.ts +28 -0
  84. package/src/plugins/scheduler-domain.ts +192 -0
  85. package/src/plugins/service-keys.ts +294 -0
  86. package/src/plugins/session-search-domain.ts +238 -0
  87. package/src/plugins/skills-domain.ts +272 -0
  88. package/src/plugins/subagent-domain.ts +287 -0
  89. package/src/plugins/tool-catalog.ts +78 -0
  90. package/src/plugins/tool-output-policy.ts +52 -0
  91. package/src/plugins/value-plugins.ts +150 -0
  92. package/src/plugins/workflow-domain.ts +198 -0
  93. package/src/plugins/workspace-policy.ts +37 -0
  94. package/src/providers/registry.ts +63 -0
  95. package/src/providers/types.ts +44 -0
  96. package/src/retry/policy.ts +282 -0
  97. package/src/retry/stream-chat.ts +312 -0
  98. package/src/runtime/agent-runtime.ts +83 -0
  99. package/src/runtime/default-agent-driver.ts +23 -0
  100. package/src/runtime/domain-tool-source.ts +156 -0
  101. package/src/runtime/events.ts +61 -0
  102. package/src/runtime/host-dependencies.ts +71 -0
  103. package/src/runtime/host-guidance.ts +22 -0
  104. package/src/runtime/host-layout.ts +23 -0
  105. package/src/runtime/host.ts +129 -0
  106. package/src/runtime/standalone-host.ts +118 -0
  107. package/src/sandbox/controller.ts +204 -0
  108. package/src/sandbox/model.ts +305 -0
  109. package/src/sandbox/provider.ts +53 -0
  110. package/src/sandbox/types.ts +110 -0
  111. package/src/scheduler/host.ts +22 -0
  112. package/src/scheduler/scheduler-tool.ts +172 -0
  113. package/src/session-search/host.ts +56 -0
  114. package/src/session-search/index.ts +23 -0
  115. package/src/session-search/session-search-tool.ts +151 -0
  116. package/src/skills/index.ts +3 -0
  117. package/src/skills/registry.ts +63 -0
  118. package/src/skills/skill-manage-tool.ts +205 -0
  119. package/src/skills/skill-tool.ts +42 -0
  120. package/src/storage/contracts.ts +159 -0
  121. package/src/storage/memory.ts +321 -0
  122. package/src/storage/options.ts +75 -0
  123. package/src/storage/runtime.ts +115 -0
  124. package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
  125. package/src/storage/sqlite.ts +321 -0
  126. package/src/storage/tool-output-artifacts.ts +75 -0
  127. package/src/storage.ts +31 -0
  128. package/src/subagent/child-session.ts +282 -0
  129. package/src/subagent/guidance.ts +8 -0
  130. package/src/subagent/policy.ts +198 -0
  131. package/src/subagent/task-tool.ts +584 -0
  132. package/src/tool-output/contracts.ts +111 -0
  133. package/src/tool-output/policy.ts +410 -0
  134. package/src/tool.ts +1 -0
  135. package/src/tools/executor.ts +258 -0
  136. package/src/tools/install-manifest.ts +40 -0
  137. package/src/tools/llm-api.ts +77 -0
  138. package/src/tools/registry.ts +206 -0
  139. package/src/tools/tool-artifact.ts +182 -0
  140. package/src/tools/tool-source.ts +53 -0
  141. package/src/utils/errors.ts +334 -0
  142. package/src/utils/strip-visualization.ts +50 -0
  143. package/src/workflow/decomposer.ts +139 -0
  144. package/src/workflow/execution.ts +523 -0
  145. package/src/workflow/orchestrator-session.ts +161 -0
  146. package/src/workflow/synthesizer.ts +130 -0
  147. package/src/workspace/contracts.ts +135 -0
  148. package/src/workspace/policy.ts +327 -0
@@ -0,0 +1,540 @@
1
+ /**
2
+ * C6 compaction task pipeline: trigger creation, conversation text, summary
3
+ * generation, budget-aware pruning, and failure persistence. All behavior is
4
+ * moved verbatim from the pre-C6 `core/compaction.ts`; the safety invariants
5
+ * (minimum-message validation, trigger validation, boundary validation, and
6
+ * the main-session requirement enforced by the executor) stay hard errors
7
+ * and are not configurable.
8
+ *
9
+ * Named core edges (AST-gated by `compaction-domain-no-core`): model
10
+ * construction (`core/model-utils`) and provider discovery
11
+ * (`core/provider-utils`) stay in core until C7.
12
+ */
13
+
14
+ import { streamText as aiStreamText } from 'ai';
15
+ import { randomUUID } from 'crypto';
16
+ import type {
17
+ AssistantMessage, CompactionPart, MessageWithParts, TextPart, ToolPart } from '@capekai/types';
18
+ import { getModelWithMetadata } from '../core/model-utils';
19
+ import { findProviderFromModel } from '../core/provider-utils';
20
+ import { getModelsConfig } from '../configuration/runtime';
21
+ import { emitRuntimeEvent, type BroadcastFn } from '../runtime/host-dependencies';
22
+ import {
23
+ buildEffectiveContextHistory,
24
+ createMessage,
25
+ createPart,
26
+ getPartsBySession,
27
+ listMessagesWithParts,
28
+ updatePart,
29
+ } from '../storage/runtime';
30
+ import type {
31
+ CompactionPolicy,
32
+ CompactionTrigger,
33
+ CompactionTriggerReason,
34
+ CompactionTaskResult,
35
+ GenerateSummaryFn,
36
+ } from './contracts';
37
+
38
+ const COMPACTION_PROMPT_FIRST = `Summarize the following conversation for context continuity.
39
+
40
+ Structure your response with these sections:
41
+ - **Decisions**: Key choices made and rationale
42
+ - **Changes**: Files/functions created or modified (with paths)
43
+ - **Context**: Important state, configurations, or patterns established
44
+ - **Open items**: Unresolved issues or planned next steps
45
+
46
+ Be specific with file paths, function names, and technical details.
47
+
48
+ Conversation to summarize:
49
+
50
+ {CONVERSATION}`;
51
+
52
+ const COMPACTION_PROMPT_INCREMENTAL = `The following is a previous conversation summary, followed by new messages since that summary.
53
+
54
+ Produce an UPDATED summary that incorporates the new information. Keep it concise and structured.
55
+
56
+ Structure your response with these sections:
57
+ - **Decisions**: Key choices made and rationale
58
+ - **Changes**: Files/functions created or modified (with paths)
59
+ - **Context**: Important state, configurations, or patterns established
60
+ - **Open items**: Unresolved issues or planned next steps
61
+
62
+ Previous summary:
63
+ {PREVIOUS_SUMMARY}
64
+
65
+ New messages since that summary:
66
+ {CONVERSATION}`;
67
+
68
+ /**
69
+ * Creates a compaction trigger message and returns it.
70
+ * The trigger is persisted to the database as a user message with a standard CompactionPart.
71
+ */
72
+ export async function createCompactionTrigger(
73
+ sessionId: string,
74
+ reason: CompactionTriggerReason,
75
+ ): Promise<CompactionTrigger> {
76
+ // Minimum validation: at least 1 user + 1 assistant message needed for meaningful compaction.
77
+ // A single agent turn with heavy tool use can produce enough context to warrant compaction.
78
+ const { messages: effectiveHistory } = await buildEffectiveContextHistory(sessionId);
79
+ const nonSystemCount = effectiveHistory.filter(
80
+ (m: MessageWithParts) => m.message.role !== 'system',
81
+ ).length;
82
+
83
+ if (nonSystemCount < 2) {
84
+ throw new Error('Not enough messages for compaction (need at least a user and assistant turn)');
85
+ }
86
+
87
+ const triggerMessageId = randomUUID();
88
+ const now = Date.now();
89
+
90
+ // Create a trigger message (user role to indicate it came from the user/system)
91
+ const triggerMessage = {
92
+ id: triggerMessageId,
93
+ sessionId,
94
+ role: 'user' as const,
95
+ createdAt: now,
96
+ };
97
+
98
+ await createMessage(triggerMessage);
99
+
100
+ // Create a standard CompactionPart (metadata-only per spec)
101
+ const compactionPart: CompactionPart = {
102
+ id: randomUUID(),
103
+ messageId: triggerMessageId,
104
+ createdAt: now,
105
+ type: 'compaction',
106
+ auto: reason !== 'manual',
107
+ overflow: reason === 'overflow',
108
+ };
109
+
110
+ await createPart(compactionPart, sessionId);
111
+
112
+ return {
113
+ messageId: triggerMessageId,
114
+ reason,
115
+ };
116
+ }
117
+
118
+ export function buildConversationText(messages: MessageWithParts[]): string {
119
+ const lines: string[] = [];
120
+
121
+ for (const { message, parts } of messages) {
122
+ if (message.role === 'system') continue;
123
+
124
+ lines.push(`\n--- ${message.role.toUpperCase()} ---`);
125
+
126
+ for (const part of parts) {
127
+ if (part.type === 'text') {
128
+ lines.push((part as { text: string }).text);
129
+ } else if (part.type === 'tool') {
130
+ const toolPart = part as {
131
+ name: string;
132
+ state: { input: unknown; output?: unknown; status: string; error?: string };
133
+ };
134
+ lines.push(`\n[TOOL: ${toolPart.name}]`);
135
+ lines.push(`Input: ${JSON.stringify(toolPart.state.input, null, 2)}`);
136
+ if (toolPart.state.status === 'completed') {
137
+ lines.push(`Output: ${formatOutput(toolPart.state.output)}`);
138
+ } else if (toolPart.state.status === 'error') {
139
+ lines.push(`Error: ${toolPart.state.error}`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ return lines.join('\n');
146
+ }
147
+
148
+ export function formatOutput(output: unknown): string {
149
+ if (typeof output === 'string') {
150
+ return output.length > 500
151
+ ? output.slice(0, 500) + '...(truncated)'
152
+ : output;
153
+ }
154
+ const str = JSON.stringify(output, null, 2);
155
+ return str.length > 500 ? str.slice(0, 500) + '...(truncated)' : str;
156
+ }
157
+
158
+ /**
159
+ * Estimate the character size of a tool's output.
160
+ * Uses cheap serialization - no need for accurate tokenization here.
161
+ */
162
+ export function estimateToolOutputSize(output: unknown): number {
163
+ if (output === null || output === undefined) {
164
+ return 0;
165
+ }
166
+ if (typeof output === 'string') {
167
+ return output.length;
168
+ }
169
+ // For objects/arrays, serialize to JSON and measure
170
+ try {
171
+ return JSON.stringify(output).length;
172
+ } catch {
173
+ return 0;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Mark tool results as compacted after a successful compaction.
179
+ * WS4: Budget-aware pruning - selectively marks tools based on policy.
180
+ *
181
+ * Pruning strategy:
182
+ * 1. Always protect 'skill' tool outputs
183
+ * 2. Protect small outputs (below preserveSmallToolChars)
184
+ * 3. Protect the N most recent eligible tools (preserveRecentToolCount)
185
+ * 4. Clear older/larger outputs that exceed toolClearCharsThreshold
186
+ * 5. Respect maxPrunedToolCount limit
187
+ *
188
+ * This preserves important recent context while still reducing context size
189
+ * for older, larger tool outputs that are less likely to be relevant.
190
+ */
191
+ async function markToolsAsCompacted(
192
+ sessionId: string,
193
+ compactedMessageIds: string[],
194
+ policy: CompactionPolicy,
195
+ ): Promise<void> {
196
+ const allParts = await getPartsBySession(sessionId);
197
+ const now = Date.now();
198
+
199
+ // Gather eligible completed tool parts within compacted messages
200
+ const eligibleTools: Array<{
201
+ part: ToolPart;
202
+ outputSize: number;
203
+ createdAt: number;
204
+ }> = [];
205
+
206
+ for (const part of allParts) {
207
+ if (part.type !== 'tool') continue;
208
+
209
+ const toolPart = part as ToolPart;
210
+
211
+ // Skip non-completed tools
212
+ if (toolPart.state.status !== 'completed') continue;
213
+
214
+ // Skip tools not in compacted messages
215
+ if (!compactedMessageIds.includes(toolPart.messageId)) continue;
216
+
217
+ // Always protect skill tool outputs
218
+ if (toolPart.name === 'skill') continue;
219
+
220
+ // Estimate output size
221
+ const outputSize = estimateToolOutputSize((toolPart.state as { output?: unknown }).output);
222
+
223
+ // Protect small outputs below threshold
224
+ if (outputSize <= policy.preserveSmallToolChars) continue;
225
+
226
+ eligibleTools.push({
227
+ part: toolPart,
228
+ outputSize,
229
+ createdAt: part.createdAt,
230
+ });
231
+ }
232
+
233
+ // Sort by createdAt descending (most recent first)
234
+ eligibleTools.sort((a, b) => b.createdAt - a.createdAt);
235
+
236
+ // Skip the N most recent eligible tools (preserveRecentToolCount) - they stay protected.
237
+ // The remainder (older tools) become candidates for pruning.
238
+ // Process candidates from oldest to newest to be conservative about what gets cleared.
239
+ const candidatesForPruning = eligibleTools
240
+ .slice(policy.preserveRecentToolCount)
241
+ .sort((a, b) => a.createdAt - b.createdAt);
242
+
243
+ // Apply maxPrunedToolCount limit - only prune up to this many tools
244
+ const toolsToPrune = candidatesForPruning.slice(0, policy.maxPrunedToolCount);
245
+
246
+ // Mark older/larger tools as compacted
247
+ for (const candidate of toolsToPrune) {
248
+ // Only clear tools that exceed the clear threshold
249
+ // (already know they exceed preserveSmallToolChars since we filtered above)
250
+ if (candidate.outputSize > policy.toolClearCharsThreshold) {
251
+ await updatePart(candidate.part.id, {
252
+ state: {
253
+ ...candidate.part.state,
254
+ compactedAt: now,
255
+ },
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Default implementation that uses AI SDK streamText.
263
+ * Uses streamText universally; required for providers like Codex/OpenAI
264
+ * Responses API that reject non-streaming calls.
265
+ */
266
+ async function defaultGenerateSummary(
267
+ prompt: string,
268
+ policy: CompactionPolicy,
269
+ sessionId: string,
270
+ abortSignal?: AbortSignal,
271
+ ): Promise<{
272
+ text: string;
273
+ usage: {
274
+ prompt: number;
275
+ completion: number;
276
+ cacheRead?: number;
277
+ cacheWrite?: number;
278
+ noCache?: number;
279
+ };
280
+ effectiveModelId: string;
281
+ effectiveProviderId: string;
282
+ }> {
283
+ const { model, omitMaxOutputTokens, providerOptions } = await getModelWithMetadata({
284
+ modelId: policy.modelId ?? undefined,
285
+ providerId: policy.providerId ?? undefined,
286
+ systemPrompt: prompt,
287
+ sessionId,
288
+ });
289
+
290
+ const effectiveModelId = policy.modelId || getModelsConfig().defaultModel;
291
+ let effectiveProviderId = policy.providerId;
292
+ if (!effectiveProviderId) {
293
+ effectiveProviderId = findProviderFromModel(effectiveModelId);
294
+ }
295
+
296
+ const stream = aiStreamText({
297
+ model,
298
+ prompt,
299
+ abortSignal,
300
+ maxOutputTokens: omitMaxOutputTokens ? undefined : policy.maxOutputTokens,
301
+ providerOptions: providerOptions as unknown as Parameters<typeof aiStreamText>[0]['providerOptions'],
302
+ });
303
+
304
+ let text: string;
305
+ try {
306
+ text = await stream.text;
307
+ } catch (err) {
308
+ console.error('[compaction] streamText failed:', err);
309
+ throw err;
310
+ }
311
+
312
+ const streamUsage = await stream.usage;
313
+ const streamFinishReason = await stream.finishReason;
314
+
315
+ if (streamFinishReason === 'length') {
316
+ console.warn('[compaction] Summary was truncated (hit maxOutputTokens limit). Some context may be lost.');
317
+ text += '\n\n[Note: Summary was truncated due to token limit. Some context may be incomplete.]';
318
+ }
319
+
320
+ return {
321
+ text,
322
+ usage: {
323
+ prompt: streamUsage.inputTokens ?? 0,
324
+ completion: streamUsage.outputTokens ?? 0,
325
+ cacheRead: streamUsage.inputTokenDetails.cacheReadTokens ?? 0,
326
+ cacheWrite: streamUsage.inputTokenDetails.cacheWriteTokens ?? 0,
327
+ noCache: streamUsage.inputTokenDetails.noCacheTokens ?? 0,
328
+ },
329
+ effectiveModelId,
330
+ effectiveProviderId,
331
+ };
332
+ }
333
+
334
+ /**
335
+ * Processes a compaction task from a trigger message.
336
+ * Creates an assistant message with the summary text.
337
+ */
338
+ export async function processCompactionTask(
339
+ sessionId: string,
340
+ triggerMessageId: string,
341
+ policy: CompactionPolicy,
342
+ generateSummaryFn?: GenerateSummaryFn,
343
+ abortSignal?: AbortSignal,
344
+ ): Promise<CompactionTaskResult> {
345
+ // Get the trigger message
346
+ const allMessages = await listMessagesWithParts(sessionId);
347
+ const triggerMsgWithParts = allMessages.find((m: MessageWithParts) => m.message.id === triggerMessageId);
348
+
349
+ if (!triggerMsgWithParts) {
350
+ throw new Error('Trigger message not found');
351
+ }
352
+
353
+ // Get the CompactionPart to determine reason
354
+ const triggerPart = triggerMsgWithParts.parts.find((p: MessageWithParts['parts'][number]) => p.type === 'compaction');
355
+ if (!triggerPart) {
356
+ throw new Error('Trigger message does not have a compaction part');
357
+ }
358
+
359
+ const compactionPart = triggerPart as CompactionPart;
360
+ const reason: CompactionTriggerReason = compactionPart.overflow
361
+ ? 'overflow'
362
+ : compactionPart.auto
363
+ ? 'auto'
364
+ : 'manual';
365
+
366
+ // Get the trigger message's boundary (all messages before the trigger)
367
+ // Find the index of the trigger in the full session history
368
+ const triggerIdx = allMessages.findIndex((m: MessageWithParts) => m.message.id === triggerMessageId);
369
+
370
+ // Check for a previous compaction summary BEFORE the trigger to avoid re-summarizing
371
+ // already-compactored content. When a previous summary exists, only compact messages
372
+ // AFTER the summary. This prevents re-sending the entire pre-compaction history to the
373
+ // LLM, which is especially important for forked sessions that inherit compaction artifacts.
374
+ let previousSummaryText: string | null = null;
375
+ let compactStartIdx = 0;
376
+
377
+ for (let i = triggerIdx - 1; i >= 0; i--) {
378
+ const m = allMessages[i];
379
+ if (
380
+ m.message.role === 'assistant' &&
381
+ (m.message as AssistantMessage).summary === true &&
382
+ (m.message as AssistantMessage).mode === 'compaction'
383
+ ) {
384
+ // Extract text from the summary
385
+ const textParts = m.parts.filter((p: MessageWithParts['parts'][number]) => p.type === 'text');
386
+ if (textParts.length > 0) {
387
+ previousSummaryText = textParts.map((p: MessageWithParts['parts'][number]) => (p as { text: string }).text).join('\n');
388
+ compactStartIdx = i + 1;
389
+ }
390
+ break;
391
+ }
392
+ }
393
+
394
+ const messagesToCompact = allMessages
395
+ .slice(compactStartIdx, triggerIdx)
396
+ .filter((m: MessageWithParts) => m.message.role !== 'system');
397
+
398
+ if (messagesToCompact.length === 0) {
399
+ throw new Error('No messages to compact');
400
+ }
401
+
402
+ // Validate: there must be at least one user message to serve as a meaningful boundary.
403
+ // This replaces the fragile hasNestedCompaction guard.
404
+ const hasUserMessage = messagesToCompact.some(
405
+ (m: MessageWithParts) => m.message.role === 'user',
406
+ );
407
+ if (!hasUserMessage) {
408
+ throw new Error('Compaction boundary must contain at least one user message');
409
+ }
410
+
411
+ // Build the prompt
412
+ const conversationText = buildConversationText(messagesToCompact);
413
+
414
+ const prompt = previousSummaryText
415
+ ? COMPACTION_PROMPT_INCREMENTAL
416
+ .replace('{PREVIOUS_SUMMARY}', previousSummaryText)
417
+ .replace('{CONVERSATION}', conversationText)
418
+ : COMPACTION_PROMPT_FIRST.replace('{CONVERSATION}', conversationText);
419
+
420
+ console.log('[compaction] modelId:', policy.modelId, 'providerId:', policy.providerId);
421
+
422
+ const generateSummary = generateSummaryFn ?? defaultGenerateSummary;
423
+ const { text: summary, usage, effectiveModelId, effectiveProviderId } = await generateSummary(
424
+ prompt,
425
+ policy,
426
+ sessionId,
427
+ abortSignal,
428
+ );
429
+
430
+ const now = Date.now();
431
+ const msgId = randomUUID();
432
+
433
+ // Create an assistant message with summary metadata
434
+ // Record the effective model/provider that was actually used for generation
435
+ const assistantMessage: AssistantMessage = {
436
+ id: msgId,
437
+ sessionId,
438
+ role: 'assistant',
439
+ status: 'completed',
440
+ modelId: effectiveModelId,
441
+ providerId: effectiveProviderId,
442
+ tokens: {
443
+ prompt: usage.prompt,
444
+ completion: usage.completion,
445
+ cacheRead: usage.cacheRead ?? 0,
446
+ cacheWrite: usage.cacheWrite ?? 0,
447
+ noCache: usage.noCache ?? 0,
448
+ },
449
+ cost: 0,
450
+ summary: true,
451
+ mode: 'compaction',
452
+ parentId: triggerMessageId,
453
+ createdAt: now,
454
+ completedAt: now,
455
+ };
456
+
457
+ await createMessage(assistantMessage);
458
+
459
+ // Create a text part with the summary content
460
+ const textPartId = randomUUID();
461
+ const textPart: TextPart = {
462
+ id: textPartId,
463
+ messageId: msgId,
464
+ createdAt: now,
465
+ type: 'text',
466
+ text: summary,
467
+ };
468
+ await createPart(textPart, sessionId);
469
+
470
+ // Mark tool results as compacted so they can be pruned in future context
471
+ // WS4: Now passes policy for budget-aware pruning
472
+ const compactedMessageIds = messagesToCompact.map((m: MessageWithParts) => m.message.id);
473
+ await markToolsAsCompacted(sessionId, compactedMessageIds, policy);
474
+
475
+ const trigger: CompactionTrigger = {
476
+ messageId: triggerMessageId,
477
+ reason,
478
+ };
479
+
480
+ return {
481
+ trigger,
482
+ summaryMessage: assistantMessage,
483
+ textParts: [textPart],
484
+ tokensUsed: usage,
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Persist a compaction failure as an append-only assistant message.
490
+ * Creates an assistant message with status='error', mode='compact_failed',
491
+ * parentId pointing to the trigger, and a text part with the error explanation.
492
+ * Broadcasts the failure via standard message/part events.
493
+ *
494
+ * NOTE: This should only be called AFTER a trigger has been created.
495
+ * If validation fails before trigger creation, do not call this function.
496
+ */
497
+ export async function persistCompactionFailure(
498
+ sessionId: string,
499
+ triggerMessageId: string,
500
+ errorMessage: string,
501
+ broadcast: BroadcastFn = emitRuntimeEvent,
502
+ ): Promise<void> {
503
+ const now = Date.now();
504
+ const msgId = randomUUID();
505
+
506
+ const assistantMessage: AssistantMessage = {
507
+ id: msgId,
508
+ sessionId,
509
+ role: 'assistant',
510
+ status: 'error',
511
+ modelId: '',
512
+ providerId: '',
513
+ tokens: {
514
+ prompt: 0,
515
+ completion: 0,
516
+ },
517
+ cost: 0,
518
+ mode: 'compact_failed',
519
+ parentId: triggerMessageId,
520
+ createdAt: now,
521
+ completedAt: now,
522
+ error: errorMessage,
523
+ };
524
+
525
+ await createMessage(assistantMessage);
526
+
527
+ const textPartId = randomUUID();
528
+ const textPart: TextPart = {
529
+ id: textPartId,
530
+ messageId: msgId,
531
+ createdAt: now,
532
+ type: 'text',
533
+ text: `Compaction failed: ${errorMessage}`,
534
+ };
535
+
536
+ await createPart(textPart, sessionId);
537
+
538
+ broadcast({ kind: 'message', action: 'created', message: assistantMessage });
539
+ broadcast({ kind: 'part', action: 'created', sessionId, part: textPart });
540
+ }
@@ -0,0 +1,58 @@
1
+ export interface ModelCapabilities {
2
+ input?: {
3
+ text?: boolean;
4
+ image?: boolean;
5
+ video?: boolean;
6
+ file?: string[];
7
+ };
8
+ structuredOutput?: { mode: 'native' | 'prompt' };
9
+ }
10
+
11
+ export interface ModelDefinition {
12
+ id: string;
13
+ name: string;
14
+ contextWindow: number;
15
+ maxOutputTokens?: number;
16
+ tier: 'budget' | 'standard' | 'premium';
17
+ variants?: Record<string, { providerOptions: Record<string, unknown> }>;
18
+ capabilities?: ModelCapabilities;
19
+ }
20
+
21
+ export interface ProviderDefinition {
22
+ id: string;
23
+ name: string;
24
+ models: ModelDefinition[];
25
+ }
26
+
27
+ export interface ModelWithProvider extends ModelDefinition {
28
+ providerId: string;
29
+ providerName: string;
30
+ }
31
+
32
+ export interface ModelsConfig {
33
+ providers: ProviderDefinition[];
34
+ defaultModel: string;
35
+ defaultProvider: string;
36
+ }
37
+
38
+ export interface RuntimeConfiguration {
39
+ findModel(modelId: string, providerId?: string): ModelWithProvider | undefined;
40
+ getMaxOutputTokens(modelId?: string): number;
41
+ findModelVariant(modelId: string, variantKey: string, providerId?: string): Record<string, unknown> | undefined;
42
+ getModelsConfig(): ModelsConfig;
43
+ getLLMTemperature(): number;
44
+ getLLMMaxSteps(): number;
45
+ getLLMSubagentMaxSteps(): number;
46
+ getLLMBaseUrl(): string | undefined;
47
+ getApiKey(providerId: string): string | undefined;
48
+ getCompactionModel(): string | undefined;
49
+ getCompactionProvider(): string | undefined;
50
+ getCompactionMaxTokens(): number;
51
+ getCompactionPreserveRecentToolCount(): number;
52
+ getCompactionPreserveSmallToolChars(): number;
53
+ getCompactionToolClearCharsThreshold(): number;
54
+ getCompactionMaxPrunedToolCount(): number;
55
+ getCompactionAutoThresholdRatio(): number;
56
+ getCompactionAutoReserveCapTokens(): number;
57
+ getCompactionAutoSafetyMarginTokens(): number;
58
+ }
@@ -0,0 +1,27 @@
1
+ import type { RuntimeConfiguration } from './contracts';
2
+
3
+ const OUTPUT_TOKEN_MAX = 32000;
4
+
5
+ export function createDefaultRuntimeConfiguration(): RuntimeConfiguration {
6
+ return {
7
+ findModel: () => undefined,
8
+ getMaxOutputTokens: () => OUTPUT_TOKEN_MAX,
9
+ findModelVariant: () => undefined,
10
+ getModelsConfig: () => ({ providers: [], defaultModel: '', defaultProvider: '' }),
11
+ getLLMTemperature: () => 0.7,
12
+ getLLMMaxSteps: () => 10,
13
+ getLLMSubagentMaxSteps: () => 50,
14
+ getLLMBaseUrl: () => undefined,
15
+ getApiKey: () => undefined,
16
+ getCompactionModel: () => undefined,
17
+ getCompactionProvider: () => undefined,
18
+ getCompactionMaxTokens: () => 8000,
19
+ getCompactionPreserveRecentToolCount: () => 3,
20
+ getCompactionPreserveSmallToolChars: () => 200,
21
+ getCompactionToolClearCharsThreshold: () => 1000,
22
+ getCompactionMaxPrunedToolCount: () => 50,
23
+ getCompactionAutoThresholdRatio: () => 0.75,
24
+ getCompactionAutoReserveCapTokens: () => 32000,
25
+ getCompactionAutoSafetyMarginTokens: () => 20000,
26
+ };
27
+ }
@@ -0,0 +1,42 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { RuntimeConfiguration } from './contracts';
3
+ import { createDefaultRuntimeConfiguration } from './defaults';
4
+
5
+ let configuration = createDefaultRuntimeConfiguration();
6
+ const scopedConfiguration = new AsyncLocalStorage<RuntimeConfiguration>();
7
+
8
+ function activeConfiguration(): RuntimeConfiguration {
9
+ return scopedConfiguration.getStore() ?? configuration;
10
+ }
11
+
12
+ export function withRuntimeConfiguration<T>(value: RuntimeConfiguration, callback: () => T): T {
13
+ return scopedConfiguration.run(value, callback);
14
+ }
15
+
16
+ export function configureRuntimeConfiguration(value?: RuntimeConfiguration): void {
17
+ configuration = value ?? createDefaultRuntimeConfiguration();
18
+ }
19
+
20
+ export function getRuntimeConfiguration(): RuntimeConfiguration {
21
+ return activeConfiguration();
22
+ }
23
+
24
+ export const findModel = (...args: Parameters<RuntimeConfiguration['findModel']>) => activeConfiguration().findModel(...args);
25
+ export const getMaxOutputTokens = (...args: Parameters<RuntimeConfiguration['getMaxOutputTokens']>) => activeConfiguration().getMaxOutputTokens(...args);
26
+ export const findModelVariant = (...args: Parameters<RuntimeConfiguration['findModelVariant']>) => activeConfiguration().findModelVariant(...args);
27
+ export const getModelsConfig = () => activeConfiguration().getModelsConfig();
28
+ export const getLLMTemperature = () => activeConfiguration().getLLMTemperature();
29
+ export const getLLMMaxSteps = () => activeConfiguration().getLLMMaxSteps();
30
+ export const getLLMSubagentMaxSteps = () => activeConfiguration().getLLMSubagentMaxSteps();
31
+ export const getLLMBaseUrl = () => activeConfiguration().getLLMBaseUrl();
32
+ export const getApiKeyForProvider = (providerId: string) => activeConfiguration().getApiKey(providerId);
33
+ export const getCompactionModel = () => activeConfiguration().getCompactionModel();
34
+ export const getCompactionProvider = () => activeConfiguration().getCompactionProvider();
35
+ export const getCompactionMaxTokens = () => activeConfiguration().getCompactionMaxTokens();
36
+ export const getCompactionPreserveRecentToolCount = () => activeConfiguration().getCompactionPreserveRecentToolCount();
37
+ export const getCompactionPreserveSmallToolChars = () => activeConfiguration().getCompactionPreserveSmallToolChars();
38
+ export const getCompactionToolClearCharsThreshold = () => activeConfiguration().getCompactionToolClearCharsThreshold();
39
+ export const getCompactionMaxPrunedToolCount = () => activeConfiguration().getCompactionMaxPrunedToolCount();
40
+ export const getCompactionAutoThresholdRatio = () => activeConfiguration().getCompactionAutoThresholdRatio();
41
+ export const getCompactionAutoReserveCapTokens = () => activeConfiguration().getCompactionAutoReserveCapTokens();
42
+ export const getCompactionAutoSafetyMarginTokens = () => activeConfiguration().getCompactionAutoSafetyMarginTokens();