clanka 0.3.0 → 0.4.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 (69) hide show
  1. package/dist/Acp.d.ts.map +1 -1
  2. package/dist/Acp.js +3 -0
  3. package/dist/Acp.js.map +1 -1
  4. package/dist/Acp.test.js +58 -0
  5. package/dist/Acp.test.js.map +1 -1
  6. package/dist/Agent.d.ts.map +1 -1
  7. package/dist/Agent.js +86 -8
  8. package/dist/Agent.js.map +1 -1
  9. package/dist/Agent.test.js +671 -0
  10. package/dist/Agent.test.js.map +1 -1
  11. package/dist/AgentExecutor.d.ts +5 -0
  12. package/dist/AgentExecutor.d.ts.map +1 -1
  13. package/dist/AgentExecutor.js +22 -0
  14. package/dist/AgentExecutor.js.map +1 -1
  15. package/dist/AgentOutput.d.ts +49 -4
  16. package/dist/AgentOutput.d.ts.map +1 -1
  17. package/dist/AgentOutput.js +45 -0
  18. package/dist/AgentOutput.js.map +1 -1
  19. package/dist/AgentSkills.d.ts +56 -0
  20. package/dist/AgentSkills.d.ts.map +1 -0
  21. package/dist/AgentSkills.js +126 -0
  22. package/dist/AgentSkills.js.map +1 -0
  23. package/dist/AgentSkills.test.d.ts +2 -0
  24. package/dist/AgentSkills.test.d.ts.map +1 -0
  25. package/dist/AgentSkills.test.js +356 -0
  26. package/dist/AgentSkills.test.js.map +1 -0
  27. package/dist/Codex.js +1 -1
  28. package/dist/Codex.js.map +1 -1
  29. package/dist/Compaction.d.ts +342 -0
  30. package/dist/Compaction.d.ts.map +1 -0
  31. package/dist/Compaction.js +566 -0
  32. package/dist/Compaction.js.map +1 -0
  33. package/dist/Compaction.test.d.ts +2 -0
  34. package/dist/Compaction.test.d.ts.map +1 -0
  35. package/dist/Compaction.test.js +576 -0
  36. package/dist/Compaction.test.js.map +1 -0
  37. package/dist/CompactionTransport.test.d.ts +2 -0
  38. package/dist/CompactionTransport.test.d.ts.map +1 -0
  39. package/dist/CompactionTransport.test.js +123 -0
  40. package/dist/CompactionTransport.test.js.map +1 -0
  41. package/dist/Copilot.d.ts.map +1 -1
  42. package/dist/Copilot.js +7 -2
  43. package/dist/Copilot.js.map +1 -1
  44. package/dist/OutputFormatter.d.ts.map +1 -1
  45. package/dist/OutputFormatter.js +9 -0
  46. package/dist/OutputFormatter.js.map +1 -1
  47. package/dist/cli.js +14 -5
  48. package/dist/cli.js.map +1 -1
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +5 -0
  52. package/dist/index.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/Acp.test.ts +85 -0
  55. package/src/Acp.ts +2 -0
  56. package/src/Agent.test.ts +994 -0
  57. package/src/Agent.ts +127 -6
  58. package/src/AgentExecutor.ts +27 -0
  59. package/src/AgentOutput.ts +58 -0
  60. package/src/AgentSkills.test.ts +652 -0
  61. package/src/AgentSkills.ts +155 -0
  62. package/src/Codex.ts +3 -1
  63. package/src/Compaction.test.ts +824 -0
  64. package/src/Compaction.ts +788 -0
  65. package/src/CompactionTransport.test.ts +228 -0
  66. package/src/Copilot.ts +8 -1
  67. package/src/OutputFormatter.ts +9 -0
  68. package/src/cli.ts +27 -6
  69. package/src/index.ts +6 -0
