@aragon-agent/cli 0.6.1 → 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 (127) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +241 -18
  3. package/dist/agent/agent-mode.d.ts +39 -0
  4. package/dist/agent/agent-mode.js +36 -0
  5. package/dist/agent/controller.d.ts +74 -1
  6. package/dist/agent/controller.js +200 -1
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +47 -0
  9. package/dist/agent/reducer.d.ts +120 -0
  10. package/dist/agent/reducer.js +201 -2
  11. package/dist/cli.js +297 -6
  12. package/dist/commands/builtins.js +73 -23
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +113 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +342 -0
  20. package/dist/compaction/compactor.d.ts +268 -0
  21. package/dist/compaction/compactor.js +689 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +179 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +255 -0
  37. package/dist/compaction/wiring.js +556 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +4 -1
  43. package/dist/config/env.js +92 -2
  44. package/dist/config/load.d.ts +31 -0
  45. package/dist/config/load.js +63 -1
  46. package/dist/config/schema.d.ts +159 -0
  47. package/dist/config/schema.js +137 -0
  48. package/dist/config/store.js +17 -1
  49. package/dist/config/ui-state.d.ts +44 -6
  50. package/dist/config/ui-state.js +21 -12
  51. package/dist/diagnostics/doctor.js +41 -1
  52. package/dist/exec/events.d.ts +50 -1
  53. package/dist/exec/index.js +8 -4
  54. package/dist/exec/runner.js +44 -2
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  58. package/dist/logging/install.d.ts +51 -1
  59. package/dist/logging/install.js +28 -0
  60. package/dist/logging/logger.d.ts +1 -1
  61. package/dist/session/cli-commands.js +23 -1
  62. package/dist/session/persist.js +19 -0
  63. package/dist/session/store.d.ts +18 -0
  64. package/dist/session/store.js +59 -2
  65. package/dist/team/report.js +7 -1
  66. package/dist/team/runtime.d.ts +10 -0
  67. package/dist/team/runtime.js +7 -0
  68. package/dist/team/subagent.d.ts +31 -1
  69. package/dist/team/subagent.js +42 -15
  70. package/dist/team/types.d.ts +8 -0
  71. package/dist/ui/ActivityLine.d.ts +19 -1
  72. package/dist/ui/ActivityLine.js +10 -2
  73. package/dist/ui/App.d.ts +48 -1
  74. package/dist/ui/App.js +317 -17
  75. package/dist/ui/Composer.d.ts +26 -1
  76. package/dist/ui/Composer.js +6 -6
  77. package/dist/ui/PromptInput.d.ts +34 -1
  78. package/dist/ui/PromptInput.js +54 -3
  79. package/dist/ui/StatusBar.d.ts +40 -0
  80. package/dist/ui/StatusBar.js +13 -4
  81. package/dist/ui/TeamPanel.js +7 -1
  82. package/dist/ui/Transcript.d.ts +21 -1
  83. package/dist/ui/Transcript.js +32 -2
  84. package/dist/ui/clipboard.d.ts +52 -0
  85. package/dist/ui/clipboard.js +91 -0
  86. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  87. package/dist/ui/entries/CompactionCard.js +158 -0
  88. package/dist/ui/frame-differ.d.ts +46 -0
  89. package/dist/ui/frame-differ.js +94 -10
  90. package/dist/ui/gauge.d.ts +16 -1
  91. package/dist/ui/gauge.js +25 -6
  92. package/dist/ui/glyphs.d.ts +11 -0
  93. package/dist/ui/glyphs.js +2 -0
  94. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  95. package/dist/ui/layout/ScrollViewport.js +128 -21
  96. package/dist/ui/layout/budget.d.ts +10 -3
  97. package/dist/ui/layout/budget.js +10 -3
  98. package/dist/ui/layout/follow-state.d.ts +137 -0
  99. package/dist/ui/layout/follow-state.js +150 -0
  100. package/dist/ui/layout/virtual-window.d.ts +11 -0
  101. package/dist/ui/layout/virtual-window.js +34 -0
  102. package/dist/ui/overlays/HelpOverlay.js +18 -2
  103. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  104. package/dist/ui/overlays/SettingsScreen.js +60 -1
  105. package/dist/ui/palettes.d.ts +16 -0
  106. package/dist/ui/palettes.js +6 -0
  107. package/dist/ui/screen.d.ts +22 -0
  108. package/dist/ui/screen.js +70 -8
  109. package/dist/ui/selection/highlight.d.ts +39 -0
  110. package/dist/ui/selection/highlight.js +64 -0
  111. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  112. package/dist/ui/selection/screen-mirror.js +35 -0
  113. package/dist/ui/selection/selection-controller.d.ts +88 -0
  114. package/dist/ui/selection/selection-controller.js +282 -0
  115. package/dist/ui/selection/selection.d.ts +90 -0
  116. package/dist/ui/selection/selection.js +223 -0
  117. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  118. package/dist/ui/stdout-frame-writer.js +15 -0
  119. package/dist/ui/theme.d.ts +11 -0
  120. package/dist/ui/theme.js +2 -0
  121. package/dist/ui/transcript-text.js +23 -0
  122. package/dist/ui/use-startup-notices.d.ts +62 -5
  123. package/dist/ui/use-startup-notices.js +81 -17
  124. package/dist/ui/use-wheel-routing.js +8 -0
  125. package/dist/ui/win-vt-force.d.ts +136 -0
  126. package/dist/ui/win-vt-force.js +237 -0
  127. package/package.json +4 -2
@@ -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
+ }
@@ -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
+ }