@aragon-agent/cli 0.6.39 → 0.6.40

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/CHANGELOG.md +2589 -2523
  2. package/README.md +3706 -3614
  3. package/dist/agent/controller.d.ts +103 -9
  4. package/dist/agent/controller.js +196 -19
  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 +14 -2
  13. package/dist/agent/system-prompt.js +7 -4
  14. package/dist/cli.js +21 -5
  15. package/dist/commands/builtins.js +12 -3
  16. package/dist/commands/skills.js +86 -1
  17. package/dist/commands/subagent-command.d.ts +25 -0
  18. package/dist/commands/subagent-command.js +242 -0
  19. package/dist/compaction/command.js +16 -3
  20. package/dist/compaction/compactor.d.ts +25 -0
  21. package/dist/compaction/compactor.js +111 -10
  22. package/dist/compaction/digest.d.ts +24 -1
  23. package/dist/compaction/digest.js +139 -17
  24. package/dist/compaction/limits.d.ts +21 -0
  25. package/dist/compaction/limits.js +21 -0
  26. package/dist/compaction/reason-text.d.ts +36 -0
  27. package/dist/compaction/reason-text.js +78 -0
  28. package/dist/compaction/types.d.ts +82 -14
  29. package/dist/compaction/wiring.d.ts +13 -8
  30. package/dist/compaction/wiring.js +1 -0
  31. package/dist/config/load.js +50 -3
  32. package/dist/config/schema.d.ts +82 -6
  33. package/dist/config/schema.js +107 -9
  34. package/dist/config/store.js +7 -2
  35. package/dist/proc/log-mirror.d.ts +21 -4
  36. package/dist/proc/log-mirror.js +29 -7
  37. package/dist/skills/service.d.ts +41 -0
  38. package/dist/skills/service.js +62 -0
  39. package/dist/skills/triage.d.ts +194 -0
  40. package/dist/skills/triage.js +393 -0
  41. package/dist/team/forced-dispatch.d.ts +95 -0
  42. package/dist/team/forced-dispatch.js +208 -0
  43. package/dist/team/limits.d.ts +21 -1
  44. package/dist/team/limits.js +21 -1
  45. package/dist/team/normalize.d.ts +15 -0
  46. package/dist/team/normalize.js +21 -4
  47. package/dist/team/prompt.d.ts +6 -3
  48. package/dist/team/prompt.js +9 -5
  49. package/dist/team/task-tool.js +11 -7
  50. package/dist/tools/human-input.d.ts +35 -2
  51. package/dist/tools/human-input.js +38 -0
  52. package/dist/tools/plan-tools.d.ts +20 -12
  53. package/dist/tools/plan-tools.js +24 -17
  54. package/dist/ui/App.js +14 -1
  55. package/dist/ui/StatusBar.d.ts +1 -0
  56. package/dist/ui/StatusBar.js +1 -0
  57. package/dist/ui/Transcript.js +1 -1
  58. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  59. package/dist/ui/entries/CompactionCard.js +65 -4
  60. package/dist/ui/layout/status-layout.d.ts +10 -0
  61. package/dist/ui/layout/status-layout.js +18 -1
  62. package/dist/ui/overlays/SettingsScreen.js +29 -5
  63. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  64. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  65. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  66. package/dist/ui/overlays/model-profile-state.js +12 -0
  67. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  68. package/dist/ui/overlays/team-fanout-state.js +97 -0
  69. package/package.json +2 -2
@@ -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
+ }
@@ -55,6 +55,23 @@ export type CompactionMode = 'summarized' | 'truncated' | 'relieved' | 'none';
55
55
  * thing a CLI-local type is for.
56
56
  */
57
57
  export type CompactionUiTrigger = 'pressure' | 'overflow' | 'manual';
