@aragon-agent/cli 0.6.23 → 0.6.25
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 +1835 -1786
- package/README.md +2578 -2544
- package/dist/agent/controller.d.ts +60 -3
- package/dist/agent/controller.js +111 -6
- package/dist/agent/conversation-reset.d.ts +91 -0
- package/dist/agent/conversation-reset.js +66 -0
- package/dist/agent/model-probe-limits.d.ts +39 -0
- package/dist/agent/model-probe-limits.js +25 -0
- package/dist/agent/model-probe.d.ts +104 -0
- package/dist/agent/model-probe.js +391 -0
- package/dist/agent/model-profile-settings.js +29 -2
- package/dist/agent/reducer.js +45 -13
- package/dist/commands/builtins.js +101 -37
- package/dist/commands/clear-command.d.ts +27 -0
- package/dist/commands/clear-command.js +107 -0
- package/dist/commands/registry.d.ts +20 -1
- package/dist/compaction/child.d.ts +15 -0
- package/dist/compaction/child.js +14 -3
- package/dist/compaction/compactor.js +1 -0
- package/dist/compaction/context-command.js +10 -1
- package/dist/compaction/meter.d.ts +9 -0
- package/dist/compaction/meter.js +16 -1
- package/dist/compaction/pressure.d.ts +7 -0
- package/dist/compaction/pressure.js +5 -0
- package/dist/compaction/types.d.ts +11 -0
- package/dist/compaction/wiring.js +1 -0
- package/dist/config/cli-commands.d.ts +1 -1
- package/dist/config/cli-commands.js +12 -0
- package/dist/config/load.js +24 -6
- package/dist/config/model-profile-resolution.d.ts +8 -0
- package/dist/config/model-profile-resolution.js +13 -6
- package/dist/config/model-profile-store.d.ts +4 -2
- package/dist/config/model-profiles.d.ts +21 -0
- package/dist/config/model-profiles.js +28 -2
- package/dist/config/schema.d.ts +61 -0
- package/dist/config/schema.js +36 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/team/model.d.ts +88 -0
- package/dist/team/model.js +136 -0
- package/dist/team/report.d.ts +11 -0
- package/dist/team/report.js +24 -1
- package/dist/team/subagent.js +37 -21
- package/dist/team/task-tool.d.ts +25 -6
- package/dist/team/task-tool.js +15 -9
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.js +44 -7
- package/dist/ui/entries/CompactionCard.d.ts +5 -4
- package/dist/ui/entries/CompactionCard.js +5 -4
- package/dist/ui/overlays/HelpOverlay.js +8 -2
- package/dist/ui/overlays/ModelDiscoveryList.d.ts +23 -0
- package/dist/ui/overlays/ModelDiscoveryList.js +161 -0
- package/dist/ui/overlays/ModelProfileEditor.d.ts +18 -1
- package/dist/ui/overlays/ModelProfileEditor.js +117 -54
- package/dist/ui/overlays/ModelProfilePicker.d.ts +14 -2
- package/dist/ui/overlays/ModelProfilePicker.js +51 -10
- package/dist/ui/overlays/QuestionOverlay.js +47 -6
- package/dist/ui/overlays/SettingsScreen.d.ts +17 -0
- package/dist/ui/overlays/SettingsScreen.js +152 -18
- package/dist/ui/overlays/model-probe-view.d.ts +128 -0
- package/dist/ui/overlays/model-probe-view.js +225 -0
- package/dist/ui/overlays/model-profile-state.d.ts +62 -5
- package/dist/ui/overlays/model-profile-state.js +121 -14
- package/dist/ui/overlays/use-model-probe.d.ts +30 -0
- package/dist/ui/overlays/use-model-probe.js +109 -0
- package/package.json +2 -2
package/dist/config/load.js
CHANGED
|
@@ -411,33 +411,44 @@ function resolveMaxTokens(flags, env, file) {
|
|
|
411
411
|
* `null`, and `null` is exactly the value being looked for. NO CLI FLAG feeds
|
|
412
412
|
* this - it is a per-installation correction for a model table entry, not a
|
|
413
413
|
* per-invocation choice, so `config set` and the environment variable are the
|
|
414
|
-
* two surfaces the design names.
|
|
414
|
+
* two surfaces the design names. The model profile bound as Main is the third
|
|
415
|
+
* (profile-context-window): its window arrives already merged into the `file`
|
|
416
|
+
* layer by `projectModelProfiles`, and `profileOwned` names it.
|
|
417
|
+
*
|
|
418
|
+
* THE ORIGIN CONTRACT (profile-context-window §6.1, shared verbatim with the
|
|
419
|
+
* save transaction): `origin !== undefined` IF AND ONLY IF `value !== null`.
|
|
420
|
+
* An explicit AUTO - profile set to auto, `config set contextWindow auto`,
|
|
421
|
+
* `ARAGON_CONTEXT_WINDOW=auto` - returns `{ value: null }` and NO origin,
|
|
422
|
+
* because "the user asserted this denominator" and "the user declared AUTO"
|
|
423
|
+
* are different facts and the meter's override branch only sees the first.
|
|
415
424
|
*/
|
|
416
|
-
function resolveContextWindow(env, file) {
|
|
425
|
+
function resolveContextWindow(env, file, profileOwned) {
|
|
417
426
|
const layers = [
|
|
418
427
|
{
|
|
419
428
|
name: 'ARAGON_CONTEXT_WINDOW',
|
|
420
429
|
value: hasOwn(env, 'contextWindow') ? env.contextWindow : undefined,
|
|
430
|
+
origin: 'env',
|
|
421
431
|
},
|
|
422
432
|
{
|
|
423
433
|
name: 'config.json contextWindow',
|
|
424
434
|
value: hasOwn(file, 'contextWindow') ? file.contextWindow : undefined,
|
|
435
|
+
origin: profileOwned ? 'profile' : 'config',
|
|
425
436
|
},
|
|
426
437
|
];
|
|
427
438
|
for (const layer of layers) {
|
|
428
439
|
if (layer.value === undefined)
|
|
429
440
|
continue; // this layer said nothing
|
|
430
441
|
if (layer.value === null || isAutoToken(layer.value))
|
|
431
|
-
return null; // AUTO
|
|
442
|
+
return { value: null }; // AUTO
|
|
432
443
|
const n = clampContextWindow(layer.value, null);
|
|
433
444
|
if (n !== null)
|
|
434
|
-
return n;
|
|
445
|
+
return { value: n, origin: layer.origin };
|
|
435
446
|
getLogger().warn('config', 'context_window_unusable', {
|
|
436
447
|
source: layer.name,
|
|
437
448
|
value: String(layer.value),
|
|
438
449
|
});
|
|
439
450
|
}
|
|
440
|
-
return DEFAULT_CONFIG.contextWindow;
|
|
451
|
+
return { value: DEFAULT_CONFIG.contextWindow };
|
|
441
452
|
}
|
|
442
453
|
/**
|
|
443
454
|
* Rewrite the cli<=0.6.16 persisted team ceiling defaults to 0 and persist the
|
|
@@ -513,7 +524,8 @@ export function loadConfig(flags = {}, options = {}) {
|
|
|
513
524
|
const liveToolOutput = pick(flags.liveToolOutput, env.partial.liveToolOutput, file.liveToolOutput) ??
|
|
514
525
|
DEFAULT_CONFIG.liveToolOutput;
|
|
515
526
|
const maxTokens = resolveMaxTokens(flags, env.partial, file);
|
|
516
|
-
const
|
|
527
|
+
const resolvedWindow = resolveContextWindow(env.partial, file, projection.mainOwnsContextWindow);
|
|
528
|
+
const contextWindow = resolvedWindow.value;
|
|
517
529
|
// `clampTheme` is also where the v0.3.0 `dark` name is migrated to `cool`,
|
|
518
530
|
// so reading a legacy config file needs no extra step here (§4.5 / R-4).
|
|
519
531
|
const theme = clampTheme(pick(flags.theme, env.partial.theme, file.theme), DEFAULT_CONFIG.theme);
|
|
@@ -609,6 +621,12 @@ export function loadConfig(flags = {}, options = {}) {
|
|
|
609
621
|
liveToolOutput,
|
|
610
622
|
maxTokens,
|
|
611
623
|
contextWindow,
|
|
624
|
+
// CONDITIONAL SPREAD so a feature-off config object stays byte-identical
|
|
625
|
+
// (invariant 6): with no override there is no origin KEY, not an undefined
|
|
626
|
+
// one, and every `toEqual` on a default config keeps passing untouched.
|
|
627
|
+
...(resolvedWindow.origin !== undefined
|
|
628
|
+
? { contextWindowOrigin: resolvedWindow.origin }
|
|
629
|
+
: {}),
|
|
612
630
|
theme,
|
|
613
631
|
reducedMotion,
|
|
614
632
|
exitTranscript: flags.exitTranscript !== undefined
|
|
@@ -4,6 +4,14 @@ export interface ProfileProjection {
|
|
|
4
4
|
file: Partial<PersistedConfig>;
|
|
5
5
|
state: ModelProfilesRuntime;
|
|
6
6
|
issues: ProfileValidationIssue[];
|
|
7
|
+
/**
|
|
8
|
+
* True when a valid library has Main bound, so the bound profile OWNS the
|
|
9
|
+
* context-window denominator (profile-context-window tradeoff B). THE ONE
|
|
10
|
+
* AUTHORITY for "does the profile decide the window" - load.ts and the save
|
|
11
|
+
* transaction both read it rather than comparing numbers, which would lie on
|
|
12
|
+
* a coincidental match.
|
|
13
|
+
*/
|
|
14
|
+
mainOwnsContextWindow: boolean;
|
|
7
15
|
}
|
|
8
16
|
export interface RoleKeyInput {
|
|
9
17
|
config: CliConfig;
|
|
@@ -9,8 +9,9 @@ export function projectModelProfiles(file) {
|
|
|
9
9
|
const issues = validateModelProfiles(file.modelProfiles);
|
|
10
10
|
const state = emptyModelProfileState(issues.length > 0);
|
|
11
11
|
const projected = { ...file };
|
|
12
|
-
if (issues.length || !file.modelProfiles)
|
|
13
|
-
return { file: projected, state, issues };
|
|
12
|
+
if (issues.length || !file.modelProfiles) {
|
|
13
|
+
return { file: projected, state, issues, mainOwnsContextWindow: false };
|
|
14
|
+
}
|
|
14
15
|
const library = normalizeModelProfiles(file.modelProfiles);
|
|
15
16
|
projected.modelProfiles = library;
|
|
16
17
|
for (const role of ['main', 'fast']) {
|
|
@@ -20,13 +21,19 @@ export function projectModelProfiles(file) {
|
|
|
20
21
|
state[role] = { selectedId: entry.id, appliedId: entry.id,
|
|
21
22
|
overriddenFields: [], invalid: false };
|
|
22
23
|
const connection = { provider: entry.provider, model: entry.model, baseUrl: entry.baseUrl };
|
|
23
|
-
if (role === 'main')
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
if (role === 'main') {
|
|
25
|
+
// The profile owns the denominator: project BOTH a number and null
|
|
26
|
+
// (null = AUTO), so "profile set auto" stays distinguishable from
|
|
27
|
+
// "no profile bound" (profile-context-window tradeoff B).
|
|
28
|
+
Object.assign(projected, connection, { contextWindow: entry.contextWindow });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
26
31
|
projected.fast = { ...DEFAULT_CONFIG.fast, ...file.fast, ...connection,
|
|
27
32
|
baseUrl: entry.baseUrl ?? '' };
|
|
33
|
+
}
|
|
28
34
|
}
|
|
29
|
-
return { file: projected, state, issues
|
|
35
|
+
return { file: projected, state, issues,
|
|
36
|
+
mainOwnsContextWindow: state.main.appliedId !== null };
|
|
30
37
|
}
|
|
31
38
|
/** Derive binding status from current connections, including session/model overrides. */
|
|
32
39
|
export function resolveModelProfileState(config, profiles = config.modelProfiles, invalid = false) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { type ModelProfilesConfig, type ModelRole, type ProfileValidationIssue } from './model-profiles.js';
|
|
2
|
-
import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig } from './schema.js';
|
|
3
|
-
export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log'>> {
|
|
2
|
+
import type { CliConfig, CompactionConfig, FastConfig, LogConfig, PersistedConfig, TeamConfig } from './schema.js';
|
|
3
|
+
export interface ModelSettingsPatch extends Partial<Omit<PersistedConfig, 'modelProfiles' | 'fast' | 'compaction' | 'log' | 'team'>> {
|
|
4
4
|
fast?: Partial<FastConfig>;
|
|
5
5
|
compaction?: Partial<CompactionConfig>;
|
|
6
6
|
log?: Partial<LogConfig>;
|
|
7
|
+
/** The subagent-model picker writes here (subagent-model D-5/I-5): draft-only until Save. */
|
|
8
|
+
team?: Partial<TeamConfig>;
|
|
7
9
|
}
|
|
8
10
|
export interface ModelSettingsDraft {
|
|
9
11
|
profiles?: ModelProfilesConfig;
|
|
@@ -6,6 +6,8 @@ export interface ModelProfile {
|
|
|
6
6
|
model: string;
|
|
7
7
|
baseUrl: string | null;
|
|
8
8
|
apiKey: string | null;
|
|
9
|
+
/** The context window of this model+endpoint; null = AUTO (profile-context-window 5.1). */
|
|
10
|
+
contextWindow: number | null;
|
|
9
11
|
}
|
|
10
12
|
export interface ModelProfilesConfig {
|
|
11
13
|
version: 1;
|
|
@@ -32,8 +34,27 @@ export interface ProfileValidationIssue {
|
|
|
32
34
|
export declare function emptyModelProfiles(): ModelProfilesConfig;
|
|
33
35
|
/** Return independently mutable role state. */
|
|
34
36
|
export declare function emptyModelProfileState(invalid?: boolean): ModelProfilesRuntime;
|
|
37
|
+
/**
|
|
38
|
+
* The endpoint rule published as a predicate (model-profile-connection-test §5 row 10).
|
|
39
|
+
*
|
|
40
|
+
* The probe's `invalid_base_url` guard has to use THE SAME ruler as validation, or a
|
|
41
|
+
* URL the editor accepts can still be refused by the test (and the reverse). A second
|
|
42
|
+
* copy of these four clauses is one edit away from disagreeing with the config layer.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isValidEndpointUrl(value: string | null | undefined): boolean;
|
|
35
45
|
/** Validate untrusted input; diagnostics never include supplied values. */
|
|
36
46
|
export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
|
|
47
|
+
/**
|
|
48
|
+
* Disk-tolerance gate for a profile-level context window (profile-context-window 5.3).
|
|
49
|
+
*
|
|
50
|
+
* THE SAME TRI-STATE DISCIPLINE as store.ts normalizeContextWindow, kept out of
|
|
51
|
+
* store.ts on purpose: this file already imports schema.js and store's sibling
|
|
52
|
+
* model-profile-store.ts already imports this one, so placing it here avoids
|
|
53
|
+
* widening the existing store <-> model-profile-store cycle. A garbage value on
|
|
54
|
+
* a hand-edited file degrades to AUTO; it NEVER invalidates the whole library
|
|
55
|
+
* (invariant 4 - one bad number must not drop the user's credentials).
|
|
56
|
+
*/
|
|
57
|
+
export declare function normalizeProfileContextWindow(value: unknown): number | null;
|
|
37
58
|
/** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
|
|
38
59
|
export declare function normalizeModelProfiles(value: unknown): ModelProfilesConfig;
|
|
39
60
|
/** Create an entry with a stable UUID and a frozen default name. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { ADAPTER_PROVIDERS } from './schema.js';
|
|
2
|
+
import { ADAPTER_PROVIDERS, clampContextWindow, isAutoToken } from './schema.js';
|
|
3
3
|
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
4
4
|
/** Return a fresh empty library without initiating a disk migration. */
|
|
5
5
|
export function emptyModelProfiles() {
|
|
@@ -32,6 +32,16 @@ function validUrl(value) {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The endpoint rule published as a predicate (model-profile-connection-test §5 row 10).
|
|
37
|
+
*
|
|
38
|
+
* The probe's `invalid_base_url` guard has to use THE SAME ruler as validation, or a
|
|
39
|
+
* URL the editor accepts can still be refused by the test (and the reverse). A second
|
|
40
|
+
* copy of these four clauses is one edit away from disagreeing with the config layer.
|
|
41
|
+
*/
|
|
42
|
+
export function isValidEndpointUrl(value) {
|
|
43
|
+
return validUrl(value);
|
|
44
|
+
}
|
|
35
45
|
function validateEntry(value, index) {
|
|
36
46
|
const path = `modelProfiles.entries[${index}]`;
|
|
37
47
|
const invalid = (field, message) => ({
|
|
@@ -91,6 +101,21 @@ export function validateModelProfiles(value) {
|
|
|
91
101
|
}
|
|
92
102
|
return issues;
|
|
93
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Disk-tolerance gate for a profile-level context window (profile-context-window 5.3).
|
|
106
|
+
*
|
|
107
|
+
* THE SAME TRI-STATE DISCIPLINE as store.ts normalizeContextWindow, kept out of
|
|
108
|
+
* store.ts on purpose: this file already imports schema.js and store's sibling
|
|
109
|
+
* model-profile-store.ts already imports this one, so placing it here avoids
|
|
110
|
+
* widening the existing store <-> model-profile-store cycle. A garbage value on
|
|
111
|
+
* a hand-edited file degrades to AUTO; it NEVER invalidates the whole library
|
|
112
|
+
* (invariant 4 - one bad number must not drop the user's credentials).
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeProfileContextWindow(value) {
|
|
115
|
+
if (value === undefined || value === null || isAutoToken(value))
|
|
116
|
+
return null;
|
|
117
|
+
return clampContextWindow(value, null);
|
|
118
|
+
}
|
|
94
119
|
/** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
|
|
95
120
|
export function normalizeModelProfiles(value) {
|
|
96
121
|
if (validateModelProfiles(value).length)
|
|
@@ -102,7 +127,8 @@ export function normalizeModelProfiles(value) {
|
|
|
102
127
|
entries: library.entries.map((entry) => ({ id: entry.id, name: entry.name.trim()
|
|
103
128
|
|| [...entry.model.trim()].slice(0, 80).join(''), provider: entry.provider,
|
|
104
129
|
model: entry.model.trim(), baseUrl: entry.baseUrl?.trim() || null,
|
|
105
|
-
apiKey: entry.apiKey?.trim() || null
|
|
130
|
+
apiKey: entry.apiKey?.trim() || null,
|
|
131
|
+
contextWindow: normalizeProfileContextWindow(entry.contextWindow) })) };
|
|
106
132
|
}
|
|
107
133
|
/** Create an entry with a stable UUID and a frozen default name. */
|
|
108
134
|
export function createModelProfile(input) {
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -99,6 +99,35 @@ export declare const MAX_CONTEXT_WINDOW = 5000000;
|
|
|
99
99
|
* this deliberately copies its shape rather than inventing a second convention.
|
|
100
100
|
*/
|
|
101
101
|
export declare function clampContextWindow(v: unknown, fallback: number | null): number | null;
|
|
102
|
+
/**
|
|
103
|
+
* Where the effective `contextWindow` override came from
|
|
104
|
+
* (profile-context-window §5.2).
|
|
105
|
+
*
|
|
106
|
+
* A CLI-LOCAL RUNTIME FACT: never persisted, never promoted into Core's event
|
|
107
|
+
* shapes. It exists so `/context` can name WHICH surface supplied a
|
|
108
|
+
* denominator the user asserted - the environment, the legacy top-level key,
|
|
109
|
+
* or the model profile bound as Main.
|
|
110
|
+
*/
|
|
111
|
+
export type ContextWindowOrigin = 'env' | 'config' | 'profile';
|
|
112
|
+
/**
|
|
113
|
+
* Parse text typed into the settings screen or the profile editor for a
|
|
114
|
+
* context window (profile-context-window §5.3).
|
|
115
|
+
*
|
|
116
|
+
* THE MIRROR OF `parseMaxTokensInput` BELOW, down to the tri-state shape: it
|
|
117
|
+
* distinguishes AUTO from "the user typed nonsense", which `clampContextWindow`
|
|
118
|
+
* cannot - a typo must change NOTHING rather than silently discard the number
|
|
119
|
+
* the user meant to set. `''` / `'auto'` / `'0'` are AUTO (`isAutoToken`);
|
|
120
|
+
* a digit run is clamped through THE single gate; anything else is invalid.
|
|
121
|
+
*/
|
|
122
|
+
export declare function parseContextWindowInput(raw: string): {
|
|
123
|
+
kind: 'auto';
|
|
124
|
+
} | {
|
|
125
|
+
kind: 'value';
|
|
126
|
+
value: number;
|
|
127
|
+
clamped: boolean;
|
|
128
|
+
} | {
|
|
129
|
+
kind: 'invalid';
|
|
130
|
+
};
|
|
102
131
|
/**
|
|
103
132
|
* Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
|
|
104
133
|
*
|
|
@@ -356,6 +385,26 @@ export interface TeamConfig {
|
|
|
356
385
|
* meaningful while `team.overseer` is true.
|
|
357
386
|
*/
|
|
358
387
|
overseerIntervalMs: number;
|
|
388
|
+
/**
|
|
389
|
+
* Which model a team child runs on (subagent-model-and-question-controls
|
|
390
|
+
* D-2). `'main'` = the lead's own connection - the default and the
|
|
391
|
+
* historical parity behavior; `'fast'` = the fast tier, resolved per
|
|
392
|
+
* dispatch through `resolveFastTier`; `'profile'` = the entry named by
|
|
393
|
+
* `team.modelProfileId`.
|
|
394
|
+
*
|
|
395
|
+
* A USER-OWNED SETTING, NEVER A TOOL PARAMETER (D-1): `normalizeSubagentSpecs`
|
|
396
|
+
* keeps silently dropping a stale `model` key, so the MODEL cannot downgrade
|
|
397
|
+
* its own delegation. This key is the one explicit, visible, reversible
|
|
398
|
+
* switch the human owns.
|
|
399
|
+
*/
|
|
400
|
+
model: 'main' | 'fast' | 'profile';
|
|
401
|
+
/**
|
|
402
|
+
* The profile entry a `model: 'profile'` dispatch binds to; `null` = not
|
|
403
|
+
* bound. IGNORED by the other modes. Existence is NOT validated here - a
|
|
404
|
+
* dangling id degrades to the main model with a disclosed reason at
|
|
405
|
+
* resolution time (D-4), so deleting a profile can never break a dispatch.
|
|
406
|
+
*/
|
|
407
|
+
modelProfileId: string | null;
|
|
359
408
|
}
|
|
360
409
|
/**
|
|
361
410
|
* The requirement's ceiling on the fan-out (R-g).
|
|
@@ -967,6 +1016,18 @@ export interface CliConfig {
|
|
|
967
1016
|
* would parse and be discarded, with nothing failing.
|
|
968
1017
|
*/
|
|
969
1018
|
contextWindow: number | null;
|
|
1019
|
+
/**
|
|
1020
|
+
* WHERE the override above came from, or `undefined` when there is none
|
|
1021
|
+
* (profile-context-window §5.2). RUNTIME-ONLY, never persisted.
|
|
1022
|
+
*
|
|
1023
|
+
* THE CONTRACT IS AN IFF: `contextWindowOrigin !== undefined` exactly when
|
|
1024
|
+
* `contextWindow !== null`. An explicit AUTO (profile set to auto,
|
|
1025
|
+
* `config set contextWindow auto`, `ARAGON_CONTEXT_WINDOW=auto`) has value
|
|
1026
|
+
* null, so its origin is undefined - "the user asserted this denominator"
|
|
1027
|
+
* and "the user declared AUTO" are different facts, and the meter's
|
|
1028
|
+
* override branch only ever sees the first.
|
|
1029
|
+
*/
|
|
1030
|
+
contextWindowOrigin?: ContextWindowOrigin;
|
|
970
1031
|
theme: ThemeName;
|
|
971
1032
|
reducedMotion: boolean;
|
|
972
1033
|
/** Replay a plain-text summary on exit (full-screen only — see §4.4). */
|
package/dist/config/schema.js
CHANGED
|
@@ -148,6 +148,27 @@ export function clampContextWindow(v, fallback) {
|
|
|
148
148
|
return fallback;
|
|
149
149
|
return Math.min(MAX_CONTEXT_WINDOW, Math.max(MIN_CONTEXT_WINDOW, Math.floor(n)));
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse text typed into the settings screen or the profile editor for a
|
|
153
|
+
* context window (profile-context-window §5.3).
|
|
154
|
+
*
|
|
155
|
+
* THE MIRROR OF `parseMaxTokensInput` BELOW, down to the tri-state shape: it
|
|
156
|
+
* distinguishes AUTO from "the user typed nonsense", which `clampContextWindow`
|
|
157
|
+
* cannot - a typo must change NOTHING rather than silently discard the number
|
|
158
|
+
* the user meant to set. `''` / `'auto'` / `'0'` are AUTO (`isAutoToken`);
|
|
159
|
+
* a digit run is clamped through THE single gate; anything else is invalid.
|
|
160
|
+
*/
|
|
161
|
+
export function parseContextWindowInput(raw) {
|
|
162
|
+
const trimmed = String(raw ?? '').trim();
|
|
163
|
+
if (isAutoToken(trimmed))
|
|
164
|
+
return { kind: 'auto' };
|
|
165
|
+
if (!/^\d+$/.test(trimmed))
|
|
166
|
+
return { kind: 'invalid' };
|
|
167
|
+
const value = clampContextWindow(trimmed, null);
|
|
168
|
+
if (value === null)
|
|
169
|
+
return { kind: 'invalid' };
|
|
170
|
+
return { kind: 'value', value, clamped: value !== Number.parseInt(trimmed, 10) };
|
|
171
|
+
}
|
|
151
172
|
/**
|
|
152
173
|
* Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
|
|
153
174
|
*
|
|
@@ -457,6 +478,10 @@ export const DEFAULT_TEAM_CONFIG = {
|
|
|
457
478
|
overseer: true,
|
|
458
479
|
// 0 = the structural cadence default (300 s). See the field docstring.
|
|
459
480
|
overseerIntervalMs: 0,
|
|
481
|
+
// The parity default: children run the lead's own model until the user
|
|
482
|
+
// says otherwise (AC-1 - an upgraded config behaves exactly as before).
|
|
483
|
+
model: 'main',
|
|
484
|
+
modelProfileId: null,
|
|
460
485
|
};
|
|
461
486
|
const SUBAGENTS_RANGE = { min: 1, max: HARD_MAX_SUBAGENTS };
|
|
462
487
|
const CONCURRENT_RANGE = { min: 1, max: HARD_MAX_SUBAGENTS };
|
|
@@ -490,6 +515,17 @@ export function clampTeamConfig(raw) {
|
|
|
490
515
|
maxTurnsPerSubagent: clampIntAllowingZero(src.maxTurnsPerSubagent, DEFAULT_TEAM_CONFIG.maxTurnsPerSubagent, TURNS_RANGE),
|
|
491
516
|
overseer: bool(src.overseer, DEFAULT_TEAM_CONFIG.overseer),
|
|
492
517
|
overseerIntervalMs: clampIntAllowingZero(src.overseerIntervalMs, DEFAULT_TEAM_CONFIG.overseerIntervalMs, OVERSEER_INTERVAL_RANGE),
|
|
518
|
+
// typo-changes-nothing, the discipline every enum clamp here follows: a
|
|
519
|
+
// hand-edited `model: 'fats'` resolves to 'main' (the parity default)
|
|
520
|
+
// rather than invalidating the file or refusing to launch. A bad
|
|
521
|
+
// `modelProfileId` (wrong type, or over the 128-codepoint budget
|
|
522
|
+
// `model-profiles.ts::validText` defines) is nulled the same way;
|
|
523
|
+
// reference EXISTENCE stays a resolution-time concern (D-4).
|
|
524
|
+
model: src.model === 'fast' || src.model === 'profile' ? src.model : 'main',
|
|
525
|
+
modelProfileId: typeof src.modelProfileId === 'string' && src.modelProfileId.length > 0
|
|
526
|
+
&& [...src.modelProfileId].length <= 128
|
|
527
|
+
? src.modelProfileId
|
|
528
|
+
: null,
|
|
493
529
|
};
|
|
494
530
|
}
|
|
495
531
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive the conversation a `/clear` is about to destroy
|
|
3
|
+
* (clear-command-context-reset §3.8 / D-5 / I-5).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS AT ALL: a command whose whole job is to destroy context is a
|
|
8
|
+
* command a user will eventually fire by accident, one keystroke from `/cwd`. The
|
|
9
|
+
* difference between "I lost an hour of work" and "I resumed it" is one file, and
|
|
10
|
+
* `saveSession()` already knows how to write it.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS NOT `session/store.ts::writeSession()`: that function MOVES THE
|
|
13
|
+
* `--continue` POINTER and writes `meta`. An archive is not a session you continue;
|
|
14
|
+
* it is a record you resume BY PATH. Writing `meta` would also make the file look
|
|
15
|
+
* `aragon exec`-managed to `sessions list` / `prune`, which is the scoping rule
|
|
16
|
+
* `persist.ts` records at the top of `SessionMeta` - so a maintenance command would
|
|
17
|
+
* start deleting the user's cleared conversations.
|
|
18
|
+
*
|
|
19
|
+
* ISOLATION IS TWO DEEP (I-6 / P2-4). The directory is the first fence:
|
|
20
|
+
* `listSessions` / `pruneSessions` / `latestSessionForCwd` read `sessionsDir()`
|
|
21
|
+
* non-recursively and skip anything not ending in `.json` (`store.ts`), so a
|
|
22
|
+
* SUBDIRECTORY name is invisible to all three by construction. Omitting `meta` is
|
|
23
|
+
* the second fence, for the day someone makes that listing recursive or gives
|
|
24
|
+
* `--all` a wider meaning.
|
|
25
|
+
*/
|
|
26
|
+
import type { Message, ModelRef } from '@aragon-agent/core';
|
|
27
|
+
import type { Entry } from '../agent/reducer.js';
|
|
28
|
+
import type { TodoItem } from '../todo/types.js';
|
|
29
|
+
import type { CompactionIdentity } from '../compaction/memory-identity.js';
|
|
30
|
+
/** Everything the archive writes. `entries` is supplied by the command layer. */
|
|
31
|
+
export interface ClearedArchiveInput {
|
|
32
|
+
model: ModelRef;
|
|
33
|
+
messages: Message[];
|
|
34
|
+
compactionIdentity?: CompactionIdentity;
|
|
35
|
+
/** The screen transcript, which lives only in `ViewState` (P0-1). */
|
|
36
|
+
entries: Entry[];
|
|
37
|
+
todos: TodoItem[];
|
|
38
|
+
}
|
|
39
|
+
/** Bounded retention: the newest N cleared conversations survive. */
|
|
40
|
+
export declare const CLEAR_ARCHIVE_LIMITS: {
|
|
41
|
+
readonly maxArchives: 20;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Best-effort archive of the conversation being cleared.
|
|
45
|
+
*
|
|
46
|
+
* @returns the absolute path of the written file, or `null` when there was
|
|
47
|
+
* nothing to save (an empty conversation) or the write failed. NEVER THROWS:
|
|
48
|
+
* a full disk must not be able to stop a user from starting a new conversation
|
|
49
|
+
* (I-5), and a caller that had to wrap this in `try` would eventually forget.
|
|
50
|
+
*/
|
|
51
|
+
export declare function archiveClearedConversation(input: ClearedArchiveInput): string | null;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive the conversation a `/clear` is about to destroy
|
|
3
|
+
* (clear-command-context-reset §3.8 / D-5 / I-5).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/session/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS AT ALL: a command whose whole job is to destroy context is a
|
|
8
|
+
* command a user will eventually fire by accident, one keystroke from `/cwd`. The
|
|
9
|
+
* difference between "I lost an hour of work" and "I resumed it" is one file, and
|
|
10
|
+
* `saveSession()` already knows how to write it.
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS IS NOT `session/store.ts::writeSession()`: that function MOVES THE
|
|
13
|
+
* `--continue` POINTER and writes `meta`. An archive is not a session you continue;
|
|
14
|
+
* it is a record you resume BY PATH. Writing `meta` would also make the file look
|
|
15
|
+
* `aragon exec`-managed to `sessions list` / `prune`, which is the scoping rule
|
|
16
|
+
* `persist.ts` records at the top of `SessionMeta` - so a maintenance command would
|
|
17
|
+
* start deleting the user's cleared conversations.
|
|
18
|
+
*
|
|
19
|
+
* ISOLATION IS TWO DEEP (I-6 / P2-4). The directory is the first fence:
|
|
20
|
+
* `listSessions` / `pruneSessions` / `latestSessionForCwd` read `sessionsDir()`
|
|
21
|
+
* non-recursively and skip anything not ending in `.json` (`store.ts`), so a
|
|
22
|
+
* SUBDIRECTORY name is invisible to all three by construction. Omitting `meta` is
|
|
23
|
+
* the second fence, for the day someone makes that listing recursive or gives
|
|
24
|
+
* `--all` a wider meaning.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import process from 'node:process';
|
|
29
|
+
import { getLogger } from '../logging/logger.js';
|
|
30
|
+
import { saveSession } from './persist.js';
|
|
31
|
+
import { sessionsDir } from './store.js';
|
|
32
|
+
/** Bounded retention: the newest N cleared conversations survive. */
|
|
33
|
+
export const CLEAR_ARCHIVE_LIMITS = { maxArchives: 20 };
|
|
34
|
+
/** `<sessions>/cleared` - a subdirectory, which is the first isolation fence. */
|
|
35
|
+
const CLEARED_DIR_NAME = 'cleared';
|
|
36
|
+
/** Every archive's filename starts with this, which is what `prune` selects on. */
|
|
37
|
+
const ARCHIVE_PREFIX = 'cleared-';
|
|
38
|
+
/**
|
|
39
|
+
* Best-effort archive of the conversation being cleared.
|
|
40
|
+
*
|
|
41
|
+
* @returns the absolute path of the written file, or `null` when there was
|
|
42
|
+
* nothing to save (an empty conversation) or the write failed. NEVER THROWS:
|
|
43
|
+
* a full disk must not be able to stop a user from starting a new conversation
|
|
44
|
+
* (I-5), and a caller that had to wrap this in `try` would eventually forget.
|
|
45
|
+
*/
|
|
46
|
+
export function archiveClearedConversation(input) {
|
|
47
|
+
// Clearing an empty conversation produces no file. Without this, every
|
|
48
|
+
// `/clear` on a fresh session would litter the directory with empty archives
|
|
49
|
+
// and push a real one out of the retention window.
|
|
50
|
+
if (input.messages.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const dir = join(sessionsDir(), CLEARED_DIR_NAME);
|
|
53
|
+
const path = join(dir, `${archiveId(new Date())}.json`);
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
getLogger().debug('agent', 'clear_archive_failed', { reason: reasonOf(err) });
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// DEGRADE AND RETRY (P1-6 / C-8). `saveSession` VALIDATES the compaction
|
|
62
|
+
// identity and THROWS on a damaged or stale one, and an interrupted
|
|
63
|
+
// conversation - the one a user most wants back - is exactly the one most
|
|
64
|
+
// likely to carry a stale identity. Losing a whole conversation to an
|
|
65
|
+
// accessory field would break the only promise this module makes, so the
|
|
66
|
+
// second attempt drops it: `messages` is what makes the archive recoverable,
|
|
67
|
+
// the identity only lets the NEXT compaction reuse a prefix.
|
|
68
|
+
//
|
|
69
|
+
// The retry runs ONLY when there is an identity to drop. A disk that refused
|
|
70
|
+
// the first write will refuse an identical second one, so retrying without one
|
|
71
|
+
// would just double the log line and the wait without changing the outcome.
|
|
72
|
+
const wrote = writeArchive(path, input, true)
|
|
73
|
+
|| (input.compactionIdentity !== undefined && writeArchive(path, input, false));
|
|
74
|
+
if (!wrote)
|
|
75
|
+
return null;
|
|
76
|
+
pruneArchives(dir);
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One write attempt. `withIdentity` is the only difference between the first and
|
|
81
|
+
* the degraded second attempt, which is why it is a parameter rather than a
|
|
82
|
+
* duplicated body.
|
|
83
|
+
*/
|
|
84
|
+
function writeArchive(path, input, withIdentity) {
|
|
85
|
+
const identity = withIdentity ? input.compactionIdentity : undefined;
|
|
86
|
+
try {
|
|
87
|
+
saveSession(path, {
|
|
88
|
+
model: input.model,
|
|
89
|
+
messages: input.messages,
|
|
90
|
+
...(identity ? { compactionIdentity: identity } : {}),
|
|
91
|
+
entries: input.entries,
|
|
92
|
+
todos: input.todos,
|
|
93
|
+
});
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
getLogger().debug('agent', 'clear_archive_write_failed', {
|
|
98
|
+
withIdentity, reason: reasonOf(err),
|
|
99
|
+
});
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Deterministic, collision-free id (P2-3).
|
|
105
|
+
*
|
|
106
|
+
* `cleared-<yyyyMMdd>-<HHmmss>-<pid>` is ASCII, matches `isValidSessionId`
|
|
107
|
+
* (`[A-Za-z0-9][A-Za-z0-9._-]*`), and is legible enough to type after
|
|
108
|
+
* `/resume `. The pid removes cross-process collisions; within one process a
|
|
109
|
+
* second `cleared-` file in the same second is unreachable because the FIRST
|
|
110
|
+
* clear leaves an empty conversation and `archiveClearedConversation` returns
|
|
111
|
+
* early on that. So no `-2`/`-3` suffix ladder is needed, and none is here.
|
|
112
|
+
*/
|
|
113
|
+
function archiveId(at) {
|
|
114
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
115
|
+
const day = `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}`;
|
|
116
|
+
const clock = `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`;
|
|
117
|
+
return `${ARCHIVE_PREFIX}${day}-${clock}-${process.pid}`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Keep the newest `maxArchives` files; delete the rest.
|
|
121
|
+
*
|
|
122
|
+
* A sweep, not a scheduled job: retention that runs on the write path cannot be
|
|
123
|
+
* forgotten, and the failure mode of a missed sweep is bounded disk usage rather
|
|
124
|
+
* than unbounded. Every error here is swallowed on purpose - pruning is
|
|
125
|
+
* housekeeping and must never be able to fail a clear that already succeeded.
|
|
126
|
+
*/
|
|
127
|
+
function pruneArchives(dir) {
|
|
128
|
+
const survivors = [];
|
|
129
|
+
for (const name of safeReaddir(dir)) {
|
|
130
|
+
if (!name.startsWith(ARCHIVE_PREFIX) || !name.endsWith('.json'))
|
|
131
|
+
continue;
|
|
132
|
+
const path = join(dir, name);
|
|
133
|
+
try {
|
|
134
|
+
survivors.push({ path, modified: statSync(path).mtimeMs });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// A file that vanished between listing and stat is already deleted.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
survivors.sort((a, b) => b.modified - a.modified);
|
|
141
|
+
for (const doomed of survivors.slice(CLEAR_ARCHIVE_LIMITS.maxArchives)) {
|
|
142
|
+
try {
|
|
143
|
+
unlinkSync(doomed.path);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
getLogger().debug('agent', 'clear_archive_prune_failed', { reason: reasonOf(err) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function safeReaddir(dir) {
|
|
151
|
+
try {
|
|
152
|
+
return readdirSync(dir);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function reasonOf(err) {
|
|
159
|
+
return err instanceof Error ? err.message : String(err);
|
|
160
|
+
}
|