@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
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `/subagent` — force the next turn to fan out into a parallel team
3
+ * (subagent-fanout 3.5 / 5.2).
4
+ *
5
+ * ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHAT THIS COMMAND IS AND IS NOT (D-8 / D-9 / D-10). It does not dispatch: it
8
+ * parses the user's intent, refuses honestly when forcing cannot work, prints
9
+ * ONE line saying what will happen next, and submits a normal user turn. The
10
+ * lead then calls `task` on the one dispatch path that already exists, so Esc,
11
+ * the watchdog pause, the report, spend accounting and the concurrency lock all
12
+ * keep working without a second implementation. The consequence the user must
13
+ * be told about is that forcing is INSTRUCTION-LEVEL: the notice says "the next
14
+ * turn dispatches them", never "N subagents started".
15
+ *
16
+ * THE GUARD ORDER IS A CONTRACT (R-P1-10 / R-P1-11): status -> capability ->
17
+ * parse. A `--no-team` session typing `/subagent -n x` is told the session has
18
+ * no team capability, because a syntax hint would send them to fix the one thing
19
+ * that is not wrong.
20
+ */
21
+ import { buildForcedDirective, parseSubagentArgs } from '../team/forced-dispatch.js';
22
+ import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
23
+ /**
24
+ * The default count when the user names none (D-11).
25
+ *
26
+ * FOUR, not `maxSubagents`: `<team_mode>` tells the model two to four is usually
27
+ * enough, so "force one dispatch" must not silently become the most expensive
28
+ * call the session can make. The status line advertises the real ceiling so the
29
+ * user who wants the widest fan-out knows which flag to add.
30
+ */
31
+ const DEFAULT_FORCED_COUNT = 4;
32
+ /** `off (--no-team)` / `off` / `on`: the three states the status line tells apart. */
33
+ function teamState(ctx) {
34
+ if (!ctx.controller.isTeamRegistered())
35
+ return 'off (--no-team)';
36
+ return ctx.controller.isTeamEnabled() ? 'on' : 'off';
37
+ }
38
+ /** The subagent model line, phrased by the same authority `/team status` uses. */
39
+ function modelLine(ctx) {
40
+ const config = ctx.controller.getConfig();
41
+ const team = ctx.controller.getTeamConfig();
42
+ const fast = config.fast;
43
+ return subagentModelSummary({
44
+ mode: team.model,
45
+ modelProfileId: team.modelProfileId,
46
+ mainConnection: `${config.provider}:${config.model}`,
47
+ fastConnection: fast.enabled && fast.model
48
+ ? `${fast.provider || config.provider}:${fast.model}` : null,
49
+ profiles: config.modelProfiles,
50
+ hasKey: (id) => ctx.controller.hasApiKey(id),
51
+ });
52
+ }
53
+ /**
54
+ * The no-argument answer: five lines, honest in every session state.
55
+ *
56
+ * IT RUNS BEFORE EVERY GUARD (R-P1-11). "How wide can I go?" is a question, not
57
+ * a request, and a question deserves an answer even in a `--no-team` session -
58
+ * which is exactly where the user is most likely to ask it.
59
+ */
60
+ function reportStatus(ctx) {
61
+ const team = ctx.controller.getTeamConfig();
62
+ const max = team.maxSubagents;
63
+ // The bracket's bounds are the LIVE width, never a literal: at capacity 2 this
64
+ // line has to read `<2-2>`, or the status line teaches a flag that arrives
65
+ // narrowed every time.
66
+ const low = Math.min(2, max);
67
+ const lines = [
68
+ `Forced fan-out: /subagent [-n <${low}-${max}>] [--parts a,b,c] [--read-only] <brief>`,
69
+ `Capacity: ${max} per dispatch, ${team.maxConcurrent} at once `
70
+ + '(team.maxSubagents / team.maxConcurrent)',
71
+ `Subagent model: ${modelLine(ctx)}`,
72
+ `Supervisor: ${team.overseer ? 'on' : 'off'}. Team mode: ${teamState(ctx)}.`,
73
+ ];
74
+ // Disclosed, never refused (R-P2-6): plan mode may remove `task` from this
75
+ // turn's tool array, and a user who is told so will not read the silence as a
76
+ // broken command.
77
+ if (ctx.controller.getAgentMode() === 'plan') {
78
+ lines.push('Plan mode is on: the task tool may be unavailable this turn.');
79
+ }
80
+ ctx.notify('info', lines.join('\n'));
81
+ }
82
+ /**
83
+ * Guard 5's sentence. The provider is named because "no API key" alone leaves
84
+ * the user guessing which of their configured providers is the empty one.
85
+ */
86
+ function reportMissingKey(ctx, team) {
87
+ const config = ctx.controller.getConfig();
88
+ const resolution = ctx.controller.subagentModelResolution();
89
+ const provider = resolution.effective === 'main'
90
+ ? config.provider : resolution.ref.providerId;
91
+ const note = team.model === 'fast' && !ctx.controller.hasApiKey(undefined, 'fast')
92
+ ? ' Fast tier has no key either; children will fail and the report will say so.'
93
+ : '';
94
+ ctx.notify('warn', `No API key for ${provider}. Set one with /settings or an environment variable.${note}`);
95
+ }
96
+ /** The five capability refusals, in the fixed order 3.5.3 records. */
97
+ function refused(ctx, team) {
98
+ if (!ctx.controller.isTeamRegistered()) {
99
+ ctx.notify('warn', 'Team mode is off for this session (started with --no-team). '
100
+ + 'Restart without --no-team to use /subagent.');
101
+ return true;
102
+ }
103
+ if (!ctx.controller.isTeamEnabled()) {
104
+ ctx.notify('warn', 'Team mode is off. Run /team on, then /subagent again.');
105
+ return true;
106
+ }
107
+ // The team lock and the lead's turn are separate refusals for one reason: both
108
+ // would collide with `task`'s own BUSY_REFUSAL, and the user would see a failed
109
+ // tool card instead of a sentence explaining why nothing ran.
110
+ if (ctx.controller.isTeamBusy()) {
111
+ ctx.notify('warn', 'A team dispatch is already running. '
112
+ + 'Wait for its report, or press Esc to stop it.');
113
+ return true;
114
+ }
115
+ if (ctx.controller.isRunning()) {
116
+ ctx.notify('warn', 'A turn is in progress. Press Esc to stop it, then run /subagent again.');
117
+ return true;
118
+ }
119
+ if (!ctx.controller.hasApiKey()) {
120
+ reportMissingKey(ctx, team);
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+ /** The four parse failures, each with the one sentence that fixes it. */
126
+ function reportParseError(ctx, parsed) {
127
+ if (parsed.error === 'count') {
128
+ ctx.notify('warn', '/subagent: -n needs a number of 1 or more, e.g. -n 4.');
129
+ return;
130
+ }
131
+ if (parsed.error === 'conflict') {
132
+ ctx.notify('warn', '/subagent: use -n or --parts, not both.');
133
+ return;
134
+ }
135
+ if (parsed.error === 'flag') {
136
+ ctx.notify('warn', `/subagent: unknown option "${parsed.token ?? ''}". Try /subagent for usage.`);
137
+ return;
138
+ }
139
+ ctx.notify('warn', '/subagent <brief> - describe the work; add -n <count> to fix the fan-out.');
140
+ }
141
+ /**
142
+ * Narrow the parsed intent against the LIVE width.
143
+ *
144
+ * TWO CEILINGS, IN THIS ORDER (R-P1-3): the structural `forcedPartsMax` bounds
145
+ * what one argument may carry (the parser already applied it), and the policy
146
+ * `maxSubagents` bounds what this session may dispatch - which only this layer
147
+ * can apply, because only it can read the live config.
148
+ */
149
+ function planForced(args, team) {
150
+ const max = team.maxSubagents;
151
+ if (args.parts.length > 0) {
152
+ // A PARTS LIST STARTS WITH A CHILD PER SURVIVING PART, and `requested` is the
153
+ // number the user TYPED - not the structural 8 the parser already applied.
154
+ // Quoting the post-truncation count made the old notice tell a user who asked
155
+ // for eleven parts that they had asked for eight (R-4: the notice and the
156
+ // directive must both name the user's number).
157
+ const parts = args.parts.slice(0, max);
158
+ return {
159
+ brief: args.brief,
160
+ parts,
161
+ partsAdmitted: args.parts.length,
162
+ partsRequested: args.partsRequested,
163
+ requested: args.partsRequested,
164
+ count: parts.length,
165
+ readOnly: args.readOnly,
166
+ };
167
+ }
168
+ const requested = args.count ?? Math.min(DEFAULT_FORCED_COUNT, max);
169
+ return {
170
+ brief: args.brief,
171
+ parts: [],
172
+ partsAdmitted: 0,
173
+ partsRequested: 0,
174
+ requested,
175
+ count: Math.max(1, Math.min(requested, max)),
176
+ readOnly: args.readOnly,
177
+ };
178
+ }
179
+ /** The one line that says what the NEXT turn will do - a plan, not a result. */
180
+ function planNotice(planned, team) {
181
+ const noun = planned.count === 1 ? 'subagent' : 'subagents';
182
+ const head = `Forcing a fan-out of ${planned.count} ${noun}`;
183
+ const followUp = `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
184
+ + 'Esc stops the turn.';
185
+ // TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may
186
+ // carry and `team.maxSubagents` bounds what may be dispatched; naming the wrong
187
+ // one sends the user to change a number that is not the reason.
188
+ if (planned.parts.length > 0) {
189
+ if (planned.parts.length < planned.partsAdmitted) {
190
+ return `${head} (asked for ${planned.partsRequested} parts; `
191
+ + `team.maxSubagents is ${team.maxSubagents}).`;
192
+ }
193
+ if (planned.partsAdmitted < planned.partsRequested) {
194
+ return `${head} (asked for ${planned.partsRequested} parts, `
195
+ + `the first ${planned.parts.length} are used).`;
196
+ }
197
+ return followUp;
198
+ }
199
+ if (planned.requested > planned.count) {
200
+ return `${head} (asked for ${planned.requested}; `
201
+ + `team.maxSubagents is ${team.maxSubagents}).`;
202
+ }
203
+ return followUp;
204
+ }
205
+ /** The command body; `registerSubagentCommand` is the only registration site. */
206
+ export function runSubagentCommand(ctx) {
207
+ if (ctx.args.trim().length === 0) {
208
+ reportStatus(ctx);
209
+ return;
210
+ }
211
+ const team = ctx.controller.getTeamConfig();
212
+ if (refused(ctx, team))
213
+ return;
214
+ const parsed = parseSubagentArgs(ctx.args);
215
+ if (!parsed.ok) {
216
+ reportParseError(ctx, parsed);
217
+ return;
218
+ }
219
+ const planned = planForced(parsed.args, team);
220
+ const directive = buildForcedDirective({
221
+ brief: planned.brief,
222
+ count: planned.count,
223
+ readOnly: planned.readOnly,
224
+ parts: planned.parts,
225
+ maxSubagents: team.maxSubagents,
226
+ clamped: planned.requested > planned.count,
227
+ requested: planned.requested,
228
+ planMode: ctx.controller.getAgentMode() === 'plan',
229
+ });
230
+ ctx.notify('info', planNotice(planned, team));
231
+ // `userInitiated: false`: a directive the user did not type belongs in neither
232
+ // the prompt history nor the hint-fade counter (todo-plan-followthrough C-12).
233
+ ctx.submit(directive, { userInitiated: false });
234
+ }
235
+ /** Registered from `builtins.ts` with one line, next to every other subsystem. */
236
+ export function registerSubagentCommand(registry) {
237
+ registry.register({
238
+ name: 'subagent',
239
+ description: 'Force the next turn to fan out into a parallel team',
240
+ run: (ctx) => { runSubagentCommand(ctx); },
241
+ });
242
+ }
@@ -16,6 +16,7 @@ import { parseThresholdInput } from '../config/schema.js';
16
16
  import { formatCost, formatTokens } from '../agent/usage.js';
17
17
  import { computeCost } from '../agent/usage.js';
18
18
  import { COMPACTION_LIMITS } from './limits.js';
19
+ import { compactionReasonLine } from './reason-text.js';
19
20
  import { findArchive, listArchives } from './archive.js';
20
21
  /**
21
22
  * `/compact status` — THE GUARANTEED REPORTING SURFACE (§6.5 / AC-17).
@@ -46,7 +47,7 @@ export function formatCompactionStatus(ctx) {
46
47
  return lines.join('\n');
47
48
  }
48
49
  const state = snapshot.selfDisabled
49
- ? `self-disabled (${snapshot.selfDisabledReason ?? 'no progress'})`
50
+ ? `self-disabled (${compactionReasonLine(snapshot.selfDisabledReason ?? 'no progress')})`
50
51
  : controller.isCompactionEnabled()
51
52
  ? snapshot.live
52
53
  ? 'on'
@@ -61,7 +62,13 @@ export function formatCompactionStatus(ctx) {
61
62
  `[${p.source === 'usage' ? 'measured' : 'estimated'}]`);
62
63
  lines.push(` Automatic trigger: occupancy >= ${(config.threshold * 100).toFixed(2)}%; ` +
63
64
  `manual: /compact (${Math.ceil(p.contextWindow * config.threshold)} tokens)`);
65
+ // TWO LINES, BECAUSE THE TAIL GUARANTEE IS NOW CONDITIONAL (never-fires B).
66
+ // `keepRecentTurns` stays the promise; the second line is what happens when a run
67
+ // has too few user turns for that promise to be satisfiable, and saying it here
68
+ // is what makes the card's `kept M of N` readable rather than surprising.
64
69
  lines.push(` Keeps: the original task + ${config.keepRecentTurns} recent turns, verbatim`);
70
+ lines.push(` Relaxes to: the last ${COMPACTION_LIMITS.minRetainedMessages} messages ` +
71
+ 'when a run has too few user turns for that');
65
72
  lines.push(` Summarizer: ${snapshot.model || '(none)'}` +
66
73
  (config.useFastTier ? ' (fast tier preferred)' : ' (fast tier not used)'));
67
74
  lines.push(' On summarize failure: ' + (config.onFailure === 'truncate'
@@ -79,8 +86,14 @@ export function formatCompactionStatus(ctx) {
79
86
  if (declined > 0) {
80
87
  sessionNotes.push(`${declined} ${declined === 1 ? 'checkpoint' : 'checkpoints'} declined`);
81
88
  }
82
- lines.push(` This session: ${snapshot.compactions} ` +
83
- `${snapshot.compactions === 1 ? 'compaction' : 'compactions'}` +
89
+ // ATTEMPTS, THEN APPLIED (never-fires C). `compactions` counts every card the
90
+ // user saw, which now includes the ones that declined, so reporting it alone
91
+ // would overstate how much the feature actually achieved.
92
+ const attempts = snapshot.applied === undefined
93
+ ? `${snapshot.compactions} ${snapshot.compactions === 1 ? 'compaction' : 'compactions'}`
94
+ : `${snapshot.compactions} ${snapshot.compactions === 1 ? 'attempt' : 'attempts'}` +
95
+ ` (${snapshot.applied} applied)`;
96
+ lines.push(` This session: ${attempts}` +
84
97
  (sessionNotes.length > 0 ? ` (${sessionNotes.join(', ')})` : '') +
85
98
  `, ${formatTokens(snapshot.tokensReclaimed)} tokens reclaimed`);
86
99
  const cost = snapshot.pricingUnknown
@@ -93,6 +93,7 @@ export declare class Compactor implements ContextManager {
93
93
  private compactionsThisRun;
94
94
  private lastCompactionTurn;
95
95
  private compactions;
96
+ private appliedCompactions;
96
97
  private declined;
97
98
  private tokensReclaimed;
98
99
  private sessionUsage;
@@ -132,8 +133,31 @@ export declare class Compactor implements ContextManager {
132
133
  private callOnce;
133
134
  private checkCandidate;
134
135
  private isCurrent;
136
+ /**
137
+ * Open the card for THIS attempt (C).
138
+ *
139
+ * CALLED FROM `compact()`'S ENTRY, so the two pre-model rejections are visible
140
+ * too. The old position - inside `callOnce` - made an attempt that never called
141
+ * a model indistinguishable from a checkpoint that never fired, which is exactly
142
+ * how this feature spent 96 attempts without a single user-visible symptom.
143
+ *
144
+ * `state.index !== undefined` keeps it idempotent: `callOnce` no longer calls it,
145
+ * but the guard stays so a future re-announce cannot open a second card for one
146
+ * attempt.
147
+ */
135
148
  private announce;
149
+ /** Publish the current phase, for the live card only. */
150
+ private phase;
136
151
  private keep;
152
+ /**
153
+ * The relaxation disclosure, as record fields.
154
+ *
155
+ * ONE PLACE BUILDS IT, so the card, the reducer and the diagnostic log cannot
156
+ * describe one relaxed cut three different ways. Absent entirely when the first
157
+ * planning pass found a cut, which is what keeps a normal compaction's record
158
+ * byte-identical to the round before this change.
159
+ */
160
+ private relaxedFields;
137
161
  private finishRecord;
138
162
  /** Commit only the engine/host verdict; candidates have no authority before this point. */
139
163
  settleOperation(verdict: {
@@ -152,6 +176,7 @@ export declare class Compactor implements ContextManager {
152
176
  sessionTotals(): {
153
177
  selfDisabledReason?: string | undefined;
154
178
  compactions: number;
179
+ applied: number;
155
180
  declined: number;
156
181
  tokensReclaimed: number;
157
182
  usage: TokenUsage;
@@ -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;