@aragon-agent/cli 0.6.2 → 0.6.3

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 (118) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +202 -0
  3. package/dist/agent/controller.d.ts +74 -1
  4. package/dist/agent/controller.js +200 -1
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +120 -0
  8. package/dist/agent/reducer.js +201 -2
  9. package/dist/cli.js +224 -5
  10. package/dist/commands/builtins.js +73 -23
  11. package/dist/commands/registry.d.ts +27 -0
  12. package/dist/compaction/archive.d.ts +175 -0
  13. package/dist/compaction/archive.js +267 -0
  14. package/dist/compaction/child.d.ts +97 -0
  15. package/dist/compaction/child.js +113 -0
  16. package/dist/compaction/command.d.ts +41 -0
  17. package/dist/compaction/command.js +342 -0
  18. package/dist/compaction/compactor.d.ts +268 -0
  19. package/dist/compaction/compactor.js +689 -0
  20. package/dist/compaction/digest.d.ts +62 -0
  21. package/dist/compaction/digest.js +164 -0
  22. package/dist/compaction/limits.d.ts +165 -0
  23. package/dist/compaction/limits.js +172 -0
  24. package/dist/compaction/pressure.d.ts +135 -0
  25. package/dist/compaction/pressure.js +167 -0
  26. package/dist/compaction/summarize-call.d.ts +70 -0
  27. package/dist/compaction/summarize-call.js +86 -0
  28. package/dist/compaction/summary-prompt.d.ts +85 -0
  29. package/dist/compaction/summary-prompt.js +197 -0
  30. package/dist/compaction/tail-budget.d.ts +52 -0
  31. package/dist/compaction/tail-budget.js +67 -0
  32. package/dist/compaction/types.d.ts +179 -0
  33. package/dist/compaction/types.js +22 -0
  34. package/dist/compaction/wiring.d.ts +255 -0
  35. package/dist/compaction/wiring.js +556 -0
  36. package/dist/config/app-paths.d.ts +14 -0
  37. package/dist/config/app-paths.js +16 -0
  38. package/dist/config/cli-commands.d.ts +41 -0
  39. package/dist/config/cli-commands.js +77 -1
  40. package/dist/config/env.d.ts +4 -1
  41. package/dist/config/env.js +92 -2
  42. package/dist/config/load.d.ts +31 -0
  43. package/dist/config/load.js +63 -1
  44. package/dist/config/schema.d.ts +159 -0
  45. package/dist/config/schema.js +137 -0
  46. package/dist/config/store.js +17 -1
  47. package/dist/config/ui-state.d.ts +28 -1
  48. package/dist/config/ui-state.js +9 -0
  49. package/dist/exec/events.d.ts +50 -1
  50. package/dist/exec/runner.js +42 -0
  51. package/dist/input/mouse-events.d.ts +31 -5
  52. package/dist/input/mouse-events.js +86 -27
  53. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  54. package/dist/logging/install.d.ts +51 -1
  55. package/dist/logging/install.js +28 -0
  56. package/dist/logging/logger.d.ts +1 -1
  57. package/dist/session/persist.js +19 -0
  58. package/dist/team/report.js +7 -1
  59. package/dist/team/runtime.d.ts +10 -0
  60. package/dist/team/runtime.js +7 -0
  61. package/dist/team/subagent.d.ts +31 -1
  62. package/dist/team/subagent.js +42 -15
  63. package/dist/team/types.d.ts +8 -0
  64. package/dist/ui/ActivityLine.d.ts +19 -1
  65. package/dist/ui/ActivityLine.js +10 -2
  66. package/dist/ui/App.d.ts +48 -1
  67. package/dist/ui/App.js +294 -13
  68. package/dist/ui/Composer.d.ts +13 -1
  69. package/dist/ui/Composer.js +2 -2
  70. package/dist/ui/PromptInput.d.ts +34 -1
  71. package/dist/ui/PromptInput.js +54 -3
  72. package/dist/ui/StatusBar.d.ts +40 -0
  73. package/dist/ui/StatusBar.js +13 -4
  74. package/dist/ui/TeamPanel.js +7 -1
  75. package/dist/ui/Transcript.d.ts +21 -1
  76. package/dist/ui/Transcript.js +32 -2
  77. package/dist/ui/clipboard.d.ts +52 -0
  78. package/dist/ui/clipboard.js +91 -0
  79. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  80. package/dist/ui/entries/CompactionCard.js +158 -0
  81. package/dist/ui/frame-differ.d.ts +46 -0
  82. package/dist/ui/frame-differ.js +94 -10
  83. package/dist/ui/gauge.d.ts +16 -1
  84. package/dist/ui/gauge.js +25 -6
  85. package/dist/ui/glyphs.d.ts +11 -0
  86. package/dist/ui/glyphs.js +2 -0
  87. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  88. package/dist/ui/layout/ScrollViewport.js +128 -21
  89. package/dist/ui/layout/budget.d.ts +10 -3
  90. package/dist/ui/layout/budget.js +10 -3
  91. package/dist/ui/layout/follow-state.d.ts +137 -0
  92. package/dist/ui/layout/follow-state.js +150 -0
  93. package/dist/ui/layout/virtual-window.d.ts +11 -0
  94. package/dist/ui/layout/virtual-window.js +34 -0
  95. package/dist/ui/overlays/HelpOverlay.js +6 -1
  96. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  97. package/dist/ui/overlays/SettingsScreen.js +60 -1
  98. package/dist/ui/palettes.d.ts +16 -0
  99. package/dist/ui/palettes.js +6 -0
  100. package/dist/ui/screen.d.ts +22 -0
  101. package/dist/ui/screen.js +70 -8
  102. package/dist/ui/selection/highlight.d.ts +39 -0
  103. package/dist/ui/selection/highlight.js +64 -0
  104. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  105. package/dist/ui/selection/screen-mirror.js +35 -0
  106. package/dist/ui/selection/selection-controller.d.ts +88 -0
  107. package/dist/ui/selection/selection-controller.js +282 -0
  108. package/dist/ui/selection/selection.d.ts +90 -0
  109. package/dist/ui/selection/selection.js +223 -0
  110. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  111. package/dist/ui/stdout-frame-writer.js +15 -0
  112. package/dist/ui/theme.d.ts +11 -0
  113. package/dist/ui/theme.js +2 -0
  114. package/dist/ui/transcript-text.js +23 -0
  115. package/dist/ui/use-startup-notices.d.ts +27 -1
  116. package/dist/ui/use-startup-notices.js +43 -11
  117. package/dist/ui/use-wheel-routing.js +8 -0
  118. package/package.json +4 -2
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Per-child context compaction (context-auto-compaction-hardening §3.4 / W3).
3
+ *
4
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY THIS REVERSES D-15. That decision said children are bounded by their
7
+ * dispatch timeout and turn cap, so giving them a manager only makes a dispatch's
8
+ * cost unpredictable. The premise is measurable and false at the configured
9
+ * bounds: `team.maxTurnsPerSubagent` is 24 and `team.dispatchTimeoutMs` is
10
+ * 900 000, which is 24 turns over fifteen minutes - the same profile as the lead
11
+ * run this feature exists for. The cost argument also inverts once the
12
+ * alternative is priced: an overflow ENDS the dispatch, the lead pays for every
13
+ * turn the child already ran, and receives one partial sentence for all of it.
14
+ * Two fast-model summarizations is the cheap side of that trade.
15
+ *
16
+ * A CHILD MANAGER IS THE SAME `Compactor` UNDER A DIFFERENT POLICY, and the
17
+ * overlay needs TWO mechanisms rather than one (RV-2):
18
+ *
19
+ * A. A CONFIG VIEW, for `keepRecentTurns` / `onFailure` / `useFastTier`, which
20
+ * `Compactor` reads through `deps.getConfig()`.
21
+ * B. AN INSTANCE BOUND, for `maxPerRun`, which is STRUCTURAL and has no config
22
+ * path by design (`limits.ts` header). Without it the child silently
23
+ * inherits the lead's 5 - two and a half times the bound the reversal above
24
+ * rests on.
25
+ *
26
+ * NO UI. A child's compaction produces no transcript card (DH-7): the lead's
27
+ * transcript describes the LEAD's context, and a card there would claim the
28
+ * lead's own history had been compacted. The team panel row and the dispatch
29
+ * report are where a child's internals belong.
30
+ */
31
+ import type { AssistantMessage, ContextManager, LLMRequest, Message, ModelInfo, ModelRef, TokenUsage } from '@aragon-agent/core';
32
+ import type { CliConfig } from '../config/schema.js';
33
+ export interface ChildCompactionDeps {
34
+ /** Read LIVE on every call - see `childConfig` below. */
35
+ getConfig: () => CliConfig;
36
+ hasKey: (providerId: string) => boolean;
37
+ getApiKey: (providerId: string) => string | undefined;
38
+ getModelInfoFor: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => ModelInfo;
39
+ isPricedModel: (ref: Pick<ModelRef, 'providerId' | 'modelId'>) => boolean;
40
+ /** The lead wiring's own transport, reused (D-10 stays true). */
41
+ complete: (providerId: string, request: LLMRequest) => Promise<AssistantMessage>;
42
+ /** Child compaction spend joins the session totals through the lead's sink. */
43
+ onUsage: (usage: TokenUsage) => void;
44
+ }
45
+ /**
46
+ * Everything one child manager needs about its child.
47
+ *
48
+ * AN OPTIONS OBJECT RATHER THAN FIVE POSITIONAL PARAMETERS - the rule
49
+ * `TeamRuntime.RunOneCtx` states one package over, and this signature would sit
50
+ * exactly on the cap.
51
+ */
52
+ export interface ChildContextManagerRequest {
53
+ label: string;
54
+ /**
55
+ * The model the child actually runs on.
56
+ *
57
+ * CARRIED RATHER THAN READ FROM CONFIG, because a `tier: 'fast'` child runs on
58
+ * a model whose context window is usually SMALLER than the lead's. Measuring a
59
+ * child against the lead's window would put the trigger in the wrong place in
60
+ * the one direction that matters - late.
61
+ */
62
+ model: ModelRef;
63
+ /**
64
+ * LAZY, AND IT MUST BE. `agentRef` is assigned AFTER the factory call returns,
65
+ * so a non-lazy read here is `undefined` at construction and the child's
66
+ * estimate branch would silently measure an empty history - CR-1 reproduced one
67
+ * module over.
68
+ */
69
+ getMessages: () => readonly Message[];
70
+ getSystemPrompt: () => string;
71
+ onCompacted: () => void;
72
+ }
73
+ /**
74
+ * A child manager, plus the one hook the port does not carry.
75
+ *
76
+ * `onTurnEnd` IS NOT PART OF `ContextManager` and is called by `subagent.ts` from
77
+ * its existing `turn_end` case. Without it the child never records the history
78
+ * length its last measurement covered, so W1's delta is permanently `undefined`
79
+ * and the child reads the same systematically-short number the lead used to.
80
+ */
81
+ export interface ChildContextManager extends ContextManager {
82
+ onTurnEnd(usage: TokenUsage, messages: readonly Message[], systemPrompt: string): void;
83
+ }
84
+ export type ChildContextManagerFactory = (req: ChildContextManagerRequest) => ChildContextManager;
85
+ /**
86
+ * The shape `subagent.ts` is handed, which may DECLINE.
87
+ *
88
+ * `undefined` RATHER THAN A THROW OR A NO-OP MANAGER, and the difference is
89
+ * AC-H10: the caller spreads the result conditionally, so a session with
90
+ * `compaction.subagents: false` builds a child whose options bag has no
91
+ * `contextManager` KEY AT ALL - the loop's gate then tests a genuinely absent
92
+ * field. The decision is re-taken per child rather than per dispatch, so a
93
+ * settings-screen edit mid-dispatch reaches the next one.
94
+ */
95
+ export type ChildContextManagerProvider = (req: ChildContextManagerRequest) => ChildContextManager | undefined;
96
+ /** One manager per child. State is per-history and must not be shared. */
97
+ export declare function createChildContextManager(req: ChildContextManagerRequest, deps: ChildCompactionDeps): ChildContextManager;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Per-child context compaction (context-auto-compaction-hardening §3.4 / W3).
3
+ *
4
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY THIS REVERSES D-15. That decision said children are bounded by their
7
+ * dispatch timeout and turn cap, so giving them a manager only makes a dispatch's
8
+ * cost unpredictable. The premise is measurable and false at the configured
9
+ * bounds: `team.maxTurnsPerSubagent` is 24 and `team.dispatchTimeoutMs` is
10
+ * 900 000, which is 24 turns over fifteen minutes - the same profile as the lead
11
+ * run this feature exists for. The cost argument also inverts once the
12
+ * alternative is priced: an overflow ENDS the dispatch, the lead pays for every
13
+ * turn the child already ran, and receives one partial sentence for all of it.
14
+ * Two fast-model summarizations is the cheap side of that trade.
15
+ *
16
+ * A CHILD MANAGER IS THE SAME `Compactor` UNDER A DIFFERENT POLICY, and the
17
+ * overlay needs TWO mechanisms rather than one (RV-2):
18
+ *
19
+ * A. A CONFIG VIEW, for `keepRecentTurns` / `onFailure` / `useFastTier`, which
20
+ * `Compactor` reads through `deps.getConfig()`.
21
+ * B. AN INSTANCE BOUND, for `maxPerRun`, which is STRUCTURAL and has no config
22
+ * path by design (`limits.ts` header). Without it the child silently
23
+ * inherits the lead's 5 - two and a half times the bound the reversal above
24
+ * rests on.
25
+ *
26
+ * NO UI. A child's compaction produces no transcript card (DH-7): the lead's
27
+ * transcript describes the LEAD's context, and a card there would claim the
28
+ * lead's own history had been compacted. The team panel row and the dispatch
29
+ * report are where a child's internals belong.
30
+ */
31
+ import { getLogger } from '../logging/logger.js';
32
+ import { Compactor } from './compactor.js';
33
+ import { COMPACTION_LIMITS } from './limits.js';
34
+ /** One manager per child. State is per-history and must not be shared. */
35
+ export function createChildContextManager(req, deps) {
36
+ const log = getLogger().child('compaction');
37
+ /**
38
+ * MECHANISM A - the config view.
39
+ *
40
+ * IT RE-READS `deps.getConfig()` ON EVERY CALL and never closes over one
41
+ * snapshot: a settings-screen edit mid-dispatch has to reach a running child,
42
+ * which is the same reason `subagent.ts` reuses the lead's `getApiKey` closure
43
+ * rather than the resolved key.
44
+ *
45
+ * `useFastTier: true` EXPRESSES A PREFERENCE, NOT AN OUTCOME. `resolveFastTier`
46
+ * still decides, and when nothing resolves the child falls back exactly as the
47
+ * lead does - to its OWN model, because `provider` / `model` are overlaid here
48
+ * too.
49
+ *
50
+ * `onFailure: 'truncate'` IS FORCED because a child that stops is a failed
51
+ * dispatch and the user is not there to intervene.
52
+ */
53
+ const childConfig = () => {
54
+ const base = deps.getConfig();
55
+ return {
56
+ ...base,
57
+ provider: req.model.providerId,
58
+ model: req.model.modelId,
59
+ ...(req.model.baseUrl !== undefined ? { baseUrl: req.model.baseUrl } : {}),
60
+ compaction: {
61
+ ...base.compaction,
62
+ keepRecentTurns: COMPACTION_LIMITS.childKeepRecentTurns,
63
+ onFailure: 'truncate',
64
+ useFastTier: true,
65
+ // ONE ARCHIVE PER LEAD COMPACTION IS AUDITABLE; TWENTY PER DISPATCH IS
66
+ // NOISE. The child's dropped slice is therefore never retained either -
67
+ // `runCompaction` reads this key before it stashes anything.
68
+ archive: false,
69
+ },
70
+ };
71
+ };
72
+ const compactor = new Compactor({
73
+ getConfig: childConfig,
74
+ hasKey: deps.hasKey,
75
+ getApiKey: deps.getApiKey,
76
+ getModelInfoFor: deps.getModelInfoFor,
77
+ isPricedModel: deps.isPricedModel,
78
+ getMessages: req.getMessages,
79
+ getSystemPrompt: req.getSystemPrompt,
80
+ complete: deps.complete,
81
+ emit: (event) => {
82
+ if (event.type === 'usage') {
83
+ deps.onUsage(event.usage);
84
+ return;
85
+ }
86
+ if (event.type !== 'compaction_end')
87
+ return;
88
+ if (!event.record.applied && event.record.mode === 'none')
89
+ return;
90
+ req.onCompacted();
91
+ // W3 HAS NO CARD, so this line is the whole record of a child compaction.
92
+ log.info('compaction_child', {
93
+ label: req.label,
94
+ index: event.record.index,
95
+ tokensBefore: event.record.tokensBefore,
96
+ tokensAfter: event.record.tokensAfter,
97
+ });
98
+ },
99
+ // A CHILD HAS NO TRANSCRIPT AND NOBODY WATCHING IT. `notify` on the lead's
100
+ // channel would put a warning about a background worker's context in the
101
+ // user's transcript with no card to explain which agent it is about.
102
+ notify: () => { },
103
+ },
104
+ // MECHANISM B - the instance bound (RV-2 / DH-16).
105
+ { maxPerRun: COMPACTION_LIMITS.childMaxPerRun });
106
+ return {
107
+ // THERE IS NO `/compact` FOR A CHILD, so nothing is ever queued and the
108
+ // manual branch of `shouldCompact` is unreachable by construction.
109
+ shouldCompact: (probe) => compactor.shouldCompact(probe),
110
+ compact: (ctx) => compactor.compact(ctx),
111
+ onTurnEnd: (usage, messages, systemPrompt) => compactor.onTurnEnd(usage, messages, systemPrompt),
112
+ };
113
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The `/compact` slash command and its status report
3
+ * (context-auto-compaction §4.4).
4
+ *
5
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope, and this
6
+ * text lands in a transcript `notice` — produced outside `src/ui/**`, where no
7
+ * terminal capabilities are available, so it must be safe on a legacy `cmd.exe`.
8
+ *
9
+ * IT LIVES HERE RATHER THAN IN `builtins.ts`, AND THAT IS A BUDGET DECISION
10
+ * RATHER THAN TASTE (C-16 / P1-12). `CLAUDE.md` caps a source file at 1000 lines,
11
+ * `builtins.ts` is already past it, and `/compact` has eight forms plus the
12
+ * longest status report in the package. `builtins.ts` gets the registration and nothing
13
+ * else — the same split `controller.ts` argues for `FastWiring`, one layer out.
14
+ */
15
+ import type { CommandContext } from '../commands/registry.js';
16
+ /**
17
+ * `/compact status` — THE GUARANTEED REPORTING SURFACE (§6.5 / AC-17).
18
+ *
19
+ * The chip drops on a narrow terminal, the activity row is suppressed under an
20
+ * overlay, and the card scrolls away. This command answers every question about
21
+ * the feature's state at any width, in any mode, including after the fact.
22
+ *
23
+ * IT READS THE SAME NUMBERS THE STATUS BAR DOES, through one `occupiedTokens`
24
+ * (§3.4.1 / R-11): a user watching 78 % on the bar who is told 91 % here would
25
+ * conclude one of the two is lying, and they would be right.
26
+ *
27
+ * `unknown (no price table for <model>)` RATHER THAN A CURRENCY AMOUNT when the
28
+ * static table has never seen the summarizer (the C-11 / RV-4 lesson from the
29
+ * fast tier). `$0.00` on a feature that is spending money is the same class of
30
+ * lie as pricing a Haiku child at Sonnet rates — and it matters MORE here,
31
+ * because the DEFAULT summarizer is the session's own frontier model.
32
+ */
33
+ export declare function formatCompactionStatus(ctx: CommandContext): string;
34
+ /**
35
+ * The `/compact` handler.
36
+ *
37
+ * `/compact <free text>` is the Claude Code affordance and is therefore the
38
+ * FALL-THROUGH rather than a subcommand: anything that is not one of the seven
39
+ * verbs is instructions for the summarizer.
40
+ */
41
+ export declare function runCompactCommand(ctx: CommandContext): Promise<void>;
@@ -0,0 +1,342 @@
1
+ /**
2
+ * The `/compact` slash command and its status report
3
+ * (context-auto-compaction §4.4).
4
+ *
5
+ * ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope, and this
6
+ * text lands in a transcript `notice` — produced outside `src/ui/**`, where no
7
+ * terminal capabilities are available, so it must be safe on a legacy `cmd.exe`.
8
+ *
9
+ * IT LIVES HERE RATHER THAN IN `builtins.ts`, AND THAT IS A BUDGET DECISION
10
+ * RATHER THAN TASTE (C-16 / P1-12). `CLAUDE.md` caps a source file at 1000 lines,
11
+ * `builtins.ts` is already past it, and `/compact` has eight forms plus the
12
+ * longest status report in the package. `builtins.ts` gets the registration and nothing
13
+ * else — the same split `controller.ts` argues for `FastWiring`, one layer out.
14
+ */
15
+ import { parseThresholdInput } from '../config/schema.js';
16
+ import { formatCost, formatTokens } from '../agent/usage.js';
17
+ import { computeCost } from '../agent/usage.js';
18
+ import { COMPACTION_LIMITS } from './limits.js';
19
+ import { findArchive, listArchives } from './archive.js';
20
+ /**
21
+ * `/compact status` — THE GUARANTEED REPORTING SURFACE (§6.5 / AC-17).
22
+ *
23
+ * The chip drops on a narrow terminal, the activity row is suppressed under an
24
+ * overlay, and the card scrolls away. This command answers every question about
25
+ * the feature's state at any width, in any mode, including after the fact.
26
+ *
27
+ * IT READS THE SAME NUMBERS THE STATUS BAR DOES, through one `occupiedTokens`
28
+ * (§3.4.1 / R-11): a user watching 78 % on the bar who is told 91 % here would
29
+ * conclude one of the two is lying, and they would be right.
30
+ *
31
+ * `unknown (no price table for <model>)` RATHER THAN A CURRENCY AMOUNT when the
32
+ * static table has never seen the summarizer (the C-11 / RV-4 lesson from the
33
+ * fast tier). `$0.00` on a feature that is spending money is the same class of
34
+ * lie as pricing a Haiku child at Sonnet rates — and it matters MORE here,
35
+ * because the DEFAULT summarizer is the session's own frontier model.
36
+ */
37
+ export function formatCompactionStatus(ctx) {
38
+ const controller = ctx.controller;
39
+ const snapshot = controller.getCompactionSnapshot();
40
+ const config = controller.getCompactionConfig();
41
+ const lines = [];
42
+ if (!controller.isCompactionRegistered()) {
43
+ lines.push('Auto-compaction: not registered for this session (started with --no-compaction).');
44
+ lines.push(` Saved setting: compaction.enabled = ${config.enabled}`);
45
+ lines.push(' A full context window will end the run, as it did before this feature existed.');
46
+ return lines.join('\n');
47
+ }
48
+ const state = snapshot.selfDisabled
49
+ ? `self-disabled (${snapshot.selfDisabledReason ?? 'no progress'})`
50
+ : controller.isCompactionEnabled()
51
+ ? snapshot.live
52
+ ? 'on'
53
+ : 'on, but no summarizer model resolves'
54
+ : 'off for this session';
55
+ lines.push(`Auto-compaction: ${state}`);
56
+ const p = snapshot.pressure;
57
+ const pct = Math.round(p.ratio * 100);
58
+ const approx = p.source === 'estimate' || !p.windowKnown ? '~' : '';
59
+ lines.push(` Occupancy: ${approx}${pct}% (${formatTokens(p.occupied)} of ` +
60
+ `${formatTokens(p.contextWindow)}${p.windowKnown ? '' : ', window unknown'}) ` +
61
+ `[${p.source === 'usage' ? 'measured' : 'estimated'}]`);
62
+ lines.push(` Triggers at: ${Math.round(config.threshold * 100)}% ` +
63
+ `(amber at ${Math.round(config.warnThreshold * 100)}%), ` +
64
+ `or when headroom drops below one full response`);
65
+ lines.push(` Keeps: the original task + ${config.keepRecentTurns} recent turns, verbatim`);
66
+ lines.push(` Summarizer: ${snapshot.model || '(none)'}` +
67
+ (config.useFastTier ? ' (fast tier preferred)' : ' (fast tier not used)'));
68
+ lines.push(` On summarize failure: ${config.onFailure}`);
69
+ lines.push(` This session: ${snapshot.compactions} ` +
70
+ `${snapshot.compactions === 1 ? 'compaction' : 'compactions'}` +
71
+ (snapshot.generation > 0 ? ` (generation ${snapshot.generation})` : '') +
72
+ `, ${formatTokens(snapshot.tokensReclaimed)} tokens reclaimed`);
73
+ const cost = snapshot.pricingUnknown
74
+ ? `unknown (no price table for ${snapshot.model || 'the summarizer'})`
75
+ : formatCost(compactionCost(ctx));
76
+ lines.push(` Compaction spend: ${formatTokens(snapshot.usage.inputTokens)} in / ` +
77
+ `${formatTokens(snapshot.usage.outputTokens)} out, ${cost}`);
78
+ // TWO MORE LINES, AND ONLY WHEN THEY APPLY (hardening §4.4). A clip inside the
79
+ // retained turns is a real data loss and `/compact status` is the GUARANTEED
80
+ // reporting surface, so it has to be sayable here as well as on a card that
81
+ // may have scrolled away.
82
+ const relief = totalTailRelief(ctx);
83
+ if (relief) {
84
+ lines.push(` Tail relief ${relief.messages} tool ` +
85
+ `${relief.messages === 1 ? 'result' : 'results'} clipped ` +
86
+ `(${formatTokens(relief.charsRemoved)} chars) in ` +
87
+ `${relief.compactions} ${relief.compactions === 1 ? 'compaction' : 'compactions'}`);
88
+ }
89
+ const archives = archiveSummary(ctx);
90
+ if (archives)
91
+ lines.push(` Archive ${archives}`);
92
+ if (snapshot.inFlight)
93
+ lines.push(' A compaction is running right now.');
94
+ if (controller.isCompactionRegistered() && !controller.isCompactionEnabled()) {
95
+ lines.push(' Turn it back on with /compact on.');
96
+ }
97
+ return lines.join('\n');
98
+ }
99
+ /**
100
+ * The summarizer's own price, never the lead's.
101
+ *
102
+ * THE REF IS ASKED FOR, NOT RECONSTRUCTED. Deriving the provider from config
103
+ * (`fast.enabled ? fast.provider : provider`) is wrong exactly when
104
+ * `fast.enabled` is true and `compaction.useFastTier` is false: the summarizer is
105
+ * then the MAIN model looked up under the FAST provider, which the static table
106
+ * has never seen, so `getModelInfoFor` returns a runtime model costed at zero and
107
+ * this line reports `$0.00` for a priced model. `pricingUnknown` cannot catch it
108
+ * either — the compactor sets that flag from the ref it actually used, which IS
109
+ * priced. `getCompactionSummarizerRef()` carries the answer instead.
110
+ */
111
+ function compactionCost(ctx) {
112
+ const snapshot = ctx.controller.getCompactionSnapshot();
113
+ const ref = ctx.controller.getCompactionSummarizerRef();
114
+ if (!ref)
115
+ return 0;
116
+ return computeCost(snapshot.usage, ctx.controller.getModelInfoFor(ref).cost);
117
+ }
118
+ /**
119
+ * The session's clipped-tool-result total, or `null` when relief never fired.
120
+ *
121
+ * READ FROM THE TRANSCRIPT ENTRIES rather than from a counter on the snapshot,
122
+ * because relief is rare enough that a session-total field would be a number
123
+ * carried on every snapshot for a line almost nobody sees. The entries are
124
+ * already in view state and already carry it.
125
+ */
126
+ function totalTailRelief(ctx) {
127
+ let messages = 0;
128
+ let charsRemoved = 0;
129
+ let compactions = 0;
130
+ for (const entry of ctx.state.entries) {
131
+ if (entry.kind !== 'compaction' || !entry.tailRelief)
132
+ continue;
133
+ messages += entry.tailRelief.messages;
134
+ charsRemoved += entry.tailRelief.charsRemoved;
135
+ compactions += 1;
136
+ }
137
+ return compactions === 0 ? null : { messages, charsRemoved, compactions };
138
+ }
139
+ /** `N files in <dir>` for this run, or `null` when there are none. */
140
+ function archiveSummary(ctx) {
141
+ const runId = ctx.controller.getCompactionRunId();
142
+ if (!runId)
143
+ return null;
144
+ if (!ctx.controller.getCompactionConfig().archive)
145
+ return null;
146
+ const listing = listArchives(runId);
147
+ if (listing.entries.length === 0)
148
+ return null;
149
+ return `${listing.entries.length} ${listing.entries.length === 1 ? 'file' : 'files'} in ${listing.dir}`;
150
+ }
151
+ /** One `/compact history` row. */
152
+ function archiveRow(entry) {
153
+ const at = new Date(entry.createdAt);
154
+ const pad = (n) => String(n).padStart(2, '0');
155
+ const clock = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
156
+ return (` #${entry.index} ${clock} ${entry.trigger.padEnd(8)} ` +
157
+ `${formatTokens(entry.tokensBefore)} -> ${formatTokens(entry.tokensAfter)} ` +
158
+ `${entry.droppedCount} messages ${entry.fileName}`);
159
+ }
160
+ /**
161
+ * `/compact history` - THIS RUN's archives, newest first.
162
+ *
163
+ * THE LISTING IS `runId`-SCOPED (RV-6). `<home>/compaction` is shared by every
164
+ * `aragon` on the machine, so an unscoped listing would present a neighbouring
165
+ * session's compactions as this one's and `#2` would be ambiguous the moment two
166
+ * runs both reached their second compaction. Other runs' files are COUNTED in one
167
+ * trailing line rather than listed, because the alternative is a user concluding
168
+ * their own compactions went missing.
169
+ */
170
+ function formatCompactionHistory(ctx) {
171
+ const runId = ctx.controller.getCompactionRunId();
172
+ if (!runId) {
173
+ return 'Compaction is not registered for this session (started with --no-compaction).';
174
+ }
175
+ const listing = listArchives(runId);
176
+ if (listing.entries.length === 0) {
177
+ return ctx.controller.getCompactionConfig().archive
178
+ ? 'No compactions have been archived yet.'
179
+ : 'No compaction archives for this session. (compaction.archive is off)';
180
+ }
181
+ const lines = [
182
+ `compaction history (${listing.entries.length} ` +
183
+ `${listing.entries.length === 1 ? 'archive' : 'archives'}, ${listing.dir})`,
184
+ '',
185
+ ...listing.entries.map(archiveRow),
186
+ ];
187
+ if (listing.otherRuns > 0) {
188
+ lines.push('');
189
+ lines.push(` (${listing.otherRuns} more ${listing.otherRuns === 1 ? 'archive' : 'archives'} ` +
190
+ 'from earlier runs in this directory)');
191
+ }
192
+ return lines.join('\n');
193
+ }
194
+ /**
195
+ * `/compact show <n>` - metadata, the stored summary, and the path.
196
+ *
197
+ * `<n>` IS THE COMPACTION INDEX WITHIN THIS RUN - the `#n` `/compact history`
198
+ * printed, which is also the `index` field in the document. Those are the same
199
+ * number by construction: the listing is `runId`-scoped and `index` is unique
200
+ * within a run, so there is no second numbering to confuse it with.
201
+ *
202
+ * IT NEVER PRINTS MESSAGE BODIES (DH-9). A single archive can be megabytes and
203
+ * the transcript is not a pager; the path is the useful output.
204
+ */
205
+ function formatCompactionShow(ctx, raw) {
206
+ const runId = ctx.controller.getCompactionRunId();
207
+ if (!runId) {
208
+ return 'Compaction is not registered for this session (started with --no-compaction).';
209
+ }
210
+ const n = Number.parseInt(raw.trim(), 10);
211
+ if (!Number.isFinite(n) || n <= 0)
212
+ return 'Usage: /compact show <n> (see /compact history)';
213
+ const entry = findArchive(runId, n);
214
+ if (!entry)
215
+ return `No archive #${n}. Try /compact history.`;
216
+ const lines = [
217
+ `compaction #${entry.index} (${entry.trigger}, ${entry.mode})`,
218
+ ` When: ${new Date(entry.createdAt).toISOString()}`,
219
+ ` Tokens: ${formatTokens(entry.tokensBefore)} -> ${formatTokens(entry.tokensAfter)}`,
220
+ ` Dropped: ${entry.droppedCount} messages${entry.clipped ? ' (archive body clipped to fit)' : ''}`,
221
+ ` Model: ${entry.model || '(none)'}`,
222
+ ];
223
+ if (entry.tailRelief) {
224
+ lines.push(` Clipped: ${entry.tailRelief.messages} tool results in the retained turns ` +
225
+ `(${formatTokens(entry.tailRelief.charsRemoved)} chars)`);
226
+ }
227
+ lines.push(` File: ${entry.path}`);
228
+ if (entry.summary) {
229
+ lines.push('');
230
+ lines.push(entry.summary);
231
+ }
232
+ return lines.join('\n');
233
+ }
234
+ const USAGE = 'Usage: /compact [status | on | off | threshold <n> | keep <n> | history | show <n> | <instructions>]';
235
+ /**
236
+ * The `/compact` handler.
237
+ *
238
+ * `/compact <free text>` is the Claude Code affordance and is therefore the
239
+ * FALL-THROUGH rather than a subcommand: anything that is not one of the seven
240
+ * verbs is instructions for the summarizer.
241
+ */
242
+ export async function runCompactCommand(ctx) {
243
+ const arg = ctx.args.trim();
244
+ const [rawVerb, value] = arg.split(/\s+/, 2);
245
+ const verb = (rawVerb ?? '').toLowerCase();
246
+ const controller = ctx.controller;
247
+ if (verb === 'status') {
248
+ ctx.notify('info', formatCompactionStatus(ctx));
249
+ return;
250
+ }
251
+ if (verb === 'on' || verb === 'off') {
252
+ const enabled = verb === 'on';
253
+ const applied = controller.setCompactionConfig({ enabled });
254
+ ctx.persistConfig({ compaction: { enabled } });
255
+ if (!controller.isCompactionRegistered()) {
256
+ // The honest branch, and the whole point of splitting by what was decided
257
+ // at construction. `enabled` decides whether the wiring EXISTS, and a
258
+ // session that started without one cannot be given one mid-flight: the
259
+ // `Agent` takes the port in its constructor and has no setter for it
260
+ // (§3.2). `/fast on` and `/team on` each record the same shape.
261
+ ctx.notify('info', enabled
262
+ ? 'Compaction is off for this session (started with --no-compaction). Saved for next launch.'
263
+ : 'Compaction is already off for this session. Saved for next launch.');
264
+ return;
265
+ }
266
+ controller.setCompactionEnabled(applied.enabled);
267
+ ctx.toast('success', `Auto-compaction ${enabled ? 'on' : 'off'}.`);
268
+ return;
269
+ }
270
+ if (verb === 'threshold') {
271
+ const raw = (value ?? '').trim();
272
+ const parsed = raw ? parseThresholdInput(raw) : null;
273
+ if (parsed === null) {
274
+ ctx.notify('warn', 'Usage: /compact threshold <0.5-0.95> (also accepts 90%)');
275
+ return;
276
+ }
277
+ const applied = controller.setCompactionConfig({ threshold: parsed });
278
+ ctx.persistConfig({
279
+ compaction: { threshold: applied.threshold },
280
+ });
281
+ // The APPLIED value, not the requested one: `clampCompactionConfig` may have
282
+ // moved it, and reporting what was asked for would make the clamp invisible.
283
+ ctx.toast('success', `Compaction threshold set to ${Math.round(applied.threshold * 100)}%.`);
284
+ return;
285
+ }
286
+ if (verb === 'keep') {
287
+ const n = Number.parseInt((value ?? '').trim(), 10);
288
+ if (!Number.isFinite(n) || n <= 0) {
289
+ ctx.notify('warn', 'Usage: /compact keep <1-20>');
290
+ return;
291
+ }
292
+ const applied = controller.setCompactionConfig({ keepRecentTurns: n });
293
+ ctx.persistConfig({
294
+ compaction: { keepRecentTurns: applied.keepRecentTurns },
295
+ });
296
+ ctx.toast('success', `Keeping ${applied.keepRecentTurns} recent turns verbatim.`);
297
+ return;
298
+ }
299
+ // BEFORE THE FREE-TEXT FALL-THROUGH, so a user cannot accidentally summarize
300
+ // with the word "history".
301
+ if (verb === 'history') {
302
+ ctx.notify('info', formatCompactionHistory(ctx));
303
+ return;
304
+ }
305
+ if (verb === 'show') {
306
+ ctx.notify('info', formatCompactionShow(ctx, value ?? ''));
307
+ return;
308
+ }
309
+ if (verb === 'help' || verb === '?') {
310
+ ctx.notify('info', USAGE);
311
+ return;
312
+ }
313
+ // --- Compact now, with `arg` (possibly empty) as instructions -------------
314
+ if (!controller.isCompactionRegistered()) {
315
+ ctx.notify('info', 'Compaction is not registered for this session (started with --no-compaction).');
316
+ return;
317
+ }
318
+ if (!controller.isCompactionEnabled()) {
319
+ ctx.notify('warn', 'Auto-compaction is off. Turn it on with /compact on, then try again.');
320
+ return;
321
+ }
322
+ const instructions = arg.length > 0 ? arg.slice(0, COMPACTION_LIMITS.summaryMaxChars) : undefined;
323
+ if (ctx.state.status === 'running') {
324
+ // QUEUED, NOT RACED (D-17). Replacing `messages` under a live loop is a race;
325
+ // a flag `shouldCompact` honours at the next turn boundary is not, because
326
+ // the boundary is the one moment the engine is single-threaded with respect
327
+ // to the history, by construction.
328
+ controller.queueCompaction(instructions);
329
+ ctx.toast('info', 'Will compact before the next turn.');
330
+ return;
331
+ }
332
+ ctx.toast('info', 'Compacting context...');
333
+ const outcome = await controller.compactNow(instructions);
334
+ if (outcome.ok) {
335
+ ctx.toast('success', 'Context compacted.');
336
+ return;
337
+ }
338
+ // THE SAME `invalid_history: <reason>` VOCABULARY THE ENGINE PRODUCES (D-25 /
339
+ // P1-7), so the card, the log and `/compact status` cannot tell the idle path
340
+ // apart from the in-loop one.
341
+ ctx.notify('warn', `Compaction did not run: ${outcome.reason ?? 'unknown'}`);
342
+ }