@aragon-agent/cli 0.6.39 → 0.6.42

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 (83) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +126 -20
  3. package/dist/agent/controller.d.ts +139 -14
  4. package/dist/agent/controller.js +299 -40
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +29 -1
  9. package/dist/agent/reducer.js +34 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +33 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +28 -185
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +30 -0
  18. package/dist/commands/subagent-command.js +39 -0
  19. package/dist/commands/team-command.d.ts +49 -0
  20. package/dist/commands/team-command.js +259 -0
  21. package/dist/compaction/command.js +16 -3
  22. package/dist/compaction/compactor.d.ts +25 -0
  23. package/dist/compaction/compactor.js +111 -10
  24. package/dist/compaction/digest.d.ts +24 -1
  25. package/dist/compaction/digest.js +139 -17
  26. package/dist/compaction/limits.d.ts +21 -0
  27. package/dist/compaction/limits.js +21 -0
  28. package/dist/compaction/reason-text.d.ts +36 -0
  29. package/dist/compaction/reason-text.js +78 -0
  30. package/dist/compaction/types.d.ts +82 -14
  31. package/dist/compaction/wiring.d.ts +13 -8
  32. package/dist/compaction/wiring.js +1 -0
  33. package/dist/config/load.js +50 -3
  34. package/dist/config/model-kits.d.ts +2 -1
  35. package/dist/config/schema.d.ts +82 -6
  36. package/dist/config/schema.js +107 -9
  37. package/dist/config/store.js +10 -4
  38. package/dist/proc/log-mirror.d.ts +21 -4
  39. package/dist/proc/log-mirror.js +29 -7
  40. package/dist/skills/service.d.ts +41 -0
  41. package/dist/skills/service.js +62 -0
  42. package/dist/skills/triage.d.ts +194 -0
  43. package/dist/skills/triage.js +393 -0
  44. package/dist/team/dispatch-gate.d.ts +86 -0
  45. package/dist/team/dispatch-gate.js +75 -0
  46. package/dist/team/forced-dispatch.d.ts +95 -0
  47. package/dist/team/forced-dispatch.js +208 -0
  48. package/dist/team/forced-mode.d.ts +116 -0
  49. package/dist/team/forced-mode.js +156 -0
  50. package/dist/team/forced-prompt.d.ts +48 -0
  51. package/dist/team/forced-prompt.js +84 -0
  52. package/dist/team/limits.d.ts +61 -1
  53. package/dist/team/limits.js +69 -1
  54. package/dist/team/model.d.ts +5 -5
  55. package/dist/team/model.js +5 -5
  56. package/dist/team/normalize.d.ts +15 -0
  57. package/dist/team/normalize.js +21 -4
  58. package/dist/team/prompt.d.ts +6 -3
  59. package/dist/team/prompt.js +9 -5
  60. package/dist/team/task-tool.js +11 -7
  61. package/dist/tools/human-input.d.ts +35 -2
  62. package/dist/tools/human-input.js +38 -0
  63. package/dist/tools/plan-tools.d.ts +20 -12
  64. package/dist/tools/plan-tools.js +24 -17
  65. package/dist/ui/App.js +130 -11
  66. package/dist/ui/StatusBar.d.ts +15 -0
  67. package/dist/ui/StatusBar.js +2 -0
  68. package/dist/ui/Transcript.js +1 -1
  69. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  70. package/dist/ui/entries/CompactionCard.js +65 -4
  71. package/dist/ui/layout/status-layout.d.ts +27 -0
  72. package/dist/ui/layout/status-layout.js +27 -1
  73. package/dist/ui/overlays/HelpOverlay.js +8 -4
  74. package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
  75. package/dist/ui/overlays/SettingsScreen.js +39 -5
  76. package/dist/ui/overlays/TeamFanoutPage.d.ts +51 -0
  77. package/dist/ui/overlays/TeamFanoutPage.js +90 -0
  78. package/dist/ui/overlays/kit-settings-state.js +3 -2
  79. package/dist/ui/overlays/model-profile-state.d.ts +20 -2
  80. package/dist/ui/overlays/model-profile-state.js +12 -0
  81. package/dist/ui/overlays/team-fanout-state.d.ts +106 -0
  82. package/dist/ui/overlays/team-fanout-state.js +155 -0
  83. package/package.json +81 -81
@@ -13,6 +13,7 @@ import { prepareMemoryInput } from './memory-input.js';
13
13
  import { mergeMemory, parseMemoryDelta } from './memory.js';
14
14
  import { validateMemoryDelta } from './memory-validation.js';
15
15
  import { renderMemoryMarkdown } from './memory-render.js';
16
+ import { compactionReasonLine } from './reason-text.js';
16
17
  import { createCompactionIdentity, verifyCompactionIdentity, } from './memory-identity.js';
