@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -0,0 +1,539 @@
1
+ // Compaction: how a conversation keeps its beginning instead of forgetting it.
2
+ //
3
+ // ADR 0027 bounded one model request and evicts whole Turns oldest-first when
4
+ // it overflows. ADR 0030 puts two tiers in front of that eviction, and this
5
+ // module is both of them plus the decision of when to reach for either:
6
+ //
7
+ // 1. **Prune old tool outputs.** A deterministic assembly rule, no durable
8
+ // write and no model call. A `tool/result` message older than the newest
9
+ // few Turns keeps its `callId` and `name` and loses only its payload, so
10
+ // the call/result pairing every provider validates survives while the
11
+ // bytes that actually dominate a long conversation do not.
12
+ // 2. **Summarise the oldest Turns.** One `conversation/compacted` event on
13
+ // the durable log, computed once and replayed thereafter. It is *not*
14
+ // recomputed per request: that was the mistake the design this copies was
15
+ // reverted for, and an event log is exactly the substrate that fixes it.
16
+ //
17
+ // Everything here is a pure function over the session log. The Turn-end hook
18
+ // in `agent.ts` is what runs the summariser and appends the events; this
19
+ // module decides what it should do and how the result is read back.
20
+ import {
21
+ COMPACTION_FAILURE_REASON_MAX_LENGTH,
22
+ COMPACTION_IDENTIFIERS_MAX,
23
+ COMPACTION_IDENTIFIER_MAX_LENGTH,
24
+ COMPACTION_SUMMARY_MAX_LENGTH,
25
+ type LlmMessage,
26
+ type ModelBindingSnapshot,
27
+ type Session,
28
+ type SessionEvent,
29
+ } from "@frockbot/kernel-contracts";
30
+
31
+ /**
32
+ * The share of the history budget above which a compaction is due.
33
+ *
34
+ * Below 1 on purpose: compaction exists to stop whole-Turn eviction being
35
+ * reached, so it has to land before the budget is actually spent. Evaluated at
36
+ * Turn end and never before a model call, so this threshold costs a person no
37
+ * latency — the Turn they were waiting on is already over when it is read.
38
+ */
39
+ export const COMPACTION_TRIGGER_RATIO_V1 = 0.7;
40
+
41
+ /**
42
+ * Turns never covered by a compaction. The conversation the User is actually
43
+ * having stays verbatim; only its history is compressed.
44
+ */
45
+ export const COMPACTION_KEEP_RECENT_TURNS_V1 = 4;
46
+
47
+ /** Turns whose tool results keep their payload. Fewer than are kept verbatim. */
48
+ export const TOOL_OUTPUT_KEEP_RECENT_TURNS_V1 = 3;
49
+
50
+ /** What stands in for an elided tool payload. */
51
+ export const PRUNED_TOOL_RESULT_V1 = "[pruned]";
52
+
53
+ /** Longest a pruned tool result may be before it is worth pruning at all. */
54
+ export const PRUNE_MIN_RESULT_CHARS_V1 = 200;
55
+
56
+ /** Time one summariser call is allowed. */
57
+ export const COMPACTION_DEADLINE_MS_V1 = 60_000;
58
+
59
+ /** Most Turn ends a retry ever waits after a failure. */
60
+ export const COMPACTION_MAX_BACKOFF_TURNS_V1 = 8;
61
+
62
+ /** The line a person sees in the transcript where a compaction stands. */
63
+ export const COMPACTED_ANNOUNCEMENT_TEXT_V1 =
64
+ "Earlier messages were summarised";
65
+
66
+ /** One completed compaction, read back off the log. */
67
+ export interface CompactionV1 {
68
+ effectId: string;
69
+ fromTurn: number;
70
+ throughTurn: number;
71
+ summary: string;
72
+ identifiers: readonly string[];
73
+ provider: string;
74
+ model: string;
75
+ }
76
+
77
+ /**
78
+ * What the log says about compaction for this conversation.
79
+ *
80
+ * Everything the Turn-end hook needs to decide, derived rather than stored:
81
+ * the newest completed compaction, an intent a restart left unsettled, and the
82
+ * consecutive failures that space the next attempt.
83
+ */
84
+ export interface CompactionStateV1 {
85
+ compaction?: CompactionV1;
86
+ /** An intent with neither outcome. A restart interrupted it. */
87
+ unsettled?: { effectId: string; throughTurn: number };
88
+ /** Consecutive failures since the last completed compaction. */
89
+ failures: number;
90
+ /** The Turn the newest failure covered, for backoff. */
91
+ lastFailureThroughTurn: number;
92
+ }
93
+
94
+ export function compactionStateV1(
95
+ events: readonly SessionEvent[],
96
+ ): CompactionStateV1 {
97
+ let compaction: CompactionV1 | undefined;
98
+ let unsettled: { effectId: string; throughTurn: number } | undefined;
99
+ let failures = 0;
100
+ let lastFailureThroughTurn = 0;
101
+ for (const event of events) {
102
+ if (event.type === "conversation/compaction-intent") {
103
+ unsettled = { effectId: event.effectId, throughTurn: event.throughTurn };
104
+ continue;
105
+ }
106
+ if (event.type === "conversation/compacted") {
107
+ if (unsettled?.effectId === event.effectId) unsettled = undefined;
108
+ // A prefix supersedes every shorter prefix, so the newest wins outright.
109
+ if (!compaction || event.throughTurn >= compaction.throughTurn) {
110
+ compaction = {
111
+ effectId: event.effectId,
112
+ fromTurn: event.fromTurn,
113
+ throughTurn: event.throughTurn,
114
+ summary: event.summary,
115
+ identifiers: event.identifiers,
116
+ provider: event.provider,
117
+ model: event.model,
118
+ };
119
+ }
120
+ failures = 0;
121
+ lastFailureThroughTurn = 0;
122
+ continue;
123
+ }
124
+ if (event.type === "conversation/compaction-failed") {
125
+ if (unsettled?.effectId === event.effectId) unsettled = undefined;
126
+ failures += 1;
127
+ lastFailureThroughTurn = event.throughTurn;
128
+ }
129
+ }
130
+ return {
131
+ ...(compaction ? { compaction } : {}),
132
+ ...(unsettled ? { unsettled } : {}),
133
+ failures,
134
+ lastFailureThroughTurn,
135
+ };
136
+ }
137
+
138
+ /** The message a compaction contributes, first in the assembled window. */
139
+ export function compactionMessageV1(compaction: CompactionV1): LlmMessage {
140
+ const identifiers =
141
+ compaction.identifiers.length > 0
142
+ ? `\n\nIdentifiers that appeared in those Turns, exactly as written: ${compaction.identifiers.join(", ")}`
143
+ : "";
144
+ return {
145
+ role: "user",
146
+ content: [
147
+ `Turns ${compaction.fromTurn} to ${compaction.throughTurn} of this conversation are not included verbatim. This is their summary, and it is the only record of them in this request. Treat it as history you remember, not as something the user just said.`,
148
+ "",
149
+ compaction.summary,
150
+ identifiers,
151
+ ]
152
+ .join("\n")
153
+ .trimEnd(),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Replaces the payload of tool results older than the newest `keepTurns`.
159
+ *
160
+ * The message survives with its `callId` and `name`, because a tool result
161
+ * whose call has been dropped is a malformed request to every provider — the
162
+ * same constraint whole-Turn eviction solves by keeping both. Small results
163
+ * are left alone: pruning one costs a round number of characters and buys
164
+ * nothing.
165
+ */
166
+ export function pruneToolOutputsV1(
167
+ messages: readonly LlmMessage[],
168
+ turns: readonly number[],
169
+ keepTurns = TOOL_OUTPUT_KEEP_RECENT_TURNS_V1,
170
+ ): LlmMessage[] {
171
+ const distinct = [...new Set(turns)].sort((left, right) => right - left);
172
+ const verbatim = new Set(distinct.slice(0, Math.max(0, keepTurns)));
173
+ return messages.map((message, index) => {
174
+ if (message.role !== "tool") return message;
175
+ if (verbatim.has(turns[index]!)) return message;
176
+ if (message.content.length <= PRUNE_MIN_RESULT_CHARS_V1) return message;
177
+ const { attachments: _attachments, ...rest } = message;
178
+ return { ...rest, content: PRUNED_TOOL_RESULT_V1 };
179
+ });
180
+ }
181
+
182
+ /** The character measure ADR 0027 chose, over whatever window it is given. */
183
+ export function historyCharsV1(messages: readonly LlmMessage[]): number {
184
+ return messages.reduce(
185
+ (sum, message) => sum + JSON.stringify(message).length,
186
+ 0,
187
+ );
188
+ }
189
+
190
+ /** What one Turn-end evaluation concluded. */
191
+ export interface CompactionAssessmentV1 {
192
+ /** The pruned window's size, in characters. */
193
+ chars: number;
194
+ /** The threshold it was compared against. */
195
+ threshold: number;
196
+ /** The last Turn a new compaction would cover, when one is due. */
197
+ throughTurn?: number;
198
+ /** The first Turn it would cover — after any compaction already recorded. */
199
+ fromTurn?: number;
200
+ /** Why no compaction is due, when none is. */
201
+ skipped?:
202
+ | "under-threshold"
203
+ | "nothing-new-to-cover"
204
+ | "backing-off"
205
+ | "not-a-conversation";
206
+ }
207
+
208
+ /**
209
+ * Whether this conversation should be compacted, and over what range.
210
+ *
211
+ * `chatTurns` is every chat Turn on the log in order, and `messages`/`turns`
212
+ * the chat-only window the next request would carry — already narrowed by any
213
+ * compaction already recorded, so a conversation that has been compacted once
214
+ * is measured on what it actually costs now.
215
+ */
216
+ export function assessCompactionV1(input: {
217
+ messages: readonly LlmMessage[];
218
+ turns: readonly number[];
219
+ chatTurns: readonly number[];
220
+ state: CompactionStateV1;
221
+ budget: number;
222
+ /** The Turn that just ended, which spaces a retry after a failure. */
223
+ currentTurn: number;
224
+ }): CompactionAssessmentV1 {
225
+ const pruned = pruneToolOutputsV1(input.messages, input.turns);
226
+ const chars = historyCharsV1(pruned);
227
+ const threshold = Math.floor(input.budget * COMPACTION_TRIGGER_RATIO_V1);
228
+ const base = { chars, threshold };
229
+ if (chars <= threshold) return { ...base, skipped: "under-threshold" };
230
+ const covered = input.state.compaction?.throughTurn ?? 0;
231
+ const eligible = input.chatTurns.filter((turn) => turn > covered);
232
+ const throughTurn = eligible
233
+ .slice(0, Math.max(0, eligible.length - COMPACTION_KEEP_RECENT_TURNS_V1))
234
+ .at(-1);
235
+ if (throughTurn === undefined) {
236
+ return { ...base, skipped: "nothing-new-to-cover" };
237
+ }
238
+ if (input.state.failures > 0) {
239
+ const wait = Math.min(
240
+ 2 ** (input.state.failures - 1),
241
+ COMPACTION_MAX_BACKOFF_TURNS_V1,
242
+ );
243
+ if (input.currentTurn - input.state.lastFailureThroughTurn < wait) {
244
+ return { ...base, skipped: "backing-off" };
245
+ }
246
+ }
247
+ return {
248
+ ...base,
249
+ throughTurn,
250
+ fromTurn: input.state.compaction ? input.state.compaction.fromTurn : 1,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * The summariser's instructions.
256
+ *
257
+ * The identifier rule is the one artifact worth carrying over verbatim from
258
+ * the design this replaces, and FrockBot's stakes are higher than a chat app's:
259
+ * a paraphrased Package id, Applet id, Session id or Workspace path becomes a
260
+ * later tool call with a plausible-looking wrong argument. Asking the model to
261
+ * *list* what it saw is a far stronger constraint than asking it not to mangle
262
+ * ids in passing, and the list is what the event stores.
263
+ */
264
+ export const COMPACTION_SYSTEM_PROMPT_V1 = [
265
+ "You are compressing the earlier part of a conversation so it can be carried forward in a smaller prompt. Write the summary, and nothing else.",
266
+ "",
267
+ "CRITICAL: You MUST preserve ALL opaque identifiers exactly as they appear. That includes UUIDs, hashes, full URLs with their query parameters, file and Workspace paths, Package ids, Applet ids, Bot ids, Session ids, tool call ids, model names and version strings. Do NOT paraphrase, abbreviate, or generalise an identifier. Copy it exactly.",
268
+ "",
269
+ "Use exactly these headings, in this order, and omit none of them:",
270
+ "",
271
+ "## Summary",
272
+ "What the conversation is about and what has happened, in a few short paragraphs or bullets.",
273
+ "",
274
+ "## Decisions",
275
+ "Decisions made and the reason for each. Where a decision was later changed, keep only the latest and say it superseded an earlier one.",
276
+ "",
277
+ "## Open items",
278
+ "Work that is pending, promised, or unfinished. Be specific about what is owed and by whom.",
279
+ "",
280
+ "## Identifiers mentioned",
281
+ "A bullet list of every opaque identifier that appeared, one per line, copied exactly. Write `- none` if there were none.",
282
+ "",
283
+ "Leave out pleasantries, repetition, and superseded detail. Do not invent anything that is not in the transcript. Do not address the user.",
284
+ ].join("\n");
285
+
286
+ /** The transcript one summariser call is given, flattened to plain text. */
287
+ export function compactionTranscriptV1(
288
+ messages: readonly LlmMessage[],
289
+ ): string {
290
+ return messages
291
+ .map((message) => {
292
+ if (message.role === "user") return `USER: ${message.content}`;
293
+ if (message.role === "tool") {
294
+ return `[tool-result ${message.name}${message.isError ? " (error)" : ""}: ${message.content}]`;
295
+ }
296
+ const calls = message.toolCalls
297
+ .map(
298
+ (call) => `[tool-call ${call.name}(${JSON.stringify(call.input)})]`,
299
+ )
300
+ .join(" ");
301
+ return `ASSISTANT: ${message.content}${calls ? ` ${calls}` : ""}`;
302
+ })
303
+ .join("\n");
304
+ }
305
+
306
+ /** The one user message a summariser request carries. */
307
+ export function compactionRequestMessagesV1(input: {
308
+ messages: readonly LlmMessage[];
309
+ previous?: CompactionV1;
310
+ }): LlmMessage[] {
311
+ const preamble = input.previous
312
+ ? [
313
+ `The conversation was already summarised through Turn ${input.previous.throughTurn}. That summary follows, then the Turns after it. Produce ONE summary covering both: fold the old summary in rather than repeating it beside the new material.`,
314
+ "",
315
+ "--- summary so far ---",
316
+ input.previous.summary,
317
+ "--- end summary so far ---",
318
+ "",
319
+ ].join("\n")
320
+ : "";
321
+ return [
322
+ {
323
+ role: "user",
324
+ content: `${preamble}--- transcript to summarise ---\n${compactionTranscriptV1(input.messages)}\n--- end transcript ---`,
325
+ },
326
+ ];
327
+ }
328
+
329
+ /** A summary the log will accept, or `undefined` when there is nothing usable. */
330
+ export interface ParsedCompactionSummaryV1 {
331
+ summary: string;
332
+ identifiers: string[];
333
+ }
334
+
335
+ /**
336
+ * Reads the summariser's answer back.
337
+ *
338
+ * The prose is kept whole and bounded; the identifier list is lifted out of
339
+ * its heading so a test — and the audit view — can see exactly what the model
340
+ * claimed to have preserved. A model that ignored the headings still produces
341
+ * a usable summary with an empty identifier list, which is honest: it says
342
+ * nothing was verified rather than pretending something was.
343
+ */
344
+ export function parseCompactionSummaryV1(
345
+ text: string,
346
+ ): ParsedCompactionSummaryV1 | undefined {
347
+ const trimmed = text.trim();
348
+ if (trimmed.length === 0) return undefined;
349
+ const summary = trimmed.slice(0, COMPACTION_SUMMARY_MAX_LENGTH);
350
+ const heading = summary.search(/^##\s*Identifiers mentioned\s*$/im);
351
+ const identifiers: string[] = [];
352
+ if (heading >= 0) {
353
+ const body = summary.slice(heading).split("\n").slice(1);
354
+ for (const line of body) {
355
+ if (/^##\s/.test(line)) break;
356
+ const match = /^\s*[-*]\s+(.+?)\s*$/.exec(line);
357
+ if (!match) continue;
358
+ const value = match[1]!;
359
+ if (value.toLowerCase() === "none") continue;
360
+ if (value.length > COMPACTION_IDENTIFIER_MAX_LENGTH) continue;
361
+ if (identifiers.length >= COMPACTION_IDENTIFIERS_MAX) break;
362
+ if (!identifiers.includes(value)) identifiers.push(value);
363
+ }
364
+ }
365
+ return { summary, identifiers };
366
+ }
367
+
368
+ /** The model, and the Connection authority, one summariser call runs on. */
369
+ export interface CompactionModelV1 {
370
+ provider: string;
371
+ model: string;
372
+ modelBinding?: ModelBindingSnapshot;
373
+ }
374
+
375
+ /** The model one summariser call runs on, read back off the log. */
376
+ export function compactionModelV1(
377
+ events: readonly SessionEvent[],
378
+ ): CompactionModelV1 | undefined {
379
+ const request = events.findLast((event) => event.type === "model/request");
380
+ if (request?.type !== "model/request") return undefined;
381
+ return {
382
+ provider: request.request.provider,
383
+ model: request.request.model,
384
+ // The Connection authority the Turn ran under travels with it: a provider
385
+ // refuses a request whose binding does not name the Connection generation
386
+ // it holds, and rightly so — a summariser is not a way around that.
387
+ ...(request.request.modelBinding
388
+ ? { modelBinding: request.request.modelBinding }
389
+ : {}),
390
+ };
391
+ }
392
+
393
+ /** What one Turn-end evaluation actually did. */
394
+ export type CompactionOutcomeV1 =
395
+ | { kind: "skipped"; assessment: CompactionAssessmentV1 }
396
+ | { kind: "compacted"; throughTurn: number; fromTurn: number }
397
+ | { kind: "failed"; throughTurn: number; reason: string };
398
+
399
+ export interface CompactionRunnerV1 {
400
+ /** The whole log, and the append surface the events are written to. */
401
+ session: Session;
402
+ /** The chat-only window the next request would carry, already narrowed. */
403
+ window: {
404
+ messages: readonly LlmMessage[];
405
+ turns: readonly number[];
406
+ chatTurns: readonly number[];
407
+ state: CompactionStateV1;
408
+ };
409
+ budget: number;
410
+ currentTurn: number;
411
+ newEffectId(): string;
412
+ /** One bounded summariser call on the Bot's own model binding. */
413
+ summarise(
414
+ request: CompactionModelV1 & {
415
+ system: string;
416
+ messages: LlmMessage[];
417
+ signal: AbortSignal;
418
+ },
419
+ ): Promise<string>;
420
+ deadlineMs?: number;
421
+ }
422
+
423
+ /**
424
+ * One compaction evaluation, run after a Turn has ended.
425
+ *
426
+ * Everything durable it does is bounded by the range it covers, so a restart
427
+ * cannot double-write: an unsettled intent left by a previous attempt is
428
+ * settled as a failure first, the range is refused if a `conversation/compacted`
429
+ * already covers it, and the Durable Object is single-threaded between the
430
+ * check and the append. Failure is never fatal — the request that follows is
431
+ * exactly the request ADR 0027 would have assembled.
432
+ */
433
+ export async function runCompactionV1(
434
+ input: CompactionRunnerV1,
435
+ ): Promise<CompactionOutcomeV1> {
436
+ const session = input.session;
437
+ const state = input.window.state;
438
+ if (state.unsettled) {
439
+ // A restart interrupted an attempt. Its outcome is unknowable, so it is
440
+ // settled as a failure and backoff schedules the retry (ADR 0028).
441
+ session.append({
442
+ type: "conversation/compaction-failed",
443
+ effectId: state.unsettled.effectId,
444
+ throughTurn: state.unsettled.throughTurn,
445
+ reason: "Interrupted before a summary was recorded.",
446
+ });
447
+ await input.session.flush();
448
+ return {
449
+ kind: "failed",
450
+ throughTurn: state.unsettled.throughTurn,
451
+ reason: "interrupted",
452
+ };
453
+ }
454
+ const assessment = assessCompactionV1({
455
+ messages: input.window.messages,
456
+ turns: input.window.turns,
457
+ chatTurns: input.window.chatTurns,
458
+ state,
459
+ budget: input.budget,
460
+ currentTurn: input.currentTurn,
461
+ });
462
+ if (
463
+ assessment.throughTurn === undefined ||
464
+ assessment.fromTurn === undefined
465
+ ) {
466
+ return { kind: "skipped", assessment };
467
+ }
468
+ const { throughTurn, fromTurn } = assessment;
469
+ const binding = compactionModelV1(input.session.events);
470
+ if (!binding) return { kind: "skipped", assessment };
471
+ const covered: LlmMessage[] = [];
472
+ for (const [index, message] of input.window.messages.entries()) {
473
+ if (input.window.turns[index]! <= throughTurn) covered.push(message);
474
+ }
475
+ if (covered.length === 0) return { kind: "skipped", assessment };
476
+ const effectId = input.newEffectId();
477
+ // Intent before the effect: a summariser call is billed model spend.
478
+ session.append({
479
+ type: "conversation/compaction-intent",
480
+ effectId,
481
+ throughTurn,
482
+ provider: binding.provider,
483
+ model: binding.model,
484
+ });
485
+ await input.session.flush();
486
+ const controller = new AbortController();
487
+ const deadline = setTimeout(
488
+ () => controller.abort(new Error("The summariser ran past its deadline.")),
489
+ input.deadlineMs ?? COMPACTION_DEADLINE_MS_V1,
490
+ );
491
+ try {
492
+ const text = await input.summarise({
493
+ ...binding,
494
+ system: COMPACTION_SYSTEM_PROMPT_V1,
495
+ messages: compactionRequestMessagesV1({
496
+ messages: covered,
497
+ ...(state.compaction ? { previous: state.compaction } : {}),
498
+ }),
499
+ signal: controller.signal,
500
+ });
501
+ const parsed = parseCompactionSummaryV1(text);
502
+ if (!parsed) throw new Error("The summariser returned nothing usable.");
503
+ session.append({
504
+ type: "conversation/compacted",
505
+ effectId,
506
+ fromTurn,
507
+ throughTurn,
508
+ summary: parsed.summary,
509
+ identifiers: parsed.identifiers,
510
+ provider: binding.provider,
511
+ model: binding.model,
512
+ });
513
+ await input.session.flush();
514
+ return { kind: "compacted", throughTurn, fromTurn };
515
+ } catch (error) {
516
+ const reason = compactionFailureReasonV1(error);
517
+ session.append({
518
+ type: "conversation/compaction-failed",
519
+ effectId,
520
+ throughTurn,
521
+ reason,
522
+ });
523
+ await input.session.flush();
524
+ return { kind: "failed", throughTurn, reason };
525
+ } finally {
526
+ clearTimeout(deadline);
527
+ }
528
+ }
529
+
530
+ /** Truncates a failure description to what the event accepts. */
531
+ export function compactionFailureReasonV1(error: unknown): string {
532
+ const message =
533
+ error instanceof Error ? error.message : String(error ?? "unknown");
534
+ const collapsed = message.replaceAll(/\s+/g, " ").trim();
535
+ return (
536
+ collapsed.slice(0, COMPACTION_FAILURE_REASON_MAX_LENGTH) ||
537
+ "unknown failure"
538
+ );
539
+ }