@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.
- package/CHANGELOG.md +216 -0
- package/README.md +202 -0
- package/dist/agent/controller.d.ts +74 -1
- package/dist/agent/controller.js +200 -1
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +47 -0
- package/dist/agent/reducer.d.ts +120 -0
- package/dist/agent/reducer.js +201 -2
- package/dist/cli.js +224 -5
- package/dist/commands/builtins.js +73 -23
- package/dist/commands/registry.d.ts +27 -0
- package/dist/compaction/archive.d.ts +175 -0
- package/dist/compaction/archive.js +267 -0
- package/dist/compaction/child.d.ts +97 -0
- package/dist/compaction/child.js +113 -0
- package/dist/compaction/command.d.ts +41 -0
- package/dist/compaction/command.js +342 -0
- package/dist/compaction/compactor.d.ts +268 -0
- package/dist/compaction/compactor.js +689 -0
- package/dist/compaction/digest.d.ts +62 -0
- package/dist/compaction/digest.js +164 -0
- package/dist/compaction/limits.d.ts +165 -0
- package/dist/compaction/limits.js +172 -0
- package/dist/compaction/pressure.d.ts +135 -0
- package/dist/compaction/pressure.js +167 -0
- package/dist/compaction/summarize-call.d.ts +70 -0
- package/dist/compaction/summarize-call.js +86 -0
- package/dist/compaction/summary-prompt.d.ts +85 -0
- package/dist/compaction/summary-prompt.js +197 -0
- package/dist/compaction/tail-budget.d.ts +52 -0
- package/dist/compaction/tail-budget.js +67 -0
- package/dist/compaction/types.d.ts +179 -0
- package/dist/compaction/types.js +22 -0
- package/dist/compaction/wiring.d.ts +255 -0
- package/dist/compaction/wiring.js +556 -0
- package/dist/config/app-paths.d.ts +14 -0
- package/dist/config/app-paths.js +16 -0
- package/dist/config/cli-commands.d.ts +41 -0
- package/dist/config/cli-commands.js +77 -1
- package/dist/config/env.d.ts +4 -1
- package/dist/config/env.js +92 -2
- package/dist/config/load.d.ts +31 -0
- package/dist/config/load.js +63 -1
- package/dist/config/schema.d.ts +159 -0
- package/dist/config/schema.js +137 -0
- package/dist/config/store.js +17 -1
- package/dist/config/ui-state.d.ts +28 -1
- package/dist/config/ui-state.js +9 -0
- package/dist/exec/events.d.ts +50 -1
- package/dist/exec/runner.js +42 -0
- package/dist/input/mouse-events.d.ts +31 -5
- package/dist/input/mouse-events.js +86 -27
- package/dist/input/stdin-mouse-filter.d.ts +8 -3
- package/dist/logging/install.d.ts +51 -1
- package/dist/logging/install.js +28 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.js +19 -0
- package/dist/team/report.js +7 -1
- package/dist/team/runtime.d.ts +10 -0
- package/dist/team/runtime.js +7 -0
- package/dist/team/subagent.d.ts +31 -1
- package/dist/team/subagent.js +42 -15
- package/dist/team/types.d.ts +8 -0
- package/dist/ui/ActivityLine.d.ts +19 -1
- package/dist/ui/ActivityLine.js +10 -2
- package/dist/ui/App.d.ts +48 -1
- package/dist/ui/App.js +294 -13
- package/dist/ui/Composer.d.ts +13 -1
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +34 -1
- package/dist/ui/PromptInput.js +54 -3
- package/dist/ui/StatusBar.d.ts +40 -0
- package/dist/ui/StatusBar.js +13 -4
- package/dist/ui/TeamPanel.js +7 -1
- package/dist/ui/Transcript.d.ts +21 -1
- package/dist/ui/Transcript.js +32 -2
- package/dist/ui/clipboard.d.ts +52 -0
- package/dist/ui/clipboard.js +91 -0
- package/dist/ui/entries/CompactionCard.d.ts +75 -0
- package/dist/ui/entries/CompactionCard.js +158 -0
- package/dist/ui/frame-differ.d.ts +46 -0
- package/dist/ui/frame-differ.js +94 -10
- package/dist/ui/gauge.d.ts +16 -1
- package/dist/ui/gauge.js +25 -6
- package/dist/ui/glyphs.d.ts +11 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/ScrollViewport.d.ts +43 -6
- package/dist/ui/layout/ScrollViewport.js +128 -21
- package/dist/ui/layout/budget.d.ts +10 -3
- package/dist/ui/layout/budget.js +10 -3
- package/dist/ui/layout/follow-state.d.ts +137 -0
- package/dist/ui/layout/follow-state.js +150 -0
- package/dist/ui/layout/virtual-window.d.ts +11 -0
- package/dist/ui/layout/virtual-window.js +34 -0
- package/dist/ui/overlays/HelpOverlay.js +6 -1
- package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
- package/dist/ui/overlays/SettingsScreen.js +60 -1
- package/dist/ui/palettes.d.ts +16 -0
- package/dist/ui/palettes.js +6 -0
- package/dist/ui/screen.d.ts +22 -0
- package/dist/ui/screen.js +70 -8
- package/dist/ui/selection/highlight.d.ts +39 -0
- package/dist/ui/selection/highlight.js +64 -0
- package/dist/ui/selection/screen-mirror.d.ts +24 -0
- package/dist/ui/selection/screen-mirror.js +35 -0
- package/dist/ui/selection/selection-controller.d.ts +88 -0
- package/dist/ui/selection/selection-controller.js +282 -0
- package/dist/ui/selection/selection.d.ts +90 -0
- package/dist/ui/selection/selection.js +223 -0
- package/dist/ui/stdout-frame-writer.d.ts +33 -0
- package/dist/ui/stdout-frame-writer.js +15 -0
- package/dist/ui/theme.d.ts +11 -0
- package/dist/ui/theme.js +2 -0
- package/dist/ui/transcript-text.js +23 -0
- package/dist/ui/use-startup-notices.d.ts +27 -1
- package/dist/ui/use-startup-notices.js +43 -11
- package/dist/ui/use-wheel-routing.js +8 -0
- 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
|
+
}
|