@@ -0,0 +1,788 @@
1
+ /**
2
+ * Context compaction for the shared Agent loop.
3
+ *
4
+ * Two independent mechanisms live here:
5
+ *
6
+ * 1. An always-on cap on `execute` results before they enter the Prompt
7
+ * (`capOutput`). It runs on every surface and is not affected by the
8
+ * compaction kill switch.
9
+ * 2. Auto-compaction of the live Prompt (`compactIfNeeded` before a model
10
+ * call, `compactAfterOverflow` after a context-length error). Both rewrite
11
+ * the Prompt to `system + <compaction-summary> user message + kept tail`.
12
+ *
13
+ * @since 1.0.0
14
+ */
15
+ import * as Context from "effect/Context"
16
+ import * as Effect from "effect/Effect"
17
+ import { identity } from "effect/Function"
18
+ import * as Layer from "effect/Layer"
19
+ import * as Option from "effect/Option"
20
+ import * as Predicate from "effect/Predicate"
21
+ import * as Stream from "effect/Stream"
22
+ import * as AiError from "effect/unstable/ai/AiError"
23
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
24
+ import * as Prompt from "effect/unstable/ai/Prompt"
25
+ import type * as AgentOutput from "./AgentOutput.ts"
26
+
27
+ // =============================================================================
28
+ // Configuration
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Runtime configuration for auto-compaction.
33
+ *
34
+ * - `enabled`: kill switch for both compact paths. Does **not** disable the
35
+ * `execute` output cap.
36
+ * - `contextWindow`: assumed model context window in tokens.
37
+ * - `reserveTokens`: headroom kept free below `contextWindow`. Compaction
38
+ * triggers once the last reported `contextTokens` (or the estimate) exceeds
39
+ * `contextWindow - reserveTokens`.
40
+ * - `keepRecentTokens`: size of the recent tail preserved verbatim after a
41
+ * compaction.
42
+ *
43
+ * @since 1.0.0
44
+ * @category Configuration
45
+ */
46
+ export interface CompactionConfigService {
47
+ readonly enabled: boolean
48
+ readonly contextWindow: number
49
+ readonly reserveTokens: number
50
+ readonly keepRecentTokens: number
51
+ }
52
+
53
+ /**
54
+ * @since 1.0.0
55
+ * @category Configuration
56
+ */
57
+ export const defaultConfig: CompactionConfigService = {
58
+ enabled: true,
59
+ contextWindow: 236_000,
60
+ reserveTokens: 16_000,
61
+ keepRecentTokens: 20_000,
62
+ }
63
+
64
+ /**
65
+ * @since 1.0.0
66
+ * @category Configuration
67
+ */
68
+ export class CompactionConfig extends Context.Reference<CompactionConfigService>(
69
+ "clanka/Compaction/CompactionConfig",
70
+ { defaultValue: () => defaultConfig },
71
+ ) {
72
+ static readonly layer = (
73
+ options: Partial<CompactionConfigService>,
74
+ ): Layer.Layer<never> =>
75
+ Layer.succeed(CompactionConfig, { ...defaultConfig, ...options })
76
+ }
77
+
78
+ /**
79
+ * Wraps the summarizer model call so a provider can apply request overrides
80
+ * (e.g. Copilot's `max_output_tokens`). Defaults to the identity.
81
+ *
82
+ * @since 1.0.0
83
+ * @category Configuration
84
+ */
85
+ export class SummarizerTransform extends Context.Reference<
86
+ <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
87
+ >("clanka/Compaction/SummarizerTransform", {
88
+ defaultValue: () => identity,
89
+ }) {}
90
+
91
+ // =============================================================================
92
+ // Constants
93
+ // =============================================================================
94
+
95
+ /**
96
+ * Hard cap, in characters, applied to every `execute` success string before it
97
+ * is appended to the Prompt. Always on.
98
+ *
99
+ * @since 1.0.0
100
+ * @category Constants
101
+ */
102
+ export const executeOutputCapChars = 32_000
103
+
104
+ /** Cap applied to `execute` results rendered into the summarizer input. */
105
+ const summarizerToolResultCapChars = 2_000
106
+
107
+ /**
108
+ * Cap applied to `execute` results that remain in the kept tail when the tail
109
+ * itself still exceeds `keepRecentTokens` after reasoning has been dropped.
110
+ *
111
+ * @since 1.0.0
112
+ * @category Constants
113
+ */
114
+ export const keptToolResultStubChars = 2_000
115
+
116
+ /**
117
+ * Maximum output tokens requested by providers that support a summary cap.
118
+ * Copilot uses this limit; Codex rejects max_output_tokens and has no
119
+ * configured summary output-token cap.
120
+ *
121
+ * @since 1.0.0
122
+ * @category Constants
123
+ */
124
+ export const summarizerMaxOutputTokens = 4_000
125
+
126
+ /**
127
+ * The synthetic summary user message is wrapped in these tags so a later
128
+ * compaction can locate it without a new Prompt schema.
129
+ *
130
+ * @since 1.0.0
131
+ * @category Constants
132
+ */
133
+ export const summaryOpenTag = "<compaction-summary>"
134
+
135
+ /**
136
+ * @since 1.0.0
137
+ * @category Constants
138
+ */
139
+ export const summaryCloseTag = "</compaction-summary>"
140
+
141
+ /**
142
+ * System prompt included in the summarizer's Prompt.
143
+ *
144
+ * @since 1.0.0
145
+ * @category Constants
146
+ */
147
+ export const summarizerSystem = `You compact the conversation history of a coding agent so the agent can continue with less context.
148
+ Reply with the summary only. No preamble, no commentary, no markdown fences.`
149
+
150
+ // =============================================================================
151
+ // Execute output cap
152
+ // =============================================================================
153
+
154
+ /**
155
+ * @since 1.0.0
156
+ * @category Cap
157
+ */
158
+ export interface CapResult {
159
+ /**
160
+ * The (possibly capped) output. When capped this is `head + marker + tail`,
161
+ * where the marker tells the model how many characters were removed and
162
+ * to narrow the read (`readFile` `startLine`/`endLine`, smaller logs).
163
+ */
164
+ readonly output: string
165
+ readonly capped: boolean
166
+ readonly charsBefore: number
167
+ readonly charsAfter: number
168
+ }
169
+
170
+ /**
171
+ * Cap a string to `maxChars` characters, keeping the head and the tail and
172
+ * inserting a marker in between. Inputs at or under the cap are returned
173
+ * unchanged.
174
+ *
175
+ * @since 1.0.0
176
+ * @category Cap
177
+ */
178
+ export const capOutput = (
179
+ output: string,
180
+ maxChars: number = executeOutputCapChars,
181
+ ): CapResult => {
182
+ const charsBefore = output.length
183
+ if (charsBefore <= maxChars) {
184
+ return { output, capped: false, charsBefore, charsAfter: charsBefore }
185
+ }
186
+ const headChars = Math.floor(maxChars / 2)
187
+ const tailChars = maxChars - headChars
188
+ const omitted = charsBefore - maxChars
189
+ const marker =
190
+ `\n\n[... output capped: ${omitted} of ${charsBefore} chars omitted from the middle. ` +
191
+ `Do not re-run this as is. Narrow the read instead: use readFile with startLine/endLine, ` +
192
+ `search for what you need, or print smaller logs ...]\n\n`
193
+ const capped =
194
+ output.slice(0, headChars) + marker + output.slice(charsBefore - tailChars)
195
+ return {
196
+ output: capped,
197
+ capped: true,
198
+ charsBefore,
199
+ charsAfter: capped.length,
200
+ }
201
+ }
202
+
203
+ // =============================================================================
204
+ // Token accounting
205
+ // =============================================================================
206
+
207
+ /**
208
+ * Cheap token estimate for a Prompt: JSON length / 4. Used when no
209
+ * `contextTokens` usage has been observed yet (first turn, ACP session load)
210
+ * and for sizing the kept tail.
211
+ *
212
+ * @since 1.0.0
213
+ * @category Tokens
214
+ */
215
+ export const estimateTokens = (prompt: Prompt.Prompt): number =>
216
+ Math.ceil(JSON.stringify(prompt.content).length / 4)
217
+
218
+ /**
219
+ * Token estimate for a single message. Same measure as `estimateTokens`.
220
+ *
221
+ * @since 1.0.0
222
+ * @category Tokens
223
+ */
224
+ export const estimateMessageTokens = (message: Prompt.Message): number =>
225
+ Math.ceil(JSON.stringify(message).length / 4)
226
+
227
+ /**
228
+ * Whether the threshold trigger fires for the next model call.
229
+ *
230
+ * `contextTokens` is the `inputTokens.total` from the most recent `finish`
231
+ * part, or `undefined` when no usage has been observed yet, in which case
232
+ * `estimateTokens(prompt)` is used instead.
233
+ *
234
+ * Always `false` when `config.enabled` is `false`.
235
+ *
236
+ * @since 1.0.0
237
+ * @category Tokens
238
+ */
239
+ export const shouldCompact = (options: {
240
+ readonly prompt: Prompt.Prompt
241
+ readonly contextTokens: number | undefined
242
+ readonly config: CompactionConfigService
243
+ }): boolean => {
244
+ if (!options.config.enabled) return false
245
+ const tokens = options.contextTokens ?? estimateTokens(options.prompt)
246
+ return tokens > options.config.contextWindow - options.config.reserveTokens
247
+ }
248
+
249
+ // =============================================================================
250
+ // Overflow detection
251
+ // =============================================================================
252
+
253
+ const contextLengthPattern =
254
+ /context[_ ]?(length|window)|too many tokens|prompt is too long|prompt token count|exceeds the (context|token|input )?limit|input token limit|exceeds the model'?s? (context|token|input)/i
255
+
256
+ const contextLengthCode = "context_length_exceeded"
257
+
258
+ /**
259
+ * Provider error code from `reason.metadata`, either at the top level or
260
+ * under a provider key (`metadata.openai.errorCode`).
261
+ */
262
+ const errorCode = (metadata: unknown): string | undefined => {
263
+ if (!Predicate.isObject(metadata)) return undefined
264
+ if (Predicate.isString(metadata.errorCode)) return metadata.errorCode
265
+ for (const value of Object.values(metadata)) {
266
+ if (Predicate.isObject(value) && Predicate.isString(value.errorCode)) {
267
+ return value.errorCode
268
+ }
269
+ }
270
+ return undefined
271
+ }
272
+
273
+ /**
274
+ * Whether an `AiError` is a context-length overflow from a known provider
275
+ * (Codex / Copilot), as opposed to any other request or transport failure.
276
+ *
277
+ * Any request, unknown or provider error counts when it carries a 413 status,
278
+ * a `context_length_exceeded` code, or a context-length description. These
279
+ * errors are never retried as-is; the Agent compacts first.
280
+ *
281
+ * @since 1.0.0
282
+ * @category Overflow
283
+ */
284
+ export const isContextLengthError = (error: AiError.AiError): boolean => {
285
+ const reason = error.reason
286
+ switch (reason._tag) {
287
+ case "InvalidRequestError":
288
+ case "UnknownError":
289
+ case "InternalProviderError":
290
+ return (
291
+ reason.http?.response?.status === 413 ||
292
+ errorCode(reason.metadata) === contextLengthCode ||
293
+ (reason.description !== undefined &&
294
+ contextLengthPattern.test(reason.description))
295
+ )
296
+ default:
297
+ return false
298
+ }
299
+ }
300
+
301
+ // =============================================================================
302
+ // Cut points
303
+ // =============================================================================
304
+
305
+ /**
306
+ * The result of choosing a cut point in a Prompt.
307
+ *
308
+ * - `system`: the system message, passed through unchanged.
309
+ * - `previousSummary`: the text inside an existing `<compaction-summary>`
310
+ * message, if the prompt was compacted before. That message is excluded from
311
+ * `toSummarize`.
312
+ * - `toSummarize`: the messages older than the cut point.
313
+ * - `kept`: the messages at and after the cut point, preserved verbatim
314
+ * (subject to `trimKept`).
315
+ *
316
+ * @since 1.0.0
317
+ * @category Cut points
318
+ */
319
+ export interface Split {
320
+ readonly system: Option.Option<Prompt.SystemMessage>
321
+ readonly previousSummary: Option.Option<string>
322
+ readonly toSummarize: ReadonlyArray<Prompt.Message>
323
+ readonly kept: ReadonlyArray<Prompt.Message>
324
+ }
325
+
326
+ const summaryText = (message: Prompt.Message): Option.Option<string> => {
327
+ if (message.role !== "user" || message.content.length !== 1) {
328
+ return Option.none()
329
+ }
330
+ const part = message.content[0]!
331
+ if (part.type !== "text") return Option.none()
332
+ const text = part.text.trim()
333
+ if (!text.startsWith(summaryOpenTag) || !text.endsWith(summaryCloseTag)) {
334
+ return Option.none()
335
+ }
336
+ return Option.some(
337
+ text
338
+ .slice(summaryOpenTag.length, text.length - summaryCloseTag.length)
339
+ .trim(),
340
+ )
341
+ }
342
+
343
+ /**
344
+ * Whether a message is the synthetic `<compaction-summary>` user message
345
+ * produced by `rewrite`. Surfaces that replay history (ACP) skip it.
346
+ *
347
+ * @since 1.0.0
348
+ * @category Cut points
349
+ */
350
+ export const isSummaryMessage = (message: Prompt.Message): boolean =>
351
+ Option.isSome(summaryText(message))
352
+
353
+ /**
354
+ * Peel the system message and a previous summary off a prompt, returning the
355
+ * remaining conversation messages.
356
+ */
357
+ const peel = (prompt: Prompt.Prompt) => {
358
+ let system = Option.none<Prompt.SystemMessage>()
359
+ let previousSummary = Option.none<string>()
360
+ const messages: Array<Prompt.Message> = []
361
+ for (const message of prompt.content) {
362
+ if (message.role === "system") {
363
+ if (Option.isNone(system)) system = Option.some(message)
364
+ continue
365
+ }
366
+ if (messages.length === 0 && Option.isNone(previousSummary)) {
367
+ const summary = summaryText(message)
368
+ if (Option.isSome(summary)) {
369
+ previousSummary = summary
370
+ continue
371
+ }
372
+ }
373
+ messages.push(message)
374
+ }
375
+ return { system, previousSummary, messages }
376
+ }
377
+
378
+ /**
379
+ * Choose the cut point for a compaction.
380
+ *
381
+ * Walks back from the newest message, accumulating `estimateMessageTokens`,
382
+ * and stops at the first message boundary where the accumulated tail would
383
+ * exceed `keepRecentTokens`. The cut is then adjusted so that an `execute`
384
+ * tool-call (assistant message) and its tool-result (tool message) are never
385
+ * separated: if the boundary falls between them the assistant message is kept
386
+ * as well. The kept tail always contains at least the newest complete unit.
387
+ *
388
+ * Returns `None` when there is nothing to summarize, which happens when the
389
+ * kept tail is the whole prompt (ignoring the system message and a previous
390
+ * summary message). Callers treat `None` as a no-op.
391
+ *
392
+ * @since 1.0.0
393
+ * @category Cut points
394
+ */
395
+ export const split = (
396
+ prompt: Prompt.Prompt,
397
+ keepRecentTokens: number,
398
+ ): Option.Option<Split> => {
399
+ const { system, previousSummary, messages } = peel(prompt)
400
+ if (messages.length === 0) return Option.none()
401
+
402
+ let cut = messages.length
403
+ let tokens = 0
404
+ while (cut > 0) {
405
+ const messageTokens = estimateMessageTokens(messages[cut - 1]!)
406
+ // Always keep the newest message, even when it alone exceeds the budget.
407
+ if (cut < messages.length && tokens + messageTokens > keepRecentTokens) {
408
+ break
409
+ }
410
+ tokens += messageTokens
411
+ cut--
412
+ }
413
+ // Never leave a tool result without its call at the head of the tail.
414
+ while (cut > 0 && messages[cut]!.role === "tool") {
415
+ cut--
416
+ }
417
+ if (cut === 0) return Option.none()
418
+
419
+ return Option.some({
420
+ system,
421
+ previousSummary,
422
+ toSummarize: messages.slice(0, cut),
423
+ kept: messages.slice(cut),
424
+ })
425
+ }
426
+
427
+ /**
428
+ * Find the text of a previous compaction summary in a Prompt, if present.
429
+ *
430
+ * @since 1.0.0
431
+ * @category Cut points
432
+ */
433
+ export const findPreviousSummary = (
434
+ prompt: Prompt.Prompt,
435
+ ): Option.Option<string> => peel(prompt).previousSummary
436
+
437
+ /**
438
+ * Shrink a kept tail that still exceeds `keepRecentTokens`:
439
+ *
440
+ * 1. Drop `reasoning` parts from assistant messages, oldest first, until the
441
+ * tail fits.
442
+ * 2. If it still does not fit, stub `execute` tool-result strings in place,
443
+ * oldest first, using `capOutput(result, keptToolResultStubChars)`.
444
+ *
445
+ * Message order, message count and tool-call / tool-result ids are never
446
+ * changed, so call/result pairing stays valid. Returns the input unchanged
447
+ * when it already fits.
448
+ *
449
+ * @since 1.0.0
450
+ * @category Cut points
451
+ */
452
+ export const trimKept = (
453
+ kept: ReadonlyArray<Prompt.Message>,
454
+ keepRecentTokens: number,
455
+ ): ReadonlyArray<Prompt.Message> => {
456
+ const sizes = kept.map(estimateMessageTokens)
457
+ let total = sizes.reduce((n, size) => n + size, 0)
458
+ if (total <= keepRecentTokens) return kept
459
+
460
+ const result = kept.slice()
461
+ const replace = (index: number, message: Prompt.Message) => {
462
+ result[index] = message
463
+ const size = estimateMessageTokens(message)
464
+ total += size - sizes[index]!
465
+ sizes[index] = size
466
+ }
467
+
468
+ for (let i = 0; i < result.length; i++) {
469
+ if (total <= keepRecentTokens) break
470
+ const message = result[i]!
471
+ if (
472
+ message.role !== "assistant" ||
473
+ !message.content.some((part) => part.type === "reasoning")
474
+ ) {
475
+ continue
476
+ }
477
+ replace(
478
+ i,
479
+ Prompt.makeMessage("assistant", {
480
+ content: message.content.filter((part) => part.type !== "reasoning"),
481
+ options: message.options,
482
+ }),
483
+ )
484
+ }
485
+
486
+ for (let i = 0; i < result.length; i++) {
487
+ if (total <= keepRecentTokens) break
488
+ const message = result[i]!
489
+ if (message.role !== "tool") continue
490
+ let changed = false
491
+ const content = message.content.map((part) => {
492
+ if (
493
+ part.type !== "tool-result" ||
494
+ typeof part.result !== "string" ||
495
+ part.result.length <= keptToolResultStubChars
496
+ ) {
497
+ return part
498
+ }
499
+ changed = true
500
+ return Prompt.makePart("tool-result", {
501
+ id: part.id,
502
+ name: part.name,
503
+ isFailure: part.isFailure,
504
+ providerExecuted: part.providerExecuted,
505
+ result: capOutput(part.result, keptToolResultStubChars).output,
506
+ options: part.options,
507
+ })
508
+ })
509
+ if (changed) {
510
+ replace(
511
+ i,
512
+ Prompt.makeMessage("tool", { content, options: message.options }),
513
+ )
514
+ }
515
+ }
516
+
517
+ return result
518
+ }
519
+
520
+ // =============================================================================
521
+ // Prompt rewrite
522
+ // =============================================================================
523
+
524
+ const renderResult = (result: unknown): string => {
525
+ const text = typeof result === "string" ? result : JSON.stringify(result)
526
+ return capOutput(text ?? "", summarizerToolResultCapChars).output
527
+ }
528
+
529
+ const renderMessage = (message: Prompt.Message): string => {
530
+ switch (message.role) {
531
+ case "system":
532
+ return ""
533
+ case "user": {
534
+ const text = message.content
535
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
536
+ .join("\n")
537
+ return `[user]\n${text}`
538
+ }
539
+ case "assistant": {
540
+ const lines: Array<string> = []
541
+ for (const part of message.content) {
542
+ switch (part.type) {
543
+ case "text":
544
+ lines.push(part.text)
545
+ break
546
+ case "tool-call": {
547
+ const params = part.params as { readonly script?: unknown }
548
+ const script =
549
+ typeof params?.script === "string"
550
+ ? params.script
551
+ : JSON.stringify(part.params)
552
+ lines.push(`[executed script]\n${script}`)
553
+ break
554
+ }
555
+ default:
556
+ break
557
+ }
558
+ }
559
+ return `[assistant]\n${lines.join("\n")}`
560
+ }
561
+ case "tool": {
562
+ const lines = message.content.flatMap((part) =>
563
+ part.type === "tool-result"
564
+ ? [
565
+ `[script output${part.isFailure ? " (error)" : ""}]\n${renderResult(part.result)}`,
566
+ ]
567
+ : [],
568
+ )
569
+ return lines.join("\n")
570
+ }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Build the Prompt sent to the summarizer model call.
576
+ *
577
+ * The conversation is rendered as text using the OpenCode-style summary
578
+ * template. `reasoning` parts are omitted, `execute` tool results are capped
579
+ * to `summarizerToolResultCapChars`, and `previousSummary` is folded in so the
580
+ * new summary supersedes it. No tools, no system prompt from the agent.
581
+ *
582
+ * @since 1.0.0
583
+ * @category Rewrite
584
+ */
585
+ export const summarizerPrompt = (options: {
586
+ readonly previousSummary: Option.Option<string>
587
+ readonly messages: ReadonlyArray<Prompt.Message>
588
+ }): Prompt.Prompt => {
589
+ const conversation = options.messages
590
+ .map(renderMessage)
591
+ .filter((text) => text.length > 0)
592
+ .join("\n\n")
593
+
594
+ const previous = Option.match(options.previousSummary, {
595
+ onNone: () => "",
596
+ onSome: (
597
+ summary,
598
+ ) => `A previous summary already covers the conversation before the messages below. Fold it into the new summary so nothing is lost:
599
+
600
+ <previous-summary>
601
+ ${summary}
602
+ </previous-summary>
603
+
604
+ `,
605
+ })
606
+
607
+ const text = `Summarize the conversation below so the coding agent can continue the task without the original messages.
608
+
609
+ Write the summary as a compact briefing. Include:
610
+ - The user's goals, and any constraints or preferences they stated
611
+ - What has been done so far: concrete file paths, commands, and their results
612
+ - Key findings and decisions, with the reasons behind them
613
+ - Errors encountered and how they were resolved
614
+ - What remains to be done, in order
615
+
616
+ Be precise. Prefer exact identifiers (paths, symbols, commands) over prose. Do not include commentary about the summary itself.
617
+
618
+ ${previous}<conversation>
619
+ ${conversation}
620
+ </conversation>`
621
+
622
+ return Prompt.make(text).pipe(Prompt.setSystem(summarizerSystem))
623
+ }
624
+
625
+ /**
626
+ * Wrap a summary in the `<compaction-summary>` tags used for the synthetic
627
+ * user message.
628
+ *
629
+ * @since 1.0.0
630
+ * @category Rewrite
631
+ */
632
+ export const wrapSummary = (summary: string): string =>
633
+ `${summaryOpenTag}\n${summary}\n${summaryCloseTag}`
634
+
635
+ /**
636
+ * Assemble the compacted Prompt: `[system?, user(wrapSummary(summary)),
637
+ * ...kept]`. The system message is passed through untouched.
638
+ *
639
+ * @since 1.0.0
640
+ * @category Rewrite
641
+ */
642
+ export const rewrite = (options: {
643
+ readonly system: Option.Option<Prompt.SystemMessage>
644
+ readonly summary: string
645
+ readonly kept: ReadonlyArray<Prompt.Message>
646
+ }): Prompt.Prompt =>
647
+ Prompt.fromMessages([
648
+ ...Option.toArray(options.system),
649
+ Prompt.makeMessage("user", {
650
+ content: [
651
+ Prompt.makePart("text", { text: wrapSummary(options.summary) }),
652
+ ],
653
+ }),
654
+ ...options.kept,
655
+ ])
656
+
657
+ // =============================================================================
658
+ // Compaction
659
+ // =============================================================================
660
+
661
+ /**
662
+ * @since 1.0.0
663
+ * @category Compaction
664
+ */
665
+ export type CompactionReason = typeof AgentOutput.CompactionReason.Type
666
+
667
+ /**
668
+ * @since 1.0.0
669
+ * @category Compaction
670
+ */
671
+ export interface CompactionResult {
672
+ readonly reason: CompactionReason
673
+ readonly prompt: Prompt.Prompt
674
+ /**
675
+ * `estimateTokens` of the prompt before and after the rewrite.
676
+ */
677
+ readonly tokensBefore: number
678
+ readonly tokensAfter: number
679
+ }
680
+
681
+ /**
682
+ * Run one compaction of `prompt`.
683
+ *
684
+ * Returns `None` without calling the model when compaction is disabled or
685
+ * when `split` finds nothing to summarize. Otherwise `onStart` runs, the
686
+ * summarizer is called with `streamText` (the Codex backend rejects
687
+ * non-streaming requests) through `SummarizerTransform`, and the prompt is
688
+ * rewritten to `system + summary + trimKept(kept)`.
689
+ *
690
+ * Fails with the summarizer's `AiError`. Callers decide whether that is fatal.
691
+ *
692
+ * @since 1.0.0
693
+ * @category Compaction
694
+ */
695
+ export const compact: (options: {
696
+ readonly prompt: Prompt.Prompt
697
+ readonly reason: CompactionReason
698
+ /**
699
+ * Runs once a cut point was found, before the summarizer call. Not invoked
700
+ * for no-op compactions.
701
+ */
702
+ readonly onStart?:
703
+ ((reason: CompactionReason) => Effect.Effect<void>) | undefined
704
+ }) => Effect.Effect<
705
+ Option.Option<CompactionResult>,
706
+ AiError.AiError,
707
+ LanguageModel.LanguageModel
708
+ > = Effect.fnUntraced(function* (options) {
709
+ const config = yield* CompactionConfig
710
+ if (!config.enabled) return Option.none()
711
+ const parts = split(options.prompt, config.keepRecentTokens)
712
+ if (Option.isNone(parts)) return Option.none()
713
+ const { system, previousSummary, toSummarize, kept } = parts.value
714
+
715
+ if (options.onStart) {
716
+ yield* options.onStart(options.reason)
717
+ }
718
+
719
+ const ai = yield* LanguageModel.LanguageModel
720
+ const transform = yield* SummarizerTransform
721
+ const summary = (yield* transform(
722
+ ai
723
+ .streamText({
724
+ prompt: summarizerPrompt({ previousSummary, messages: toSummarize }),
725
+ })
726
+ .pipe(
727
+ Stream.runFold(
728
+ () => "",
729
+ (text, part) =>
730
+ part.type === "text-delta" ? text + part.delta : text,
731
+ ),
732
+ ),
733
+ )).trim()
734
+
735
+ if (summary.length === 0) {
736
+ return yield* AiError.make({
737
+ module: "clanka/Compaction",
738
+ method: "compact",
739
+ reason: new AiError.InvalidOutputError({
740
+ description: "The summarizer returned no text",
741
+ }),
742
+ })
743
+ }
744
+
745
+ const rewritten = rewrite({
746
+ system,
747
+ summary,
748
+ kept: trimKept(kept, config.keepRecentTokens),
749
+ })
750
+ return Option.some({
751
+ reason: options.reason,
752
+ prompt: rewritten,
753
+ tokensBefore: estimateTokens(options.prompt),
754
+ tokensAfter: estimateTokens(rewritten),
755
+ })
756
+ })
757
+
758
+ /**
759
+ * Compact before the next model call when `shouldCompact` fires.
760
+ *
761
+ * Returns `None` when compaction is disabled, not needed, or a no-op. A
762
+ * failing summarizer is swallowed here: the caller proceeds with the
763
+ * uncompacted prompt and relies on the overflow path.
764
+ *
765
+ * @since 1.0.0
766
+ * @category Compaction
767
+ */
768
+ export const compactIfNeeded: (options: {
769
+ readonly prompt: Prompt.Prompt
770
+ readonly contextTokens: number | undefined
771
+ readonly onStart?:
772
+ ((reason: CompactionReason) => Effect.Effect<void>) | undefined
773
+ }) => Effect.Effect<
774
+ Option.Option<CompactionResult>,
775
+ never,
776
+ LanguageModel.LanguageModel
777
+ > = Effect.fnUntraced(function* (options) {
778
+ const config = yield* CompactionConfig
779
+ if (!shouldCompact({ ...options, config })) return Option.none()
780
+ return yield* compact({ ...options, reason: "threshold" }).pipe(
781
+ Effect.catch((error) =>
782
+ Effect.logWarning(
783
+ "Compaction failed, continuing with the uncompacted prompt",
784
+ error,
785
+ ).pipe(Effect.as(Option.none<CompactionResult>())),
786
+ ),
787
+ )
788
+ })