@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
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
* /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
|
|
4
4
|
* /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
|
|
5
5
|
*/
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
6
|
import { existsSync, statSync } from 'node:fs';
|
|
8
7
|
import { isAbsolute, resolve } from 'node:path';
|
|
9
|
-
import process from 'node:process';
|
|
10
8
|
import { resolveOutputTokens } from '@aragon-agent/core';
|
|
11
9
|
import { clampThinkingLevel, clampTheme, isAdapterProvider, isThemeName, parseMaxTokensInput, ADAPTER_PROVIDERS, DEFAULT_MAX_TOKENS, HARD_MAX_RETRIES, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
|
|
12
10
|
import { computeCost, formatCost, formatTokens } from '../agent/usage.js';
|
|
@@ -16,7 +14,9 @@ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
|
|
|
16
14
|
import { loadConfig } from '../config/load.js';
|
|
17
15
|
import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
|
|
18
16
|
import { getLogger } from '../logging/logger.js';
|
|
17
|
+
import { copyText } from '../ui/clipboard.js';
|
|
19
18
|
import { perfCommand } from './perf.js';
|
|
19
|
+
import { runCompactCommand } from '../compaction/command.js';
|
|
20
20
|
/**
|
|
21
21
|
* `/fast status` — THE GUARANTEED REPORTING SURFACE for the tier (§4.4 / R-8).
|
|
22
22
|
*
|
|
@@ -801,8 +801,21 @@ const COMMANDS = [
|
|
|
801
801
|
ctx.notify('warn', `Unknown argument "${verb}" - use /retry [show|on|off|max <n>].`);
|
|
802
802
|
},
|
|
803
803
|
},
|
|
804
|
+
{
|
|
805
|
+
// REGISTRATION ONLY. The body lives in `compaction/command.ts` because this
|
|
806
|
+
// file is already past the 1000-line guideline and `/compact` has six forms
|
|
807
|
+
// plus the longest status report in the package (C-16 / P1-12).
|
|
808
|
+
name: 'compact',
|
|
809
|
+
description: 'Context compaction: status | on | off | threshold <n> | keep <n> | <instructions>',
|
|
810
|
+
run: (ctx) => runCompactCommand(ctx),
|
|
811
|
+
},
|
|
804
812
|
{
|
|
805
813
|
name: 'expand',
|
|
814
|
+
// DELIBERATELY LEFT TOOL-ONLY, and the asymmetry with Ctrl+O is a decision
|
|
815
|
+
// rather than an oversight (§6.3). Its description says "a recent tool card",
|
|
816
|
+
// it takes an N-from-last index over tool cards specifically, and widening it
|
|
817
|
+
// would change what `/expand 3` means for every existing user. Ctrl+O is the
|
|
818
|
+
// general expansion surface — that is where `'compaction'` was added.
|
|
806
819
|
description: 'Expand / collapse a recent tool card (default: last)',
|
|
807
820
|
run: (ctx) => {
|
|
808
821
|
const toolEntries = ctx.state.entries.filter((e) => e.kind === 'tool');
|
|
@@ -942,8 +955,60 @@ const COMMANDS = [
|
|
|
942
955
|
ctx.notify('warn', 'No assistant message to copy.');
|
|
943
956
|
return;
|
|
944
957
|
}
|
|
945
|
-
|
|
946
|
-
|
|
958
|
+
// ROUTED THROUGH `ui/clipboard.ts` (§4.4.5). The duplicate spawn helper this
|
|
959
|
+
// command used to own is gone, and the command gained SSH support on the
|
|
960
|
+
// way: OSC 52 reaches the clipboard of the machine the USER is sitting at,
|
|
961
|
+
// which the local `pbcopy` never could.
|
|
962
|
+
//
|
|
963
|
+
// `ctx.writeForeign` and not `stdout.write` — see the field's comment.
|
|
964
|
+
const via = copyText(last.text, ctx.writeForeign);
|
|
965
|
+
if (via === 'none') {
|
|
966
|
+
ctx.toast('warn', 'Clipboard not available.');
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
// NAMES THE MECHANISM RATHER THAN CLAIMING SUCCESS: neither path is
|
|
970
|
+
// detectable, and tmux without `set -g set-clipboard on` swallows OSC 52
|
|
971
|
+
// silently (R-7).
|
|
972
|
+
ctx.toast('success', via === 'osc52' ? 'Sent to the terminal clipboard.' : 'Sent to the system clipboard.');
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
name: 'mouse',
|
|
977
|
+
description: 'Release the mouse to the terminal, or take it back',
|
|
978
|
+
run: (ctx) => {
|
|
979
|
+
const port = ctx.mouse;
|
|
980
|
+
if (!port) {
|
|
981
|
+
// Inline mode, `--no-mouse`, a non-TTY, or a console that cannot deliver
|
|
982
|
+
// reports. Saying so beats pretending to toggle something that is not on.
|
|
983
|
+
ctx.notify('info', 'The mouse is not captured in this session, so there is nothing to release.');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const arg = ctx.args.trim().toLowerCase();
|
|
987
|
+
if (arg === 'off') {
|
|
988
|
+
port.setCapture(false);
|
|
989
|
+
// NAMING THE COST IS THE HONEST THING TO DO, and it is one clause. The
|
|
990
|
+
// wheel goes INERT rather than back to scrolling: alternate scroll is
|
|
991
|
+
// saved and disabled alongside the capture, because otherwise every notch
|
|
992
|
+
// becomes a burst of arrow keys that `PromptInput` reads as prompt-history
|
|
993
|
+
// recall (I-10 / P1-1).
|
|
994
|
+
ctx.toast('info', 'Mouse released to the terminal - the wheel will not scroll. /mouse on gives it back.');
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (arg === 'on') {
|
|
998
|
+
port.setCapture(true);
|
|
999
|
+
ctx.toast('info', 'Mouse captured - the wheel scrolls the transcript again.');
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const captured = port.captured();
|
|
1003
|
+
const lines = [
|
|
1004
|
+
`Mouse capture: ${captured ? 'on' : 'off'}`,
|
|
1005
|
+
` drag-select ${port.selectEnabled() ? 'on' : 'off (mouseSelect is false)'}`,
|
|
1006
|
+
' /mouse off hand the mouse to your terminal for this session',
|
|
1007
|
+
' /mouse on take it back',
|
|
1008
|
+
' --no-mouse never capture it (also ARAGON_MOUSE=0)',
|
|
1009
|
+
' aragon config set mouseSelect false keep the wheel, drop drag-select',
|
|
1010
|
+
];
|
|
1011
|
+
ctx.notify('info', lines.join('\n'));
|
|
947
1012
|
},
|
|
948
1013
|
},
|
|
949
1014
|
{
|
|
@@ -1006,22 +1071,7 @@ export const BUILTIN_COMMAND_NAMES = COMMANDS.flatMap((c) => [
|
|
|
1006
1071
|
c.name,
|
|
1007
1072
|
...(c.aliases ?? []),
|
|
1008
1073
|
]).sort();
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
: process.platform === 'darwin'
|
|
1014
|
-
? { file: 'pbcopy', args: [] }
|
|
1015
|
-
: { file: 'xclip', args: ['-selection', 'clipboard'] };
|
|
1016
|
-
try {
|
|
1017
|
-
const child = spawn(cmd.file, cmd.args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
1018
|
-
child.on('error', () => {
|
|
1019
|
-
/* swallow — clipboard is best-effort */
|
|
1020
|
-
});
|
|
1021
|
-
child.stdin?.end(text);
|
|
1022
|
-
return true;
|
|
1023
|
-
}
|
|
1024
|
-
catch {
|
|
1025
|
-
return false;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1074
|
+
// The `copyToClipboard` helper that used to live here moved into
|
|
1075
|
+
// `ui/clipboard.ts` (tui-selection-and-scroll-follow §4.4.5), unchanged, so that
|
|
1076
|
+
// `/copy` and the drag-select release share ONE clipboard path instead of two
|
|
1077
|
+
// that can drift.
|
|
@@ -87,6 +87,33 @@ export interface CommandContext {
|
|
|
87
87
|
effective: AgentMode;
|
|
88
88
|
pending: AgentMode | null;
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* Mouse capture, for `/mouse` (tui-selection-and-scroll-follow §6.2 / G3).
|
|
92
|
+
*
|
|
93
|
+
* OPTIONAL, and legitimately absent: inline mode, `--no-mouse`, a non-TTY, a
|
|
94
|
+
* Windows console that cannot deliver reports, and every test that builds this
|
|
95
|
+
* context by literal. `/mouse` has to have something honest to say when it is
|
|
96
|
+
* missing, which is the same reason `update` above it is optional.
|
|
97
|
+
*/
|
|
98
|
+
mouse?: MouseCommandPort;
|
|
99
|
+
/**
|
|
100
|
+
* The foreign-write door, for `/copy`'s OSC 52 half (§4.4.5).
|
|
101
|
+
*
|
|
102
|
+
* NOT `process.stdout.write`. In full-screen mode stdout is a `Proxy` in front
|
|
103
|
+
* of the frame differ, and an unrecognised chunk there is counted as a foreign
|
|
104
|
+
* write and prints a diagnostic at the user the first time they copy anything
|
|
105
|
+
* (P1-6). Absent in inline mode and with `--no-diff-render`, where the real
|
|
106
|
+
* stream is handed over directly.
|
|
107
|
+
*/
|
|
108
|
+
writeForeign?: (text: string) => void;
|
|
109
|
+
}
|
|
110
|
+
/** What `/mouse` can see and do. Read at command time, never captured. */
|
|
111
|
+
export interface MouseCommandPort {
|
|
112
|
+
/** Whether the CLI is holding the mouse right now. */
|
|
113
|
+
captured(): boolean;
|
|
114
|
+
/** Whether drag-select is on for this session (`mouseSelect`). */
|
|
115
|
+
selectEnabled(): boolean;
|
|
116
|
+
setCapture(on: boolean): void;
|
|
90
117
|
}
|
|
91
118
|
export interface SlashCommand {
|
|
92
119
|
name: string;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compaction archive - a compaction stops being irreversible
|
|
3
|
+
* (context-auto-compaction-hardening §3.5 / W4).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope
|
|
6
|
+
* (`glyphs.test.ts::inScope`), and that INCLUDES the field names below and every
|
|
7
|
+
* string `/compact history` and `/compact show` print.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS IS FOR. Round 1's non-goal was stated plainly: "the dropped messages
|
|
10
|
+
* are gone". Combined with R-1 - "the summary is wrong or lossy, and the agent
|
|
11
|
+
* proceeds confidently on a false record... this is the feature's defining risk
|
|
12
|
+
* and it cannot be eliminated" - the product was asking the user to accept an
|
|
13
|
+
* unverifiable, unrecoverable transformation of their session. That is the wrong
|
|
14
|
+
* default for a tool that runs unattended. This does not add undo (§12: restoring
|
|
15
|
+
* an over-full history re-creates the condition that triggered compaction). It
|
|
16
|
+
* adds FIDELITY ON DISK, so R-1 becomes something a human can check.
|
|
17
|
+
*
|
|
18
|
+
* NO IMPORTS FROM `ui/`. This module writes files and formats two report blocks;
|
|
19
|
+
* it has no terminal, no theme and no glyphs.
|
|
20
|
+
*/
|
|
21
|
+
import type { Message } from '@aragon-agent/core';
|
|
22
|
+
import type { CompactionMode, CompactionUiTrigger } from './types.js';
|
|
23
|
+
/** Bumped only when the SHAPE changes; the reader rejects anything else. */
|
|
24
|
+
export declare const ARCHIVE_FORMAT_VERSION = 1;
|
|
25
|
+
export interface CompactionArchive {
|
|
26
|
+
version: number;
|
|
27
|
+
/** `COMPACTION_BLOCK_VERSION` at write time, so a bad summary ties to a revision. */
|
|
28
|
+
blockVersion: string;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
/**
|
|
31
|
+
* Which process wrote this.
|
|
32
|
+
*
|
|
33
|
+
* STORED INSIDE THE DOCUMENT as well as in the name, so a file renamed or
|
|
34
|
+
* copied by hand is still self-describing and the reader never has to parse a
|
|
35
|
+
* filename for meaning it cannot verify.
|
|
36
|
+
*/
|
|
37
|
+
runId: string;
|
|
38
|
+
/** 1-based within this run. */
|
|
39
|
+
index: number;
|
|
40
|
+
trigger: CompactionUiTrigger;
|
|
41
|
+
mode: CompactionMode;
|
|
42
|
+
model: string;
|
|
43
|
+
generation: number;
|
|
44
|
+
tokensBefore: number;
|
|
45
|
+
tokensAfter: number;
|
|
46
|
+
messagesBefore: number;
|
|
47
|
+
messagesAfter: number;
|
|
48
|
+
/**
|
|
49
|
+
* THE NUMBER. Deliberately not called `droppedMessages`: that name already
|
|
50
|
+
* means a COUNT in three typed places (`CompactionPlan`, `finish`'s `applied`
|
|
51
|
+
* argument, and `AgentEvent.compaction_end`), and this is the document someone
|
|
52
|
+
* reads while diagnosing a bad summary (RV-3).
|
|
53
|
+
*/
|
|
54
|
+
droppedCount: number;
|
|
55
|
+
summary?: string;
|
|
56
|
+
tailRelief?: {
|
|
57
|
+
messages: number;
|
|
58
|
+
charsRemoved: number;
|
|
59
|
+
};
|
|
60
|
+
/** True when `dropped` was shortened to fit `archiveMaxBytes`. */
|
|
61
|
+
clipped: boolean;
|
|
62
|
+
/** THE ARRAY. Verbatim, oldest first. */
|
|
63
|
+
dropped: Message[];
|
|
64
|
+
}
|
|
65
|
+
/** One row of `/compact history`, already resolved from disk. */
|
|
66
|
+
export interface ArchiveListing {
|
|
67
|
+
entries: ArchiveEntry[];
|
|
68
|
+
/** Archives in the directory belonging to OTHER runs. Counted, never listed. */
|
|
69
|
+
otherRuns: number;
|
|
70
|
+
dir: string;
|
|
71
|
+
}
|
|
72
|
+
export interface ArchiveEntry {
|
|
73
|
+
path: string;
|
|
74
|
+
fileName: string;
|
|
75
|
+
index: number;
|
|
76
|
+
createdAt: number;
|
|
77
|
+
trigger: CompactionUiTrigger;
|
|
78
|
+
mode: CompactionMode;
|
|
79
|
+
model: string;
|
|
80
|
+
tokensBefore: number;
|
|
81
|
+
tokensAfter: number;
|
|
82
|
+
droppedCount: number;
|
|
83
|
+
summary?: string;
|
|
84
|
+
tailRelief?: {
|
|
85
|
+
messages: number;
|
|
86
|
+
charsRemoved: number;
|
|
87
|
+
};
|
|
88
|
+
clipped: boolean;
|
|
89
|
+
}
|
|
90
|
+
export interface ArchiveWriteInput {
|
|
91
|
+
runId: string;
|
|
92
|
+
index: number;
|
|
93
|
+
trigger: CompactionUiTrigger;
|
|
94
|
+
mode: CompactionMode;
|
|
95
|
+
model: string;
|
|
96
|
+
generation: number;
|
|
97
|
+
tokensBefore: number;
|
|
98
|
+
tokensAfter: number;
|
|
99
|
+
messagesBefore: number;
|
|
100
|
+
messagesAfter: number;
|
|
101
|
+
summary?: string;
|
|
102
|
+
tailRelief?: {
|
|
103
|
+
messages: number;
|
|
104
|
+
charsRemoved: number;
|
|
105
|
+
};
|
|
106
|
+
dropped: Message[];
|
|
107
|
+
/** Injected by the tests; production passes nothing. */
|
|
108
|
+
dir?: string;
|
|
109
|
+
now?: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `compaction-<YYYY-MM-DD>-<hhmmss>-<runId>-<index>.json`.
|
|
113
|
+
*
|
|
114
|
+
* THE RUN ID IS IN THE NAME BECAUSE THE DIRECTORY IS SHARED (RV-6).
|
|
115
|
+
* `file-sink.ts` makes log retention best-effort precisely because "on Windows
|
|
116
|
+
* another `aragon` process may hold a handle on a file we would like to remove",
|
|
117
|
+
* so concurrent CLI processes are an acknowledged in-tree condition rather than
|
|
118
|
+
* an edge case. Without this segment `/compact history` presents another
|
|
119
|
+
* session's compactions as this one's and `/compact show 2` is ambiguous the
|
|
120
|
+
* moment two runs both reach their second compaction.
|
|
121
|
+
*/
|
|
122
|
+
export declare function archiveFileName(runId: string, index: number, at: Date): string;
|
|
123
|
+
/**
|
|
124
|
+
* Mint this process's archive key.
|
|
125
|
+
*
|
|
126
|
+
* NOT `SessionMeta.id`: that exists only once a session has been SAVED, and the
|
|
127
|
+
* unattended long run this feature serves is exactly the one nobody saved. Short,
|
|
128
|
+
* filename-safe, and only ever compared for equality - a grouping key, not an
|
|
129
|
+
* identity.
|
|
130
|
+
*/
|
|
131
|
+
export declare function mintRunId(pid?: number, now?: number): string;
|
|
132
|
+
/**
|
|
133
|
+
* Serialize, clipping the dropped bodies from the OLDEST end if the document
|
|
134
|
+
* exceeds `archiveMaxBytes`.
|
|
135
|
+
*
|
|
136
|
+
* THE METADATA AND THE SUMMARY ARE NEVER CLIPPED. A truncated archive that still
|
|
137
|
+
* says what happened, when, and with which model beats no archive at all - and
|
|
138
|
+
* the oldest dropped messages are the least likely to be the ones a human came
|
|
139
|
+
* looking for.
|
|
140
|
+
*/
|
|
141
|
+
export declare function serializeArchive(doc: CompactionArchive, maxBytes: number): string;
|
|
142
|
+
/**
|
|
143
|
+
* Write one archive, then prune. Returns the path written, or `null`.
|
|
144
|
+
*
|
|
145
|
+
* NEVER THROWS. The caller runs inside an event handler on the compaction path;
|
|
146
|
+
* a filesystem is not allowed to affect whether a session survives its context
|
|
147
|
+
* window. Every failure is a `warn` with the path and nothing else - a toast for
|
|
148
|
+
* a best-effort audit file trains users to ignore toasts.
|
|
149
|
+
*/
|
|
150
|
+
export declare function writeArchive(input: ArchiveWriteInput): string | null;
|
|
151
|
+
/**
|
|
152
|
+
* Two-tier retention, and the ORDER MATTERS (§3.5.2 / RV-6).
|
|
153
|
+
*
|
|
154
|
+
* 1. WITHIN THIS RUN: keep the newest `archiveMaxFiles` files carrying THIS
|
|
155
|
+
* `runId`. This is the bound that protects the user from their own long
|
|
156
|
+
* session, and it can never touch another run.
|
|
157
|
+
* 2. ACROSS RUNS: delete files older than `archiveMaxAgeMs`, regardless of
|
|
158
|
+
* `runId`, so an abandoned run's archives do not accumulate forever. AGE, not
|
|
159
|
+
* count - a count-based global prune is exactly the rule that deletes a live
|
|
160
|
+
* session's work while it is still running.
|
|
161
|
+
*
|
|
162
|
+
* Best-effort throughout, in the `enforceRetention` shape: every `rmSync` in its
|
|
163
|
+
* own `try`, a locked file skipped rather than fatal.
|
|
164
|
+
*/
|
|
165
|
+
export declare function pruneArchives(runId: string, dir: string, now: number): void;
|
|
166
|
+
/**
|
|
167
|
+
* This run's archives, newest first, plus a COUNT of everyone else's.
|
|
168
|
+
*
|
|
169
|
+
* THE COUNT IS REPORTED RATHER THAN THE FILES, because the alternative is a user
|
|
170
|
+
* concluding their own compactions went missing when the directory holds a
|
|
171
|
+
* neighbour's.
|
|
172
|
+
*/
|
|
173
|
+
export declare function listArchives(runId: string, dirOverride?: string): ArchiveListing;
|
|
174
|
+
/** One archive by its `#n`, or `null`. `n` is the index within THIS run. */
|
|
175
|
+
export declare function findArchive(runId: string, index: number, dirOverride?: string): ArchiveEntry | null;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compaction archive - a compaction stops being irreversible
|
|
3
|
+
* (context-auto-compaction-hardening §3.5 / W4).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/compaction/**` is inside the glyph scanner's scope
|
|
6
|
+
* (`glyphs.test.ts::inScope`), and that INCLUDES the field names below and every
|
|
7
|
+
* string `/compact history` and `/compact show` print.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS IS FOR. Round 1's non-goal was stated plainly: "the dropped messages
|
|
10
|
+
* are gone". Combined with R-1 - "the summary is wrong or lossy, and the agent
|
|
11
|
+
* proceeds confidently on a false record... this is the feature's defining risk
|
|
12
|
+
* and it cannot be eliminated" - the product was asking the user to accept an
|
|
13
|
+
* unverifiable, unrecoverable transformation of their session. That is the wrong
|
|
14
|
+
* default for a tool that runs unattended. This does not add undo (§12: restoring
|
|
15
|
+
* an over-full history re-creates the condition that triggered compaction). It
|
|
16
|
+
* adds FIDELITY ON DISK, so R-1 becomes something a human can check.
|
|
17
|
+
*
|
|
18
|
+
* NO IMPORTS FROM `ui/`. This module writes files and formats two report blocks;
|
|
19
|
+
* it has no terminal, no theme and no glyphs.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { getCompactionArchiveDir } from '../config/app-paths.js';
|
|
24
|
+
import { getLogger } from '../logging/logger.js';
|
|
25
|
+
import { COMPACTION_BLOCK_VERSION, COMPACTION_LIMITS } from './limits.js';
|
|
26
|
+
const FILE_PREFIX = 'compaction-';
|
|
27
|
+
const FILE_SUFFIX = '.json';
|
|
28
|
+
/** Bumped only when the SHAPE changes; the reader rejects anything else. */
|
|
29
|
+
export const ARCHIVE_FORMAT_VERSION = 1;
|
|
30
|
+
/**
|
|
31
|
+
* `compaction-<YYYY-MM-DD>-<hhmmss>-<runId>-<index>.json`.
|
|
32
|
+
*
|
|
33
|
+
* THE RUN ID IS IN THE NAME BECAUSE THE DIRECTORY IS SHARED (RV-6).
|
|
34
|
+
* `file-sink.ts` makes log retention best-effort precisely because "on Windows
|
|
35
|
+
* another `aragon` process may hold a handle on a file we would like to remove",
|
|
36
|
+
* so concurrent CLI processes are an acknowledged in-tree condition rather than
|
|
37
|
+
* an edge case. Without this segment `/compact history` presents another
|
|
38
|
+
* session's compactions as this one's and `/compact show 2` is ambiguous the
|
|
39
|
+
* moment two runs both reach their second compaction.
|
|
40
|
+
*/
|
|
41
|
+
export function archiveFileName(runId, index, at) {
|
|
42
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
43
|
+
const date = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`;
|
|
44
|
+
const time = `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`;
|
|
45
|
+
return `${FILE_PREFIX}${date}-${time}-${runId}-${index}${FILE_SUFFIX}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The inverse of `archiveFileName`, or `null` when the name is not one of ours.
|
|
49
|
+
*
|
|
50
|
+
* AN EXACT PARSE RATHER THAN `name.includes('-' + runId + '-')`, because that
|
|
51
|
+
* substring test is not the question either caller is asking. A run id is a pid
|
|
52
|
+
* in base 36 followed by four base-36 characters of the clock, so a six-character
|
|
53
|
+
* all-digit one matches the `hhmmss` segment of EVERY other run's file written at
|
|
54
|
+
* that second - and the two places this attribution is used are precisely the two
|
|
55
|
+
* RV-6 exists to protect: retention deleting a neighbour's archive, and
|
|
56
|
+
* `/compact history` presenting one as this run's.
|
|
57
|
+
*
|
|
58
|
+
* KEEP IT IN STEP WITH `archiveFileName` ABOVE. The two are one format, written
|
|
59
|
+
* adjacently so a change to either is visible against the other.
|
|
60
|
+
*/
|
|
61
|
+
function parseArchiveName(name) {
|
|
62
|
+
const match = /^compaction-\d{4}-\d{2}-\d{2}-\d{6}-(.+)-(\d+)\.json$/.exec(name);
|
|
63
|
+
if (!match)
|
|
64
|
+
return null;
|
|
65
|
+
return { runId: match[1], index: Number(match[2]) };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Mint this process's archive key.
|
|
69
|
+
*
|
|
70
|
+
* NOT `SessionMeta.id`: that exists only once a session has been SAVED, and the
|
|
71
|
+
* unattended long run this feature serves is exactly the one nobody saved. Short,
|
|
72
|
+
* filename-safe, and only ever compared for equality - a grouping key, not an
|
|
73
|
+
* identity.
|
|
74
|
+
*/
|
|
75
|
+
export function mintRunId(pid = process.pid, now = Date.now()) {
|
|
76
|
+
return `${pid.toString(36)}${now.toString(36).slice(-4)}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Serialize, clipping the dropped bodies from the OLDEST end if the document
|
|
80
|
+
* exceeds `archiveMaxBytes`.
|
|
81
|
+
*
|
|
82
|
+
* THE METADATA AND THE SUMMARY ARE NEVER CLIPPED. A truncated archive that still
|
|
83
|
+
* says what happened, when, and with which model beats no archive at all - and
|
|
84
|
+
* the oldest dropped messages are the least likely to be the ones a human came
|
|
85
|
+
* looking for.
|
|
86
|
+
*/
|
|
87
|
+
export function serializeArchive(doc, maxBytes) {
|
|
88
|
+
let dropped = doc.dropped;
|
|
89
|
+
let clipped = doc.clipped;
|
|
90
|
+
let text = JSON.stringify({ ...doc, dropped, clipped }, null, 2);
|
|
91
|
+
while (Buffer.byteLength(text, 'utf8') > maxBytes && dropped.length > 0) {
|
|
92
|
+
// HALVED RATHER THAN SHIFTED ONE AT A TIME: a 100 MB archive of 4 000
|
|
93
|
+
// messages would otherwise re-serialize 4 000 times, inside a write that is
|
|
94
|
+
// supposed to be invisible to the run.
|
|
95
|
+
dropped = dropped.slice(Math.max(1, Math.ceil(dropped.length / 2)));
|
|
96
|
+
clipped = true;
|
|
97
|
+
text = JSON.stringify({ ...doc, dropped, clipped }, null, 2);
|
|
98
|
+
}
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Write one archive, then prune. Returns the path written, or `null`.
|
|
103
|
+
*
|
|
104
|
+
* NEVER THROWS. The caller runs inside an event handler on the compaction path;
|
|
105
|
+
* a filesystem is not allowed to affect whether a session survives its context
|
|
106
|
+
* window. Every failure is a `warn` with the path and nothing else - a toast for
|
|
107
|
+
* a best-effort audit file trains users to ignore toasts.
|
|
108
|
+
*/
|
|
109
|
+
export function writeArchive(input) {
|
|
110
|
+
const dir = input.dir ?? getCompactionArchiveDir();
|
|
111
|
+
const now = input.now ?? Date.now();
|
|
112
|
+
const path = join(dir, archiveFileName(input.runId, input.index, new Date(now)));
|
|
113
|
+
try {
|
|
114
|
+
mkdirSync(dir, { recursive: true });
|
|
115
|
+
const doc = {
|
|
116
|
+
version: ARCHIVE_FORMAT_VERSION,
|
|
117
|
+
blockVersion: COMPACTION_BLOCK_VERSION,
|
|
118
|
+
createdAt: now,
|
|
119
|
+
runId: input.runId,
|
|
120
|
+
index: input.index,
|
|
121
|
+
trigger: input.trigger,
|
|
122
|
+
mode: input.mode,
|
|
123
|
+
model: input.model,
|
|
124
|
+
generation: input.generation,
|
|
125
|
+
tokensBefore: input.tokensBefore,
|
|
126
|
+
tokensAfter: input.tokensAfter,
|
|
127
|
+
messagesBefore: input.messagesBefore,
|
|
128
|
+
messagesAfter: input.messagesAfter,
|
|
129
|
+
droppedCount: input.dropped.length,
|
|
130
|
+
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
|
131
|
+
...(input.tailRelief ? { tailRelief: input.tailRelief } : {}),
|
|
132
|
+
clipped: false,
|
|
133
|
+
dropped: input.dropped,
|
|
134
|
+
};
|
|
135
|
+
writeFileSync(path, serializeArchive(doc, COMPACTION_LIMITS.archiveMaxBytes), 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
getLogger()
|
|
139
|
+
.child('compaction')
|
|
140
|
+
.warn('compaction_archive_failed', { path, error: errorText(err) });
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
pruneArchives(input.runId, dir, now);
|
|
144
|
+
return path;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Two-tier retention, and the ORDER MATTERS (§3.5.2 / RV-6).
|
|
148
|
+
*
|
|
149
|
+
* 1. WITHIN THIS RUN: keep the newest `archiveMaxFiles` files carrying THIS
|
|
150
|
+
* `runId`. This is the bound that protects the user from their own long
|
|
151
|
+
* session, and it can never touch another run.
|
|
152
|
+
* 2. ACROSS RUNS: delete files older than `archiveMaxAgeMs`, regardless of
|
|
153
|
+
* `runId`, so an abandoned run's archives do not accumulate forever. AGE, not
|
|
154
|
+
* count - a count-based global prune is exactly the rule that deletes a live
|
|
155
|
+
* session's work while it is still running.
|
|
156
|
+
*
|
|
157
|
+
* Best-effort throughout, in the `enforceRetention` shape: every `rmSync` in its
|
|
158
|
+
* own `try`, a locked file skipped rather than fatal.
|
|
159
|
+
*/
|
|
160
|
+
export function pruneArchives(runId, dir, now) {
|
|
161
|
+
let files;
|
|
162
|
+
try {
|
|
163
|
+
files = readdirSync(dir)
|
|
164
|
+
.filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX))
|
|
165
|
+
.map((name) => {
|
|
166
|
+
const path = join(dir, name);
|
|
167
|
+
try {
|
|
168
|
+
return { path, name, mtime: statSync(path).mtimeMs };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
.filter((e) => e !== null);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return; // The directory does not exist yet, or is unreadable.
|
|
178
|
+
}
|
|
179
|
+
const mine = files
|
|
180
|
+
.filter((f) => parseArchiveName(f.name)?.runId === runId)
|
|
181
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
182
|
+
for (const f of mine.slice(0, Math.max(0, mine.length - COMPACTION_LIMITS.archiveMaxFiles))) {
|
|
183
|
+
remove(f.path);
|
|
184
|
+
}
|
|
185
|
+
for (const f of files) {
|
|
186
|
+
if (now - f.mtime > COMPACTION_LIMITS.archiveMaxAgeMs)
|
|
187
|
+
remove(f.path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function remove(path) {
|
|
191
|
+
try {
|
|
192
|
+
rmSync(path, { force: true });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Held by another instance. Retention is best-effort by design.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* This run's archives, newest first, plus a COUNT of everyone else's.
|
|
200
|
+
*
|
|
201
|
+
* THE COUNT IS REPORTED RATHER THAN THE FILES, because the alternative is a user
|
|
202
|
+
* concluding their own compactions went missing when the directory holds a
|
|
203
|
+
* neighbour's.
|
|
204
|
+
*/
|
|
205
|
+
export function listArchives(runId, dirOverride) {
|
|
206
|
+
const dir = dirOverride ?? getCompactionArchiveDir();
|
|
207
|
+
const entries = [];
|
|
208
|
+
let otherRuns = 0;
|
|
209
|
+
let names;
|
|
210
|
+
try {
|
|
211
|
+
names = readdirSync(dir).filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return { entries: [], otherRuns: 0, dir };
|
|
215
|
+
}
|
|
216
|
+
for (const name of names) {
|
|
217
|
+
if (parseArchiveName(name)?.runId !== runId) {
|
|
218
|
+
otherRuns += 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const entry = readArchiveEntry(join(dir, name), name);
|
|
222
|
+
if (entry)
|
|
223
|
+
entries.push(entry);
|
|
224
|
+
}
|
|
225
|
+
entries.sort((a, b) => b.index - a.index);
|
|
226
|
+
return { entries, otherRuns, dir };
|
|
227
|
+
}
|
|
228
|
+
/** One archive by its `#n`, or `null`. `n` is the index within THIS run. */
|
|
229
|
+
export function findArchive(runId, index, dirOverride) {
|
|
230
|
+
return listArchives(runId, dirOverride).entries.find((e) => e.index === index) ?? null;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Read one document's METADATA. The `dropped` array is never returned.
|
|
234
|
+
*
|
|
235
|
+
* `/compact show` prints no message bodies (DH-9): a single archive can be
|
|
236
|
+
* megabytes and the transcript is not a pager. The path is the useful output.
|
|
237
|
+
*/
|
|
238
|
+
function readArchiveEntry(path, fileName) {
|
|
239
|
+
try {
|
|
240
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
241
|
+
if (raw.version !== ARCHIVE_FORMAT_VERSION)
|
|
242
|
+
return null;
|
|
243
|
+
return {
|
|
244
|
+
path,
|
|
245
|
+
fileName,
|
|
246
|
+
index: Number(raw.index ?? 0),
|
|
247
|
+
createdAt: Number(raw.createdAt ?? 0),
|
|
248
|
+
trigger: (raw.trigger ?? 'pressure'),
|
|
249
|
+
mode: (raw.mode ?? 'none'),
|
|
250
|
+
model: String(raw.model ?? ''),
|
|
251
|
+
tokensBefore: Number(raw.tokensBefore ?? 0),
|
|
252
|
+
tokensAfter: Number(raw.tokensAfter ?? 0),
|
|
253
|
+
droppedCount: Number(raw.droppedCount ?? 0),
|
|
254
|
+
...(typeof raw.summary === 'string' ? { summary: raw.summary } : {}),
|
|
255
|
+
...(raw.tailRelief ? { tailRelief: raw.tailRelief } : {}),
|
|
256
|
+
clipped: raw.clipped === true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// A half-written file from a process that was killed mid-write, or a format
|
|
261
|
+
// from a future version. Skipping it is the only sensible reading.
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function errorText(err) {
|
|
266
|
+
return err instanceof Error ? err.message : String(err);
|
|
267
|
+
}
|