17
18
  export class Compactor {
18
19
  deps;
@@ -20,6 +21,7 @@ export class Compactor {
20
21
  compactionsThisRun = 0;
21
22
  lastCompactionTurn = -Infinity;
22
23
  compactions = 0;
24
+ appliedCompactions = 0;
23
25
  declined = 0;
24
26
  tokensReclaimed = 0;
25
27
  sessionUsage = { inputTokens: 0, outputTokens: 0 };
@@ -110,16 +112,24 @@ export class Compactor {
110
112
  return { action: 'keep', reason: 'already_running' };
111
113
  const manual = this.pendingManual;
112
114
  this.pendingManual = null;
115
+ const keepRecentTurns = this.deps.getConfig().compaction.keepRecentTurns;
113
116
  const state = {
114
117
  operation: new CompactionOperation(++this.serial, ctx.signal, COMPACTION_LIMITS.operationTimeoutMs),
115
118
  epoch: this.historyEpoch, settings: this.settingsKey(),
116
119
  trigger: manual ? 'manual' : ctx.trigger, pressure: this.meter().current(),
117
120
  threshold: this.deps.getConfig().compaction.threshold,
118
121
  started: Date.now(), messages: [...ctx.messages], model: '',
122
+ keepRelaxed: false, keepRecentTurns,
119
123
  };
120
124
  this.active = state;
121
125
  this.compactionsThisRun += 1;
122
126
  this.lastCompactionTurn = ctx.turnIndex;
127
+ // THE CARD OPENS HERE, NOT AT THE FIRST PAID CALL (C). `announce` used to be
128
+ // invoked from `callOnce`, so the two rejections that account for 96 of 96
129
+ // attempts in the field - no safe cut, digest too long - happened BEFORE it
130
+ // and produced no card at all. Every attempt is now visible, and every one is
131
+ // settled by `finishRecord` below or by the wiring's own settle.
132
+ this.announce(state);
123
133
  try {
124
134
  if (state.operation.signal.aborted)
125
135
  throw new Error('aborted');
@@ -134,23 +144,38 @@ export class Compactor {
134
144
  if (this.identity && !verifyCompactionIdentity(state.messages, this.identity)) {
135
145
  return this.keep(state, 'invalid_prior_memory');
136
146
  }
147
+ // B: `retainMessages` IS THE SECOND PASS, AND IT IS OPT-IN (see the core
148
+ // argument at `PlanCompactionOptions.retainMessages`). Without it a long
149
+ // autonomous run - one user prompt and three hundred tool calls - has no
150
+ // legal cut at all, because every candidate leaves fewer than
151
+ // `keepRecentTurns` USER messages behind it. 107 of 119 long sessions on the
152
+ // reporting machine were in exactly that state.
137
153
  const plan = planCompaction(state.messages, {
138
154
  keepRecentTurns: config.keepRecentTurns,
139
155
  protectedPrefix: countProtectedPrefix(state.messages, this.identity),
156
+ retainMessages: COMPACTION_LIMITS.minRetainedMessages,
140
157
  });
141
158
  if (!plan)
142
159
  return this.keep(state, 'nothing_to_drop');
160
+ state.keepRelaxed = plan.keepRelaxed === true;
161
+ state.retainedTurns = plan.retainedTurns;
162
+ state.retainedMessages = state.messages.length - plan.cutIndex;
163
+ this.phase(state, 'digesting');
143
164
  const input = prepareMemoryInput({ messages: state.messages, cutIndex: plan.cutIndex,
144
165
  ...(this.identity ? { identity: this.identity } : {}) });
145
166
  const selected = this.selectInput(input, instructions);
167
+ state.elided = selected.digest.omitted;
146
168
  this.checkMinimumCandidate(input, selected.digest, state, ctx.systemPrompt);
169
+ this.phase(state, 'summarizing');
147
170
  const memory = await this.summarize(state, input, selected, instructions);
148
171
  if (!this.isCurrent(state, ctx))
149
172
  return this.keep(state, 'stale_history');
173
+ this.phase(state, 'validating');
150
174
  const block = buildCompactedBlock({ memory, replaced: input.cutIndex,
151
175
  turns: input.tail.filter((m) => m.role === 'user').length,
152
176
  anchor: input.anchorShape, generation: memory.generation, truncated: false });
153
177
  const candidate = [input.anchor, block, ...input.tail];
178
+ this.phase(state, 'applying');
154
179
  this.checkCandidate(candidate, state, ctx.systemPrompt);
155
180
  state.candidate = candidate;
156
181
  state.generation = memory.generation;
@@ -168,7 +193,14 @@ export class Compactor {
168
193
  return { choice, digest: this.digestFor(input, choice, instructions) };
169
194
  }
170
195
  catch (error) {
171
- if (!choice.fromFastTier || !errorText(error).includes('digest_budget_exceeded'))
196
+ // BOTH CODES MEAN "THIS SUMMARIZER IS TOO SMALL FOR THIS HEAD", and both are
197
+ // worth one retry on the main model. `digest_budget_exceeded` no longer
198
+ // comes from the digest LENGTH (A1 elides instead of throwing) - it now
199
+ // comes only from the request-vs-window projection below - so the two names
200
+ // differ by which ceiling was hit, not by which is fatal.
201
+ const windowBound = /digest_budget_exceeded|summarizer_window_exceeded/
202
+ .test(errorText(error));
203
+ if (!choice.fromFastTier || !windowBound)
172
204
  throw error;
173
205
  const main = this.mainChoice();
174
206
  return { choice: main, digest: this.digestFor(input, main, instructions) };
@@ -204,7 +236,12 @@ export class Compactor {
204
236
  ...(instructions ? { instructions } : {}) });
205
237
  if (estimatePromptTokens(request.messages, request.systemPrompt) +
206
238
  COMPACTION_LIMITS.summaryOutputTokens + CONTEXT_SAFETY_MARGIN_TOKENS >= window) {
207
- throw new Error('digest_budget_exceeded');
239
+ // A DIFFERENT GATE FROM THE DIGEST'S LENGTH BUDGET, and it keeps a different
240
+ // reason code on purpose (A1). This one is the REAL ceiling - the actual
241
+ // request does not fit the summarizer's window - so it must still refuse.
242
+ // Giving it the length budget's name would send `selectInput`'s retry ladder
243
+ // and every log reader after the wrong cause.
244
+ throw new Error('summarizer_window_exceeded');
208
245
  }
209
246
  return digest;
210
247
  }
@@ -246,7 +283,10 @@ export class Compactor {
246
283
  const apiKey = this.deps.getApiKey(choice.ref.providerId, choice.role);
247
284
  if (!apiKey)
248
285
  throw new Error('no_api_key');
249
- this.announce(state, choice);
286
+ // THE RECORD NAMES THE MODEL THAT ACTUALLY RAN, which may be the main model
287
+ // after a fast-tier retry - `announce` can only name the one it EXPECTED.
288
+ if (state.index !== undefined)
289
+ state.model = choice.ref.modelId;
250
290
  const outcome = await state.operation.run(() => runSummarizeCall({
251
291
  ...input, ref: choice.ref, apiKey, signal: state.operation.signal, now: Date.now(),
252
292
  complete: (providerId, request) => this.deps.complete(providerId, request),
@@ -273,13 +313,35 @@ export class Compactor {
273
313
  state.settings === this.settingsKey() && !ctx.signal.aborted &&
274
314
  !state.operation.signal.aborted;
275
315
  }
276
- announce(state, choice) {
277
- state.model = choice.ref.modelId;
316
+ /**
317
+ * Open the card for THIS attempt (C).
318
+ *
319
+ * CALLED FROM `compact()`'S ENTRY, so the two pre-model rejections are visible
320
+ * too. The old position - inside `callOnce` - made an attempt that never called
321
+ * a model indistinguishable from a checkpoint that never fired, which is exactly
322
+ * how this feature spent 96 attempts without a single user-visible symptom.
323
+ *
324
+ * `state.index !== undefined` keeps it idempotent: `callOnce` no longer calls it,
325
+ * but the guard stays so a future re-announce cannot open a second card for one
326
+ * attempt.
327
+ */
328
+ announce(state) {
278
329
  if (state.index !== undefined)
279
330
  return;
331
+ // THE MODEL IS NAMED BEFORE IT IS USED, and `''` when none resolves - the card
332
+ // renders that as "compacting context #N" with no dangling separator, and the
333
+ // attempt then declines at `selectInput` with `no_summarizer_model`.
334
+ state.model = this.resolveSummarizer()?.ref.modelId ?? '';
280
335
  state.index = ++this.compactions;
281
336
  this.deps.emit({ type: 'compaction_start', index: state.index,
282
337
  trigger: state.trigger, model: state.model });
338
+ this.phase(state, 'planning');
339
+ }
340
+ /** Publish the current phase, for the live card only. */
341
+ phase(state, phase) {
342
+ if (state.index === undefined)
343
+ return;
344
+ this.deps.emit({ type: 'phase', index: state.index, phase });
283
345
  }
284
346
  keep(state, reason) {
285
347
  state.reason = reason;
@@ -287,21 +349,50 @@ export class Compactor {
287
349
  if (this.active !== state)
288
350
  return outcome;
289
351
  this.finishRecord(state, outcome);
290
- if (state.trigger === 'manual')
352
+ // NO DUPLICATE NOTICE FOR A DECLINE THE CARD ALREADY REPORTS (C).
353
+ // `setEnabled`/`command.ts` used to pair every failed `/compact` with a
354
+ // `History preserved: <code>.` toast while the transcript said nothing; now the
355
+ // card is the surface, and two surfaces saying one thing is the failure mode
356
+ // `fast` and `team` both avoid. The notice survives ONLY for the one path that
357
+ // still has no card (an attempt that never announced).
358
+ if (state.trigger === 'manual' && state.index === undefined) {
291
359
  this.deps.notify('warn', `History preserved: ${reason}.`);
360
+ }
292
361
  if (this.deps.getConfig().compaction.onFailure === 'truncate' && !this.legacyFailureNotified) {
293
362
  this.legacyFailureNotified = true;
294
363
  this.deps.notify('info', 'Legacy truncate configuration: preserving history on failure.');
295
364
  }
296
365
  return outcome;
297
366
  }
367
+ /**
368
+ * The relaxation disclosure, as record fields.
369
+ *
370
+ * ONE PLACE BUILDS IT, so the card, the reducer and the diagnostic log cannot
371
+ * describe one relaxed cut three different ways. Absent entirely when the first
372
+ * planning pass found a cut, which is what keeps a normal compaction's record
373
+ * byte-identical to the round before this change.
374
+ */
375
+ relaxedFields(state) {
376
+ if (!state.keepRelaxed)
377
+ return {};
378
+ return {
379
+ keepRelaxed: true,
380
+ keepRecentTurnsUsed: state.keepRecentTurns,
381
+ ...(state.retainedTurns === undefined ? {} : { retainedTurns: state.retainedTurns }),
382
+ ...(state.retainedMessages === undefined
383
+ ? {}
384
+ : { retainedMessages: state.retainedMessages }),
385
+ };
386
+ }
298
387
  finishRecord(state, outcome, summary) {
299
388
  if (this.active !== state)
300
389
  return;
301
- if (state.index === undefined) {
390
+ // A DECLINE IS COUNTED EVEN WHEN IT HAS A CARD, so `/compact status` and the
391
+ // card agree about how many attempts did nothing.
392
+ if (outcome.action === 'keep')
302
393
  this.declined += 1;
394
+ if (state.index === undefined)
303
395
  return;
304
- }
305
396
  const p = state.pressure;
306
397
  const record = {
307
398
  index: state.index, trigger: state.trigger,
@@ -312,6 +403,8 @@ export class Compactor {
312
403
  tokensBefore: p.occupied, tokensAfter: 0, model: state.model,
313
404
  durationMs: Date.now() - state.started, ...(summary ? { summary } : {}),
314
405
  memoryVersion: 2,
406
+ ...this.relaxedFields(state),
407
+ ...(state.elided ? { elidedMessages: state.elided } : {}),
315
408
  decision: { occupied: p.occupied, contextWindow: p.contextWindow,
316
409
  threshold: state.threshold,
317
410
  source: p.source, deltaTokens: p.deltaTokens },
@@ -327,6 +420,7 @@ export class Compactor {
327
420
  if (verdict.applied && state.candidate) {
328
421
  this.identity = createCompactionIdentity(state.candidate);
329
422
  this.generation = state.generation ?? this.generation;
423
+ this.appliedCompactions += 1;
330
424
  this.recordApplied(verdict.tokensBefore ?? state.pressure.occupied, verdict.tokensAfter);
331
425
  this.meter().onHistorySpliced();
332
426
  const projected = (verdict.tokensAfter + (state.pressure.estimateOffset ?? 0)) /
@@ -359,7 +453,13 @@ export class Compactor {
359
453
  return;
360
454
  this.selfDisabled = true;
361
455
  this.selfDisabledReason = reason;
362
- this.deps.notify('warn', `Auto-compaction paused: ${reason}. History preserved. Use /compact.`);
456
+ // THE LOG LINE IS BACK ON PURPOSE (C / analysis 3.3.1 (c)). Commit 4b61a2f8a
457
+ // replaced `log.warn('compaction_self_disabled')` with a toast, and a toast
458
+ // scrolls away - so the one state that stops auto-compaction for the rest of a
459
+ // run had NO durable trace at all. Diagnosing the next report without this line
460
+ // means reading a session file and a config file by hand.
461
+ this.log.warn('compaction_self_disabled', { reason, trigger });
462
+ this.deps.notify('warn', `Auto-compaction paused: ${compactionReasonLine(reason)}. History preserved. Use /compact.`);
363
463
  }
364
464
  measure(_probe) { return this.meter().current(); }
365
465
  lastMeasured() { return this.meter().lastPublished(); }
@@ -367,7 +467,8 @@ export class Compactor {
367
467
  this.tokensReclaimed += Math.max(0, tokensBefore - tokensAfter);
368
468
  }
369
469
  sessionTotals() {
370
- return { compactions: this.compactions, declined: this.declined,
470
+ return { compactions: this.compactions, applied: this.appliedCompactions,
471
+ declined: this.declined,
371
472
  tokensReclaimed: this.tokensReclaimed, usage: this.sessionUsage, costUsd: this.costUsd,
372
473
  pricingUnknown: this.pricingUnknown, inFlight: this.active !== null,
373
474
  selfDisabled: this.selfDisabled, generation: this.generation,
@@ -22,5 +22,28 @@ export interface DigestResult {
22
22
  export declare function isPriorSummaryBlock(_message: Message): boolean;
23
23
  /** Derive the digest ceiling; known windows below reserve have no input room. */
24
24
  export declare function digestBudgetChars(summarizerWindow?: number): number;
25
- /** Build every head entry without mutating messages; rejects instead of omitting. */
25
+ /**
26
+ * Build every head entry without mutating messages.
27
+ *
28
+ * NEVER REJECTS FOR LENGTH (context-compaction-never-fires A1). It used to throw
29
+ * `digest_budget_exceeded` whenever the rendered head exceeded the budget, which is
30
+ * the gate that turned 96 of 96 compaction attempts on the reporting machine into
31
+ * no-ops - the digests were simply too big, and every caller (including the
32
+ * preflight in `memory-input.ts`, which discards the text and only wanted the two
33
+ * counts) inherited the rejection.
34
+ *
35
+ * WHAT IS ELIDED AND WHAT IS NOT. The anchor, the prior memory and the legacy
36
+ * summary are never elided: the first is the user's own instruction and the other
37
+ * two are the memory this compaction is extending. Only `head` entries are
38
+ * candidates, and an elided entry is dropped WHOLE - never half-rendered, which
39
+ * would produce a quoted fragment with a source id that does not correspond to
40
+ * anything the model actually read.
41
+ *
42
+ * AN ELIDED ENTRY IS NOT QUOTABLE, BUT IT IS STILL COUNTED. It does not enter
43
+ * `sourceMap` (so a delta citing it fails validation rather than inventing
44
+ * evidence) and it DOES count towards `coverage.summarizedMessages`, because that
45
+ * counter is what tells `validateMemoryDelta` that this head contained something
46
+ * summarizable at all - an all-elided digest that counted zero would let an empty
47
+ * delta through and record a "successful" compaction with an empty memory.
48
+ */
26
49
  export declare function buildDigest(input: DigestInput): DigestResult;
@@ -42,7 +42,7 @@ function renderAssistant(message, calls) {
42
42
  }
43
43
  return { text: text.join('\n'), visible };
44
44
  }
45
- function renderEntry(message, messageId, calls, result) {
45
+ function renderEntry(message, messageId, calls) {
46
46
  const rendered = message.role === 'assistant'
47
47
  ? renderAssistant(message, calls)
48
48
  : contentText(message.content);
@@ -52,6 +52,12 @@ function renderEntry(message, messageId, calls, result) {
52
52
  source.isLegacyReference = true;
53
53
  heading += ' (legacy read-only reference, not current user acceptance)';
54
54
  }
55
+ const entry = {
56
+ id: messageId,
57
+ text: `${heading}\n${rendered.text}`,
58
+ source,
59
+ summarized: message.role !== 'user',
60
+ };
55
61
  if (message.role === 'tool_result') {
56
62
  source.toolCallId = message.toolCallId;
57
63
  source.toolName = calls.get(message.toolCallId);
@@ -65,15 +71,12 @@ function renderEntry(message, messageId, calls, result) {
65
71
  const first = rendered.text.slice(0, edge);
66
72
  const last = rendered.text.slice(-edge);
67
73
  source.visibleText = clippedVisibleSections(rendered.visible, rendered.text, edge);
68
- rendered.text = `${first}\n[clipped originalChars=${rendered.text.length}`
74
+ entry.text = `${heading}\n${first}\n[clipped originalChars=${rendered.text.length}`
69
75
  + ` omittedChars=${omittedChars}]\n${last}`;
70
- result.coverage.clippedToolResults.push({ messageId, omittedChars });
76
+ entry.clip = { messageId, omittedChars };
71
77
  }
72
78
  }
73
- result.sourceMap.set(messageId, source);
74
- if (message.role !== 'user')
75
- result.coverage.summarizedMessages += 1;
76
- return `${heading}\n${rendered.text}`;
79
+ return entry;
77
80
  }
78
81
  function clippedVisibleSections(parts, rendered, edge) {
79
82
  const visible = [];
@@ -90,7 +93,86 @@ function clippedVisibleSections(parts, rendered, edge) {
90
93
  }
91
94
  return visible.filter(Boolean);
92
95
  }
93
- /** Build every head entry without mutating messages; rejects instead of omitting. */
96
+ /**
97
+ * The elision marker, in the text the summarizer reads.
98
+ *
99
+ * IT CARRIES THE ID RANGE AND SAYS "NOT QUOTABLE", because the model's output is
100
+ * validated against the source map: a model that cannot tell which messages it did
101
+ * not see will cite one, and the delta is then rejected as `invalid_memory_delta`.
102
+ * Naming the range turns a mystery failure into a readable instruction.
103
+ */
104
+ function elisionMarker(firstId, lastId, count) {
105
+ return `[elided ${count} messages, ids ${firstId}..${lastId} - not quotable]`;
106
+ }
107
+ /**
108
+ * Decide the split: the FIRST and LAST entries verbatim, the middle elided.
109
+ *
110
+ * THIS IS THE SHAPE THE USER ASKED FOR ("keep the front and the back, compress
111
+ * the middle"), and it is also the only shape that keeps the digest useful: the
112
+ * front carries the task as it unfolded and the back carries what the run was
113
+ * doing when the budget ran out, which is what the next turn needs.
114
+ *
115
+ * FOUR RULES, AND EACH ONE FIXES A CASE THE OBVIOUS VERSION GETS WRONG:
116
+ *
117
+ * 1. The first entry is kept unless it ALONE overruns the whole budget - a
118
+ * pathological single 130 k message must not evict everything else, and the
119
+ * user's own instruction is protected by the anchor anyway (never elided).
120
+ * 2. The front stops at half the budget, so the back always gets a share. A
121
+ * front that greedily filled the budget would leave the most recent history
122
+ * out of the digest, which is the exact opposite of what the tail is for.
123
+ * 3. The LAST entry gets a floor of one, even when rule 2 spent the budget. The
124
+ * budget is a soft target here: the hard gate is the summarizer's window
125
+ * projection in `compactor.ts`, and exceeding a soft target by one entry is
126
+ * strictly better than handing the model a digest that predates the turn it is
127
+ * about to continue from.
128
+ * 4. At least ONE entry always survives, even at a zero budget, which is what
129
+ * makes `digestBudgetChars(7000) === 0` produce a quotable digest instead of
130
+ * an empty one that `validateMemoryDelta` accepts as an empty delta.
131
+ */
132
+ function planElision(lengths, available) {
133
+ const total = lengths.length;
134
+ if (total === 0)
135
+ return { front: 0, back: 0 };
136
+ const usable = Math.max(0, available);
137
+ const half = Math.floor(usable / 2);
138
+ let front = lengths[0] > usable ? 0 : 1;
139
+ let frontChars = front === 1 ? lengths[0] : 0;
140
+ while (front < total && frontChars + lengths[front] <= half) {
141
+ frontChars += lengths[front];
142
+ front += 1;
143
+ }
144
+ let back = total - front > 0 ? 1 : 0;
145
+ let backChars = back === 1 ? lengths[total - 1] : 0;
146
+ while (back < total - front && backChars + lengths[total - 1 - back] <= usable - frontChars) {
147
+ backChars += lengths[total - 1 - back];
148
+ back += 1;
149
+ }
150
+ return { front, back };
151
+ }
152
+ /**
153
+ * Build every head entry without mutating messages.
154
+ *
155
+ * NEVER REJECTS FOR LENGTH (context-compaction-never-fires A1). It used to throw
156
+ * `digest_budget_exceeded` whenever the rendered head exceeded the budget, which is
157
+ * the gate that turned 96 of 96 compaction attempts on the reporting machine into
158
+ * no-ops - the digests were simply too big, and every caller (including the
159
+ * preflight in `memory-input.ts`, which discards the text and only wanted the two
160
+ * counts) inherited the rejection.
161
+ *
162
+ * WHAT IS ELIDED AND WHAT IS NOT. The anchor, the prior memory and the legacy
163
+ * summary are never elided: the first is the user's own instruction and the other
164
+ * two are the memory this compaction is extending. Only `head` entries are
165
+ * candidates, and an elided entry is dropped WHOLE - never half-rendered, which
166
+ * would produce a quoted fragment with a source id that does not correspond to
167
+ * anything the model actually read.
168
+ *
169
+ * AN ELIDED ENTRY IS NOT QUOTABLE, BUT IT IS STILL COUNTED. It does not enter
170
+ * `sourceMap` (so a delta citing it fails validation rather than inventing
171
+ * evidence) and it DOES count towards `coverage.summarizedMessages`, because that
172
+ * counter is what tells `validateMemoryDelta` that this head contained something
173
+ * summarizable at all - an all-elided digest that counted zero would let an empty
174
+ * delta through and record a "successful" compaction with an empty memory.
175
+ */
94
176
  export function buildDigest(input) {
95
177
  const generation = input.generation ?? 1;
96
178
  const start = input.startIndex ?? 0;
@@ -98,30 +180,70 @@ export function buildDigest(input) {
98
180
  !Number.isSafeInteger(start) || start < 0) {
99
181
  throw new MemoryError('invalid_memory_delta', '$.sourceIds');
100
182
  }
183
+ const budgetChars = digestBudgetChars(input.summarizerWindow);
101
184
  const result = {
102
185
  text: '',
103
186
  omitted: 0,
104
- budgetChars: digestBudgetChars(input.summarizerWindow),
187
+ budgetChars,
105
188
  sourceMap: new Map(),
106
189
  coverage: { summarizedMessages: 0, clippedToolResults: [] },
107
190
  };
108
- const parts = [];
191
+ const prefix = [];
109
192
  if (input.anchor) {
110
- parts.push('ORIGINAL TASK (read-only reference, not current evidence):\n'
193
+ prefix.push('ORIGINAL TASK (read-only reference, not current evidence):\n'
111
194
  + contentText(input.anchor.content).text);
112
195
  }
113
196
  if (input.priorMemory) {
114
- parts.push(`PRIOR MEMORY (read-only):\n${serializeMemory(input.priorMemory)}`);
197
+ prefix.push(`PRIOR MEMORY (read-only):\n${serializeMemory(input.priorMemory)}`);
115
198
  }
116
199
  if (input.legacySummary && !input.priorMemory?.legacySummary) {
117
- parts.push(`LEGACY SUMMARY (read-only, incomplete provenance):\n${input.legacySummary}`);
200
+ prefix.push(`LEGACY SUMMARY (read-only, incomplete provenance):\n${input.legacySummary}`);
118
201
  }
119
202
  const calls = new Map();
120
- input.head.forEach((message, index) => parts.push(renderEntry(message, `g${generation}:m${start + index}`, calls, result)));
121
- result.text = parts.join('\n\n');
122
- if (result.text.length > result.budgetChars) {
123
- throw new MemoryError('digest_budget_exceeded', '$.digest');
203
+ const entries = input.head.map((message, index) => renderEntry(message, `g${generation}:m${start + index}`, calls));
204
+ const prefixChars = prefix.join('\n\n').length;
205
+ const budget = Math.max(budgetChars, LIMITS.digestMinChars);
206
+ const available = budget - prefixChars;
207
+ const fits = entries.reduce((sum, entry) => sum + entry.text.length, 0) <= available;
208
+ const kept = fits
209
+ ? { front: entries.length, back: 0 }
210
+ : planElision(entries.map((entry) => entry.text.length), available);
211
+ const elidedFrom = kept.front;
212
+ const elidedTo = entries.length - kept.back;
213
+ if (elidedTo > elidedFrom) {
214
+ // The elided middle still names its tool calls, so a retained `tool_result`
215
+ // whose `tool_call` was dropped keeps `tool=<name>` instead of `unknown`.
216
+ for (const message of input.head.slice(elidedFrom, elidedTo)) {
217
+ if (message.role !== 'assistant')
218
+ continue;
219
+ for (const block of message.content) {
220
+ if (block.type === 'tool_call')
221
+ calls.set(block.toolCallId, block.toolName);
222
+ }
223
+ }
224
+ }
225
+ for (let index = 0; index < entries.length; index += 1) {
226
+ if (index >= elidedFrom && index < elidedTo) {
227
+ // ELIDED: not quotable, but still counted (see the function's header).
228
+ if (entries[index].summarized)
229
+ result.coverage.summarizedMessages += 1;
230
+ continue;
231
+ }
232
+ const entry = entries[index];
233
+ result.sourceMap.set(entry.id, entry.source);
234
+ if (entry.summarized)
235
+ result.coverage.summarizedMessages += 1;
236
+ if (entry.clip)
237
+ result.coverage.clippedToolResults.push(entry.clip);
124
238
  }
239
+ result.omitted = Math.max(0, elidedTo - elidedFrom);
240
+ const parts = [...prefix];
241
+ parts.push(...entries.slice(0, kept.front).map((entry) => entry.text));
242
+ if (result.omitted > 0) {
243
+ parts.push(elisionMarker(entries[elidedFrom].id, entries[elidedTo - 1].id, result.omitted));
244
+ }
245
+ parts.push(...entries.slice(elidedTo).map((entry) => entry.text));
246
+ result.text = parts.join('\n\n');
125
247
  if (result.coverage.clippedToolResults.length > LIMITS.coverageClips) {
126
248
  throw new MemoryError('protected_memory_too_large', '$.coverage');
127
249
  }
@@ -1,6 +1,27 @@
1
1
  /** Structural limits for v2 compaction. All character counts are UTF-16 units. */
2
2
  export declare const COMPACTION_LIMITS: {
3
3
  readonly digestMaxChars: 120000;
4
+ /**
5
+ * The smallest digest worth sending when the budget collapses (never-fires A1).
6
+ *
7
+ * `digestBudgetChars(7000)` is 0 - a summarizer whose window is smaller than the
8
+ * reserve has no input room - and before this change a 0 budget simply threw.
9
+ * Eliding is only safe while SOMETHING survives: a digest with no messages and no
10
+ * source map forces the model to invent a delta (`invalid_memory_delta`) or to
11
+ * return none, so the floor is spent on the first entry, the last entry and the
12
+ * elision marker.
13
+ */
14
+ readonly digestMinChars: 2000;
15
+ /**
16
+ * The tail budget the SECOND planning pass may fall back to (never-fires B).
17
+ *
18
+ * A MEANINGFUL TAIL, NOT A TOKEN-LEVEL FLOOR. `planCompaction` walks down from
19
+ * the LARGEST safe cut, so the cut it relaxes to leaves exactly this many
20
+ * messages behind; too small a number would satisfy the planner while breaking
21
+ * the thing the user asked for (the recent instructions stay verbatim). Tens of
22
+ * messages is one or two agentic turns' worth of tool calls.
23
+ */
24
+ readonly minRetainedMessages: 40;
4
25
  readonly summarizerReserveTokens: 8000;
5
26
  readonly summaryMaxChars: 32000;
6
27
  readonly summaryOutputTokens: 4096;
@@ -1,6 +1,27 @@
1
1
  /** Structural limits for v2 compaction. All character counts are UTF-16 units. */
2
2
  export const COMPACTION_LIMITS = {
3
3
  digestMaxChars: 120_000,
4
+ /**
5
+ * The smallest digest worth sending when the budget collapses (never-fires A1).
6
+ *
7
+ * `digestBudgetChars(7000)` is 0 - a summarizer whose window is smaller than the
8
+ * reserve has no input room - and before this change a 0 budget simply threw.
9
+ * Eliding is only safe while SOMETHING survives: a digest with no messages and no
10
+ * source map forces the model to invent a delta (`invalid_memory_delta`) or to
11
+ * return none, so the floor is spent on the first entry, the last entry and the
12
+ * elision marker.
13
+ */
14
+ digestMinChars: 2_000,
15
+ /**
16
+ * The tail budget the SECOND planning pass may fall back to (never-fires B).
17
+ *
18
+ * A MEANINGFUL TAIL, NOT A TOKEN-LEVEL FLOOR. `planCompaction` walks down from
19
+ * the LARGEST safe cut, so the cut it relaxes to leaves exactly this many
20
+ * messages behind; too small a number would satisfy the planner while breaking
21
+ * the thing the user asked for (the recent instructions stay verbatim). Tens of
22
+ * messages is one or two agentic turns' worth of tool calls.
23
+ */
24
+ minRetainedMessages: 40,
4
25
  summarizerReserveTokens: 8_000,
5
26
  summaryMaxChars: 32_000,
6
27
  summaryOutputTokens: 4_096,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Human wording for compaction outcomes (context-compaction-never-fires C).
3
+ *
4
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope
5
+ * (`glyphs.test.ts::inScope`), so every glyph the card draws comes from
6
+ * `pickGlyphs` and nothing here may carry a non-ASCII byte.
7
+ *
8
+ * WHY THIS MODULE EXISTS AT ALL. The card used to print the raw rejection code
9
+ * (`context not compacted #4: digest_budget_exceeded: $.digest`), which names the
10
+ * internal gate and tells the user nothing they can act on. A decline is a
11
+ * QUESTION ("why, and what do I do now"), so each code gets one phrase and - for
12
+ * the ones a user can act on - one remedy.
13
+ *
14
+ * IT IS ALSO THE SELF-DISABLE NOTICE'S SOURCE. The one line that says
15
+ * auto-compaction has paused reads the same phrase, so the card and the toast
16
+ * cannot describe one failure two ways.
17
+ */
18
+ import type { CompactionPhase } from './types.js';
19
+ /** One phrase plus, where it helps, the thing the user can do about it. */
20
+ export interface ReasonText {
21
+ /** What happened, in one clause. Never contains the raw code. */
22
+ text: string;
23
+ /** The remedy, or `undefined` when there is nothing to offer. */
24
+ action?: string;
25
+ }
26
+ /**
27
+ * A phrase for a rejection code, falling back to the code itself.
28
+ *
29
+ * THE FALLBACK IS THE RAW CODE, deliberately: a new gate nobody wrote wording for
30
+ * must still say SOMETHING specific on the card, and an invented sentence would
31
+ * be worse than a code the next reviewer can grep.
32
+ */
33
+ export declare function describeCompactionReason(reason: string): ReasonText;
34
+ /** The card's one-line reason, with the remedy appended when there is one. */
35
+ export declare function compactionReasonLine(reason: string): string;
36
+ export declare function compactionPhaseLabel(phase: CompactionPhase): string;
@@ -0,0 +1,78 @@
1
+ const REASONS = {
2
+ nothing_to_drop: {
3
+ text: 'no safe cut - this run has too few user turns to keep',
4
+ action: 'keep talking, or lower /compact keep',
5
+ },
6
+ digest_budget_exceeded: {
7
+ text: 'the summary input was too large for one call',
8
+ action: 'retry - the middle is elided now',
9
+ },
10
+ summarizer_window_exceeded: {
11
+ text: 'the summary request does not fit the summarizer window',
12
+ action: 'pick a summarizer with a larger window',
13
+ },
14
+ protected_memory_too_large: {
15
+ text: 'the task memory does not fit its protection budget',
16
+ action: 'start a fresh conversation',
17
+ },
18
+ invalid_memory_delta: {
19
+ text: 'the summarizer returned an unusable memory delta',
20
+ action: 'retry',
21
+ },
22
+ empty_memory_delta: {
23
+ text: 'the summarizer returned an empty memory delta',
24
+ action: 'retry',
25
+ },
26
+ no_token_reclaim: {
27
+ text: 'the summary would not have reclaimed any tokens',
28
+ },
29
+ timeout: { text: 'the summarizer timed out', action: 'retry' },
30
+ no_summarizer_model: {
31
+ text: 'no summarizer model resolves',
32
+ action: 'set a model or add a provider key',
33
+ },
34
+ no_api_key: { text: 'the summarizer provider has no API key' },
35
+ already_running: { text: 'another compaction is already running' },
36
+ invalid_prior_memory: {
37
+ text: 'the existing task memory does not match this history',
38
+ action: 'start a fresh conversation',
39
+ },
40
+ invalid_history: { text: 'this history cannot be split safely' },
41
+ protected_multimodal_tail: {
42
+ text: 'an image in the middle of this history would have to be dropped',
43
+ },
44
+ insufficient_reclaim: {
45
+ text: 'the splice freed too little to stay below the threshold',
46
+ },
47
+ disabled: { text: 'auto-compaction is off for this session', action: '/compact on' },
48
+ cancelled: { text: 'cancelled' },
49
+ aborted: { text: 'cancelled' },
50
+ stale_history: { text: 'the history changed while the summary was running' },
51
+ adoption_failed: { text: 'the compacted history could not be adopted' },
52
+ };
53
+ /**
54
+ * A phrase for a rejection code, falling back to the code itself.
55
+ *
56
+ * THE FALLBACK IS THE RAW CODE, deliberately: a new gate nobody wrote wording for
57
+ * must still say SOMETHING specific on the card, and an invented sentence would
58
+ * be worse than a code the next reviewer can grep.
59
+ */
60
+ export function describeCompactionReason(reason) {
61
+ const code = reason.split(':', 1)[0].trim();
62
+ return REASONS[code] ?? { text: reason };
63
+ }
64
+ /** The card's one-line reason, with the remedy appended when there is one. */
65
+ export function compactionReasonLine(reason) {
66
+ const { text, action } = describeCompactionReason(reason);
67
+ return action ? `${text} (${action})` : text;
68
+ }
69
+ const PHASES = {
70
+ planning: 'planning the cut',
71
+ digesting: 'building the digest',
72
+ summarizing: 'summarizing',
73
+ validating: 'validating the summary',
74
+ applying: 'applying',
75
+ };
76
+ export function compactionPhaseLabel(phase) {
77
+ return PHASES[phase];
78
+ }