58
+ /**
59
+ * Where one attempt currently is, so "compacting context" can also say WHAT it is
60
+ * doing for the forty-five seconds it is on screen.
61
+ *
62
+ * IT EXISTS BECAUSE THE GATES WERE INVISIBLE (context-compaction-never-fires
63
+ * RC-3). Planning and digesting both run BEFORE any model call, and both used to
64
+ * be able to reject while the only surface said `Context 95%` and nothing else.
65
+ * A phase is the CLI's answer to "is this stuck, or merely slow", and it is
66
+ * CLI-LOCAL for the reason the whole stream is: the engine has no phases, only a
67
+ * splice.
68
+ *
69
+ * THERE IS NO `settled` MEMBER, deliberately: the settle is already the
70
+ * `compaction_end` record. A phase restating it would give the card two ways to
71
+ * learn one fact, and the extra phase would race the record whenever the engine's
72
+ * verdict comes back first.
73
+ */
74
+ export type CompactionPhase = 'planning' | 'digesting' | 'summarizing' | 'validating' | 'applying';
58
75
  /** Whether a figure was measured by the provider or estimated by us. */
59
76
  export type PressureSource = 'usage' | 'estimate';
60
77
  /** The exact occupancy evidence at the compaction checkpoint. */
@@ -183,6 +200,36 @@ export interface CompactionRecord {
183
200
  usage?: TokenUsage;
184
201
  /** Set when an overflow attempt halved `keepRecentTurns` for itself (D-24). */
185
202
  keepRecentTurnsUsed?: number;
203
+ /**
204
+ * Set when `planCompaction` had to relax `keepRecentTurns` to find a cut
205
+ * (context-compaction-never-fires B).
206
+ *
207
+ * DISCLOSED ON THE CARD, never silent: the relaxation trades "the last N turns
208
+ * are verbatim" for "compaction happens at all", and a user who is not told
209
+ * which side they got cannot tell a lossless compaction from a lossy one.
210
+ * `retainedTurns` and `retainedMessages` are what actually survived, so the card
211
+ * can state all three numbers instead of implying one.
212
+ */
213
+ keepRelaxed?: boolean;
214
+ retainedTurns?: number;
215
+ /**
216
+ * How many MESSAGES the relaxed cut left verbatim.
217
+ *
218
+ * `retainedTurns` COUNTS USER TURNS, and in the sessions this fix exists for - one
219
+ * prompt followed by a hundred tool calls - that number is zero even though forty
220
+ * messages survived word for word. A card reading "kept 0 recent turns" at that
221
+ * moment says the opposite of what happened, so both numbers are recorded.
222
+ */
223
+ retainedMessages?: number;
224
+ /**
225
+ * Messages left OUT of the digest text and therefore not quotable by the
226
+ * summarizer (context-compaction-never-fires A1).
227
+ *
228
+ * The elision is LOUD in three places: the digest text itself carries an
229
+ * `[elided ...]` marker, this field puts the count on the card, and the elided
230
+ * ids never enter the source map, so a delta citing one is rejected.
231
+ */
232
+ elidedMessages?: number;
186
233
  /**
187
234
  * Present ONLY when tail relief fired (hardening §3.3.4 / W2).
188
235
  *
@@ -221,14 +268,24 @@ export interface CompactionSnapshot {
221
268
  /** The summarizer's model id, or `''`. */
222
269
  model: string;
223
270
  /**
224
- * How many compactions ANNOUNCED themselves (quiet-noop §6.2 / D-5).
271
+ * How many compactions ANNOUNCED themselves, i.e. how many cards the user saw.
225
272
  *
226
- * COMPACTIONS THAT RAN, NOT CHECKPOINTS THAT FIRED. A `pressure` compaction
227
- * that declines before calling a model or changing the history never advances
228
- * this, which is what keeps the card's `#N` contiguous. `declined` below is
229
- * the other half of the same total.
273
+ * IT COUNTS ATTEMPTS, NOT SPLICES (context-compaction-never-fires C). Before
274
+ * this change only attempts that committed to work announced, so this was
275
+ * "compactions that ran"; now every attempt opens a card - including the ones
276
+ * that decline at the planner or the digest - because a silent decline is
277
+ * indistinguishable from a dead feature, which is the bug this feature shipped
278
+ * with. `applied` below is the other half, and `/compact status` prints both.
230
279
  */
231
280
  compactions: number;
281
+ /**
282
+ * How many of those attempts actually replaced the history.
283
+ *
284
+ * OPTIONAL SO EVERY EXISTING CONSTRUCTION SITE STAYS UNTOUCHED. Absent means
285
+ * "not reported", which `/compact status` renders exactly as the pre-change
286
+ * wording did.
287
+ */
288
+ applied?: number;
232
289
  /**
233
290
  * How many `pressure` checkpoints declined BEFORE committing to any work
234
291
  * (quiet-noop D-9 / P1-2).
@@ -260,19 +317,26 @@ export interface CompactionSnapshot {
260
317
  /**
261
318
  * THE COMPACTION EVENT STREAM.
262
319
  *
263
- * `compaction_start` and `compaction_end` are emitted ONLY for compactions that
264
- * COMMITTED TO WORK (quiet-noop §3.1): a `pressure` compaction that declines
265
- * before calling a model or changing the history emits NEITHER, and is
266
- * deliberately indistinguishable from a turn at which `shouldCompact` returned
267
- * false. `manual` and `overflow` always emit both.
320
+ * EVERY ATTEMPT ANNOUNCES ITSELF (context-compaction-never-fires C, which
321
+ * REVERSES quiet-noop §3.1). A `pressure` compaction used to reach
322
+ * `compaction_start` only after it committed to paid work, so an attempt that
323
+ * declined at the planner (`nothing_to_drop`) or at the digest budget produced
324
+ * no card at all - and a user sitting at 95 % with a dead auto-compaction had no
325
+ * way to learn that the feature was trying and failing 96 times. The card is now
326
+ * opened at the entry of `compact()` and ALWAYS settled, so `#N` counts attempts
327
+ * and the transcript carries one line per attempt, applied or not.
328
+ *
329
+ * WHY THE OLD DESIGN WAS DEFENSIBLE AND IS STILL WRONG. Its stated worry was a
330
+ * red `context not compacted` card every two turns flooding the transcript. The
331
+ * guards bound that to `stuckLimit` declines per run (`limits.ts`), and after
332
+ * them the auto path self-disables and says so once - so the flood never
333
+ * happens, while the silence happened every time.
268
334
  *
269
335
  * The pair is still BALANCED: every `compaction_start` is followed by exactly
270
336
  * one `compaction_end` (a `finally` in the engine guarantees the verdict, and
271
337
  * `settlePending` synthesizes a skeleton record when the compactor's own has not
272
- * arrived). Consumers that count PAIRS are unaffected; consumers that counted
273
- * ATTEMPTS must read `/compact status` or the JSONL diagnostic log instead -
274
- * CORE's own `compaction_start` / `compaction_end` pair still fires for every
275
- * attempt, declined or not, and that is the stream the log subscribes to.
338
+ * arrived). `phase` events land BETWEEN them, in order, and only for the live
339
+ * card.
276
340
  */
277
341
  export type CompactionEvent = {
278
342
  type: 'compaction_start';
@@ -282,6 +346,10 @@ export type CompactionEvent = {
282
346
  } | {
283
347
  type: 'compaction_end';
284
348
  record: CompactionRecord;
349
+ } | {
350
+ type: 'phase';
351
+ index: number;
352
+ phase: CompactionPhase;
285
353
  } | ({
286
354
  type: 'usage';
287
355
  usage: TokenUsage;
@@ -27,14 +27,19 @@ import type { CompactionIdentity } from './memory-identity.js';
27
27
  * `/compact off`, `/compact threshold 0.8` and a settings-screen save take effect
28
28
  * mid-session without touching the `Agent`.
29
29
  *
30
- * DECLINED COMPACTIONS PRODUCE NO CLI EVENTS (quiet-noop §3.6). A `pressure`
31
- * compaction that changed nothing, called nothing and spent nothing never emits
32
- * `compaction_start`, so `onCompactorEvent` never opens a card and `settlePending`
33
- * finds neither a held record nor a skeleton to synthesize. CORE still emits its
34
- * own pair for every attempt, which is why `settlePending` still runs - it just
35
- * refreshes the snapshot and returns. `onCompactorEvent`'s `if (!this.open)`
36
- * guard is now unreachable for declines and stays for the abort race it was
37
- * written for.
30
+ * DECLINED COMPACTIONS ARE VISIBLE (context-compaction-never-fires C, which
31
+ * REVERSES quiet-noop §3.6). A `pressure` compaction that changes nothing used to
32
+ * emit no `compaction_start`, so `onCompactorEvent` never opened a card and the
33
+ * only trace of an attempt was a counter in `/compact status`. The compactor now
34
+ * announces at the entry of `compact()` and always settles, so this class holds a
35
+ * record for every attempt and `settlePending` emits it - declined attempts
36
+ * included, with the reason on the card.
37
+ *
38
+ * WHAT THE OLD RULE PROTECTED IS STILL PROTECTED, by a different mechanism: the
39
+ * transcript cannot flood, because `maxPerRun` (5) and `stuckLimit` (2) bound the
40
+ * attempts and the auto path self-disables and says so once. `onCompactorEvent`'s
41
+ * `if (!this.open)` guard is still load-bearing: it is what stops a late record
42
+ * from re-opening a card the engine's verdict already closed.
38
43
  */
39
44
  import type { ModelRole } from '../config/model-profiles.js';
40
45
  import { type AgentEvent, type ContextManager, type Message, type ModelInfo, type ModelRef, type ProviderRegistry, type ProviderRegistryOptions, type TokenUsage } from '@aragon-agent/core';
@@ -368,6 +368,7 @@ export class CompactionWiring {
368
368
  live: this.enabled && summarizer !== null,
369
369
  model: summarizer?.ref.modelId ?? '',
370
370
  compactions: totals.compactions,
371
+ applied: totals.applied,
371
372
  // OMITTED WHEN ZERO (quiet-noop D-9), so a healthy session's snapshot is
372
373
  // shaped exactly as it was before this feature and `/compact status` has
373
374
  // nothing extra to render.
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import process from 'node:process';
9
9
  import { JOB_WAIT_MARGIN_MS } from '../proc/limits.js';
10
- import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
10
+ import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, legacyTeamFanoutPatch, LEGACY_TEAM_FANOUT_DEFAULTS, parseThresholdInput, } from './schema.js';
11
11
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
12
12
  import { readConfigFile, updatePersistedConfig } from './store.js';
13
13
  import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey, } from './model-profile-resolution.js';
@@ -517,6 +517,49 @@ function migrateLegacyTeamCeilings(raw) {
517
517
  keys: Object.keys(patch).join(','),
518
518
  });
519
519
  }
520
+ /**
521
+ * Rewrite the cli<=0.6.x persisted fan-out defaults (5 / 3) to the new ones
522
+ * (8 / 8) and persist the change (subagent-fanout D-3 / D-4).
523
+ *
524
+ * ONE-SHOT and IDEMPOTENT, and the FILE LAYER ONLY, for the reasons
525
+ * `migrateLegacyTeamCeilings` records one block above: env (`ARAGON_TEAM_MAX`)
526
+ * and `--team-max` are THIS launch's intent and are never rewritten, the raw
527
+ * file object is patched IN PLACE so the `file` layer resolved below already
528
+ * reads 8/8, and a failed write leaves the marker absent so the next launch
529
+ * retries the whole migration.
530
+ *
531
+ * WHY A MARKER AND NOT JUST THE NUMBERS: `fanoutMigrated` is what keeps a user
532
+ * who deliberately returns to 5/3 from being migrated again on every launch
533
+ * (`legacyTeamFanoutPatch` returns `null` once it is set). The price of
534
+ * exact-equality is that one deliberate 5/3 is widened once; the marker bounds
535
+ * that to at most once.
536
+ */
537
+ function migrateLegacyTeamFanout(raw) {
538
+ const patch = legacyTeamFanoutPatch(raw?.team);
539
+ if (patch === null)
540
+ return;
541
+ // READ THE STARTING POINT BEFORE THE IN-PLACE PATCH, and never hardcode
542
+ // `5/3`: rule 4 of the matcher accepts an ABSENT `maxConcurrent`, in which
543
+ // case the honest starting point is "unset" - a log line the user cannot find
544
+ // on disk is worse than no log line.
545
+ const from = `${LEGACY_TEAM_FANOUT_DEFAULTS.maxSubagents}/${raw?.team?.maxConcurrent ?? 'unset'}`;
546
+ // THE FULL SECTION, not the bare patch: the write port types `team` as a
547
+ // whole `TeamConfig`, and the section the file already carries plus the three
548
+ // keys IS the section we want on disk.
549
+ const migratedTeam = { ...raw.team, ...patch };
550
+ Object.assign(raw.team, patch);
551
+ try {
552
+ updatePersistedConfig({ team: migratedTeam });
553
+ }
554
+ catch {
555
+ // Unwritable config dir: in-memory values are already migrated; retry next
556
+ // launch (idempotent, because the marker never made it to disk).
557
+ }
558
+ getLogger().warn('config', 'team_fanout_migrated', {
559
+ from,
560
+ to: `${patch.maxSubagents}/${patch.maxConcurrent}`,
561
+ });
562
+ }
520
563
  /**
521
564
  * Resolve the effective config by merging all layers, then derive the runtime
522
565
  * (non-persisted) fields and enforce the timeout invariant.
@@ -527,6 +570,12 @@ export function loadConfig(flags = {}, options = {}) {
527
570
  loadDotenv(cwd);
528
571
  const read = readConfigFile();
529
572
  migrateLegacyTeamCeilings(read.config);
573
+ // THE NEXT LINE, not a shared pass: the two migrations are independent, and
574
+ // keeping them as two statements keeps each one's "returns null when nothing
575
+ // matches" contract visible. Both run BEFORE `projectModelProfiles` for the
576
+ // same reason - the `file` layer resolved below must already read the migrated
577
+ // numbers, and neither migration needs a second projection path.
578
+ migrateLegacyTeamFanout(read.config);
530
579
  const projection = projectModelProfiles(read.config ?? {});
531
580
  const file = projection.file;
532
581
  const env = readEnvConfig();
@@ -570,7 +619,6 @@ export function loadConfig(flags = {}, options = {}) {
570
619
  // a config file that turned plan mode on.
571
620
  const startInPlanMode = pick(flags.plan, env.partial.planModeDefault, file.planModeDefault) ??
572
621
  DEFAULT_CONFIG.planModeDefault;
573
- const planModeMaxAskRounds = clampAskRounds(file.planModeMaxAskRounds, DEFAULT_CONFIG.planModeMaxAskRounds);
574
622
  const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
575
623
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
576
624
  // THE `jobs` SECTION IS RESOLVED HERE, ABOVE THE TIMEOUT BLOCK, and the
@@ -706,7 +754,6 @@ export function loadConfig(flags = {}, options = {}) {
706
754
  // From `<home>/state.json`, not the config file — see `ui-state.ts`.
707
755
  submitCount: readSubmitCount(),
708
756
  startInPlanMode,
709
- planModeMaxAskRounds,
710
757
  planModeHumanTimeoutMs,
711
758
  skills,
712
759
  skillsRuntime: {