@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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The probe's VIEW MODEL (model-profile-connection-test §3.2 / §6.3).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: the editor's row list, the result sentences, the candidate
|
|
5
|
+
* ordering and the two number formatters. Rendering and input both read
|
|
6
|
+
* `editorRows()`, so "which row am I on" has exactly one definition and inserting a
|
|
7
|
+
* row can no longer shift `apiKey` off index 6 by arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* ASCII ONLY (`ui/` is inside `glyphs.test.ts::inScope`).
|
|
10
|
+
*/
|
|
11
|
+
import { PROBE_LIMITS } from '../../agent/model-probe-limits.js';
|
|
12
|
+
import { resolveDraftKey } from '../../agent/model-probe.js';
|
|
13
|
+
import { profileEndpointSummary } from './ModelProfilePicker.js';
|
|
14
|
+
export const EDITOR_FIELDS = ['name', 'provider', 'model', 'contextWindow', 'baseUrl',
|
|
15
|
+
'credentialMode', 'apiKey'];
|
|
16
|
+
export const EDITOR_LABELS = ['Name', 'Provider', 'Model', 'Context window', 'Base URL',
|
|
17
|
+
'Credential mode', 'API key'];
|
|
18
|
+
function idleView(hasKey) {
|
|
19
|
+
return { kind: 'idle', reason: hasKey ? 'no_result' : 'no_key' };
|
|
20
|
+
}
|
|
21
|
+
function hasKey(target) {
|
|
22
|
+
return Boolean(target.apiKey && target.apiKey.trim().length > 0);
|
|
23
|
+
}
|
|
24
|
+
/** Whether pressing a probe action can produce anything at all (AC-12 / AC-15). */
|
|
25
|
+
export function canProbe(view) {
|
|
26
|
+
return !(view.kind === 'idle' && (view.reason === 'no_key' || view.reason === 'unavailable'));
|
|
27
|
+
}
|
|
28
|
+
function testView(test, target, probe) {
|
|
29
|
+
if (!probe)
|
|
30
|
+
return { kind: 'idle', reason: 'unavailable' };
|
|
31
|
+
if (test.phase === 'testing') {
|
|
32
|
+
return test.targetId === probe.targetFingerprint(target)
|
|
33
|
+
? { kind: 'testing' } : idleView(hasKey(target));
|
|
34
|
+
}
|
|
35
|
+
if (test.phase === 'tested') {
|
|
36
|
+
return test.outcome.targetId === probe.targetFingerprint(target)
|
|
37
|
+
? { kind: 'result', outcome: test.outcome } : idleView(hasKey(target));
|
|
38
|
+
}
|
|
39
|
+
return idleView(hasKey(target));
|
|
40
|
+
}
|
|
41
|
+
function discoveryView(state, target, probe) {
|
|
42
|
+
if (!probe)
|
|
43
|
+
return { kind: 'idle' };
|
|
44
|
+
if (state.phase === 'discovering') {
|
|
45
|
+
return state.connectionId === probe.connectionFingerprint(target) ? { kind: 'discovering' }
|
|
46
|
+
: { kind: 'idle' };
|
|
47
|
+
}
|
|
48
|
+
if (state.phase === 'discovered') {
|
|
49
|
+
return state.outcome.connectionId === probe.connectionFingerprint(target)
|
|
50
|
+
? { kind: 'discovery', outcome: state.outcome } : { kind: 'idle' };
|
|
51
|
+
}
|
|
52
|
+
return { kind: 'idle' };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Filter both slots down to what the CURRENT target may honestly claim (§4.6).
|
|
56
|
+
*
|
|
57
|
+
* A `OK 812 ms` that describes a model the user has since edited is worse than no
|
|
58
|
+
* result at all, so a fingerprint mismatch degrades to `idle` - no effect, no
|
|
59
|
+
* reset call, no cross-page synchronisation.
|
|
60
|
+
*/
|
|
61
|
+
export function visibleProbeViews(test, discovery, target, probe) {
|
|
62
|
+
return { test: testView(test, target, probe), discovery: discoveryView(discovery, target, probe) };
|
|
63
|
+
}
|
|
64
|
+
/** The exact target a draft would use - the resolved key included (§4.2.1). */
|
|
65
|
+
export function editorProbeTarget(editor, apiKeys) {
|
|
66
|
+
return {
|
|
67
|
+
provider: editor.provider,
|
|
68
|
+
model: editor.model,
|
|
69
|
+
baseUrl: editor.baseUrl.trim().length > 0 ? editor.baseUrl.trim() : null,
|
|
70
|
+
apiKey: resolveDraftKey({ credentialMode: editor.credentialMode, draftKey: editor.apiKey,
|
|
71
|
+
existingKey: editor.original.apiKey, sharedKey: apiKeys[editor.provider] }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** `812 ms` / `1.4 s` - one decimal only once a second has passed. */
|
|
75
|
+
export function latencyLabel(ms) {
|
|
76
|
+
const safe = Number.isFinite(ms) && ms >= 0 ? ms : 0;
|
|
77
|
+
return safe < 1000 ? `${Math.round(safe)} ms` : `${(safe / 1000).toFixed(1)} s`;
|
|
78
|
+
}
|
|
79
|
+
/** `128K` / `1M` / `auto` - `auto` when the vendor did not report a window. */
|
|
80
|
+
export function windowLabel(window) {
|
|
81
|
+
if (!window || !Number.isFinite(window))
|
|
82
|
+
return 'auto';
|
|
83
|
+
if (window >= 1_000_000) {
|
|
84
|
+
const millions = window / 1_000_000;
|
|
85
|
+
return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
|
|
86
|
+
}
|
|
87
|
+
if (window >= 1000)
|
|
88
|
+
return `${Math.round(window / 1000)}K`;
|
|
89
|
+
return String(Math.round(window));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Clean and order a discovered list: one row per id, and the model the draft
|
|
93
|
+
* already names first, so "which one am I on" is answerable without scrolling.
|
|
94
|
+
*/
|
|
95
|
+
export function rankCandidates(models, currentModel) {
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const unique = [];
|
|
98
|
+
for (const model of models) {
|
|
99
|
+
if (model.id.length === 0 || seen.has(model.id))
|
|
100
|
+
continue;
|
|
101
|
+
seen.add(model.id);
|
|
102
|
+
unique.push(model);
|
|
103
|
+
}
|
|
104
|
+
const wanted = currentModel.trim().toLowerCase();
|
|
105
|
+
if (wanted.length === 0)
|
|
106
|
+
return unique;
|
|
107
|
+
const matches = (model) => model.id.toLowerCase() === wanted;
|
|
108
|
+
const prefixed = (model) => model.id.toLowerCase().startsWith(wanted);
|
|
109
|
+
return [...unique.filter(matches), ...unique.filter((model) => !matches(model) && prefixed(model)),
|
|
110
|
+
...unique.filter((model) => !matches(model) && !prefixed(model))];
|
|
111
|
+
}
|
|
112
|
+
function usagePart(outcome) {
|
|
113
|
+
if (!outcome.usage)
|
|
114
|
+
return '';
|
|
115
|
+
return ` | ${outcome.usage.inputTokens} in / ${outcome.usage.outputTokens} out`;
|
|
116
|
+
}
|
|
117
|
+
function fixLine(outcome) {
|
|
118
|
+
if (outcome.kind === 'auth')
|
|
119
|
+
return 'Fix: edit API key, or set Credential mode to Shared.';
|
|
120
|
+
if (outcome.kind !== 'model_not_found')
|
|
121
|
+
return undefined;
|
|
122
|
+
const reach = outcome.availableModels;
|
|
123
|
+
return `Fix: choose Discover models.${reach === undefined ? ''
|
|
124
|
+
: ` This key can reach ${reach} models.`}`;
|
|
125
|
+
}
|
|
126
|
+
function resultLines(outcome, target) {
|
|
127
|
+
const name = `${target.provider}:${target.model.trim()}`;
|
|
128
|
+
if (outcome.ok) {
|
|
129
|
+
const head = `Last test: ${name} OK in ${latencyLabel(outcome.latencyMs)}${usagePart(outcome)}`;
|
|
130
|
+
const reply = outcome.reply ? `Reply: "${outcome.reply}"` : undefined;
|
|
131
|
+
const reach = outcome.reply || outcome.availableModels === undefined ? undefined
|
|
132
|
+
: `This key can reach ${outcome.availableModels} models.`;
|
|
133
|
+
const second = reply ?? reach;
|
|
134
|
+
return second ? [head, second] : [head];
|
|
135
|
+
}
|
|
136
|
+
const head = `Last test: FAILED - ${outcome.detail ?? 'The connection test failed.'}`;
|
|
137
|
+
const fix = fixLine(outcome);
|
|
138
|
+
return fix ? [head, fix] : [head];
|
|
139
|
+
}
|
|
140
|
+
/** At most TWO rows: the verdict, then what to do about it (§3.1 / D-11). */
|
|
141
|
+
export function probeLines(view, target) {
|
|
142
|
+
if (view.kind === 'result')
|
|
143
|
+
return resultLines(view.outcome, target).slice(0, 2);
|
|
144
|
+
if (view.kind === 'idle') {
|
|
145
|
+
if (view.reason === 'unavailable')
|
|
146
|
+
return ['Connection test is unavailable in this session.'];
|
|
147
|
+
if (view.reason === 'no_key')
|
|
148
|
+
return ['No key yet: set API key, or use Credential mode Shared.'];
|
|
149
|
+
return ['Not tested for this profile yet.'];
|
|
150
|
+
}
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
/** Colour for the result rows; the status WORDS carry the meaning without colour. */
|
|
154
|
+
export function probeTone(view) {
|
|
155
|
+
if (view.kind !== 'result')
|
|
156
|
+
return 'muted';
|
|
157
|
+
return view.outcome.ok ? 'ok' : 'fail';
|
|
158
|
+
}
|
|
159
|
+
function fieldValue(editor, field) {
|
|
160
|
+
if (field === 'apiKey') {
|
|
161
|
+
if (editor.credentialMode === 'replace') {
|
|
162
|
+
return editor.apiKey ? 'Configured (replacement)' : '(type replacement)';
|
|
163
|
+
}
|
|
164
|
+
return editor.credentialMode === 'keep' ? 'Configured (unchanged)' : 'Shared / environment';
|
|
165
|
+
}
|
|
166
|
+
if (field === 'credentialMode')
|
|
167
|
+
return editor.credentialMode;
|
|
168
|
+
return editor[field] || (field === 'name' ? '(defaults to model)'
|
|
169
|
+
: field === 'contextWindow' ? '(auto)' : field === 'baseUrl'
|
|
170
|
+
? '(provider default)' : '(required)');
|
|
171
|
+
}
|
|
172
|
+
function actionValue(action, view) {
|
|
173
|
+
if (action === 'retain' || action === 'apply' || action === 'cancel')
|
|
174
|
+
return '';
|
|
175
|
+
if (view.kind === 'idle' && view.reason === 'unavailable')
|
|
176
|
+
return '(unavailable)';
|
|
177
|
+
if (view.kind === 'idle' && view.reason === 'no_key')
|
|
178
|
+
return '(no key)';
|
|
179
|
+
if (action === 'test' && view.kind === 'testing')
|
|
180
|
+
return 'testing...';
|
|
181
|
+
return action === 'test' ? 'Enter or Ctrl+R' : 'Enter or Ctrl+F';
|
|
182
|
+
}
|
|
183
|
+
function actionRow(action, label, view) {
|
|
184
|
+
return { kind: 'action', action, label, value: actionValue(action, view) };
|
|
185
|
+
}
|
|
186
|
+
/** THE single source of the editor's rows - fields, actions, notes AND results. */
|
|
187
|
+
export function editorRows(input) {
|
|
188
|
+
const { editor, retain, probe, target } = input;
|
|
189
|
+
const rows = EDITOR_FIELDS.map((field, at) => ({ kind: 'field', field,
|
|
190
|
+
label: EDITOR_LABELS[at], value: fieldValue(editor, field) }));
|
|
191
|
+
if (retain)
|
|
192
|
+
rows.push(actionRow('retain', 'Retain key for new endpoint', probe));
|
|
193
|
+
rows.push(actionRow('test', 'Test connection', probe), actionRow('discover', 'Discover models', probe), actionRow('apply', 'Apply to settings draft', probe), actionRow('cancel', 'Cancel editing', probe));
|
|
194
|
+
rows.push({ kind: 'note', muted: true,
|
|
195
|
+
label: `Target: ${editor.provider} ${profileEndpointSummary(editor.baseUrl)}` });
|
|
196
|
+
if (input.fastOnly) {
|
|
197
|
+
rows.push({ kind: 'note', muted: true,
|
|
198
|
+
label: 'Window applies when this profile is bound as Main.' });
|
|
199
|
+
}
|
|
200
|
+
const tone = probeTone(probe);
|
|
201
|
+
for (const line of probeLines(probe, target)) {
|
|
202
|
+
rows.push({ kind: 'result', label: line, tone });
|
|
203
|
+
}
|
|
204
|
+
return rows;
|
|
205
|
+
}
|
|
206
|
+
/** Rows the cursor can land on; index arithmetic now derives from the row model. */
|
|
207
|
+
export function isFocusable(row) {
|
|
208
|
+
return row.kind === 'field' || row.kind === 'action';
|
|
209
|
+
}
|
|
210
|
+
/** The focusable rows, in order - the index space `useProfileScroll` follows. */
|
|
211
|
+
export function focusableRows(rows) {
|
|
212
|
+
return rows.filter(isFocusable);
|
|
213
|
+
}
|
|
214
|
+
/** Result rows currently on screen, so the viewport can be widened to include them. */
|
|
215
|
+
export function resultRowCount(rows) {
|
|
216
|
+
return rows.filter((row) => row.kind === 'result').length;
|
|
217
|
+
}
|
|
218
|
+
/** Longest id shown in one list row, inside the 40-column budget. */
|
|
219
|
+
export const LIST_ID_CHARS = 28;
|
|
220
|
+
/** A model's one-line name, bounded by `maxNameChars`. */
|
|
221
|
+
export function clipName(name) {
|
|
222
|
+
const points = [...name];
|
|
223
|
+
return points.length <= PROBE_LIMITS.maxNameChars ? name
|
|
224
|
+
: `${points.slice(0, PROBE_LIMITS.maxNameChars - 3).join('')}...`;
|
|
225
|
+
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { type ModelProfile, type ModelProfilesConfig, type ModelRole } from '../../config/model-profiles.js';
|
|
2
2
|
import type { ModelSettingsDraft } from '../../config/model-profile-store.js';
|
|
3
|
-
|
|
3
|
+
import type { SubagentModelMode } from '../../team/model.js';
|
|
4
|
+
export type ProfilePage = 'root' | 'picker' | 'manager' | 'editor' | 'models' | 'delete-confirm' | 'discard-confirm';
|
|
5
|
+
/**
|
|
6
|
+
* Which role a PROFILE PICKER page is choosing for.
|
|
7
|
+
*
|
|
8
|
+
* `'subagent'` is a PICKER-ONLY role (subagent-model 6.2): `ModelRole` stays
|
|
9
|
+
* `'main' | 'fast'` because it is the type basis of the config layer's
|
|
10
|
+
* `${role}Id` template keys - widening it would invent a `subagentId` library
|
|
11
|
+
* key nothing reads. The subagent picker writes `draft.patch.team` instead.
|
|
12
|
+
*/
|
|
13
|
+
export type ProfilePickerRole = ModelRole | 'subagent';
|
|
14
|
+
/** The picker's built-in `Fast tier model` row; never a profile id on disk. */
|
|
15
|
+
export declare const SUBAGENT_FAST_ROW_ID = "__fast__";
|
|
4
16
|
export interface ProfileEditorDraft {
|
|
5
17
|
original: ModelProfile;
|
|
6
18
|
name: string;
|
|
7
19
|
provider: string;
|
|
8
20
|
model: string;
|
|
21
|
+
/** Text exactly as typed; `''` renders `(auto)` and parses to AUTO (§6.5). */
|
|
22
|
+
contextWindow: string;
|
|
9
23
|
baseUrl: string;
|
|
10
24
|
credentialMode: 'keep' | 'replace' | 'shared';
|
|
11
25
|
apiKey: string;
|
|
@@ -14,21 +28,23 @@ export interface ProfileEditorDraft {
|
|
|
14
28
|
export interface ModelProfileState {
|
|
15
29
|
draft: ModelSettingsDraft;
|
|
16
30
|
page: ProfilePage;
|
|
17
|
-
role:
|
|
31
|
+
role: ProfilePickerRole;
|
|
18
32
|
editor?: ProfileEditorDraft;
|
|
19
33
|
returnPage: 'picker' | 'manager';
|
|
20
34
|
bindNew: boolean;
|
|
21
35
|
dirty: boolean;
|
|
22
36
|
error?: string;
|
|
23
37
|
deleteId?: string;
|
|
38
|
+
/** Who referenced the entry being deleted ('Main + Subagent', or ''). */
|
|
39
|
+
deleteUsers?: string;
|
|
24
40
|
}
|
|
25
41
|
export type ModelProfileAction = {
|
|
26
42
|
type: 'page';
|
|
27
43
|
page: ProfilePage;
|
|
28
|
-
role?:
|
|
44
|
+
role?: ProfilePickerRole;
|
|
29
45
|
} | {
|
|
30
46
|
type: 'select';
|
|
31
|
-
role:
|
|
47
|
+
role: ProfilePickerRole;
|
|
32
48
|
id: string | null;
|
|
33
49
|
} | {
|
|
34
50
|
type: 'edit';
|
|
@@ -44,6 +60,14 @@ export type ModelProfileAction = {
|
|
|
44
60
|
type: 'merge-editor';
|
|
45
61
|
} | {
|
|
46
62
|
type: 'cancel-editor';
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The ONE write path for a discovered model (§6.3 / D-2 / D-3): only `model`
|
|
66
|
+
* changes - never the credential, never the context window, never the draft.
|
|
67
|
+
*/
|
|
68
|
+
| {
|
|
69
|
+
type: 'choose-model';
|
|
70
|
+
model: string;
|
|
47
71
|
} | {
|
|
48
72
|
type: 'duplicate';
|
|
49
73
|
id: string;
|
|
@@ -67,7 +91,40 @@ export declare function finishProfileEditor(editor: ProfileEditorDraft): {
|
|
|
67
91
|
/** Make an isolated root draft; opening the screen never mutates live config. */
|
|
68
92
|
export declare function createModelProfileState(draft: ModelSettingsDraft): ModelProfileState;
|
|
69
93
|
/** Describe references using text so ASCII and no-color modes remain usable. */
|
|
70
|
-
export declare function profileUsers(profiles: ModelProfilesConfig | undefined, id: string): string;
|
|
94
|
+
export declare function profileUsers(profiles: ModelProfilesConfig | undefined, id: string, subagentProfileId?: string | null): string;
|
|
95
|
+
/**
|
|
96
|
+
* The team section a draft would save: the patch first, then the disk baseline
|
|
97
|
+
* (subagent-model §3.6). The explicit-`undefined` test is what makes an
|
|
98
|
+
* explicit `null` in the patch win over the baseline - `??` would fold it away.
|
|
99
|
+
*/
|
|
100
|
+
export declare function draftTeam(draft: ModelSettingsDraft): {
|
|
101
|
+
model: SubagentModelMode;
|
|
102
|
+
modelProfileId: string | null;
|
|
103
|
+
};
|
|
104
|
+
/** The profile id the draft's team section binds, for reference checks (3.7). */
|
|
105
|
+
export declare function draftSubagentProfileId(draft: ModelSettingsDraft): string | null;
|
|
106
|
+
/** The projected picker selection for the subagent role (6.2). */
|
|
107
|
+
export declare function subagentSelectedId(draft: ModelSettingsDraft): string | null;
|
|
108
|
+
/** What the settings root row and `/team model` show for the current mode. */
|
|
109
|
+
export interface SubagentModelSummaryInput {
|
|
110
|
+
mode: SubagentModelMode;
|
|
111
|
+
modelProfileId: string | null;
|
|
112
|
+
/** `<provider>:<model>` of the connection the main role runs on. */
|
|
113
|
+
mainConnection: string;
|
|
114
|
+
/** The fast tier's `<provider>:<model>`, or null when it does not resolve. */
|
|
115
|
+
fastConnection: string | null;
|
|
116
|
+
profiles: ModelProfilesConfig | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* KEY EXISTENCE AS THE SETTINGS SCREEN SEES IT (V-10): derived from the
|
|
119
|
+
* config-file keys, exactly like the `name()` row and `fastTierLine` beside
|
|
120
|
+
* it. A key that exists ONLY as an environment variable is invisible here,
|
|
121
|
+
* so an env-key user may see `(off ...)` / `[Missing API key]` where the
|
|
122
|
+
* runtime would succeed - the accepted approximation both sibling lines
|
|
123
|
+
* already ship.
|
|
124
|
+
*/
|
|
125
|
+
hasKey: (providerId: string) => boolean;
|
|
126
|
+
}
|
|
127
|
+
export declare function subagentModelSummary(input: SubagentModelSummaryInput): string;
|
|
71
128
|
/** Adopt persisted fields only, resolving the old fast inheritance once. */
|
|
72
129
|
export declare function createCustomProfile(draft: ModelSettingsDraft, role: ModelRole): ModelProfile;
|
|
73
130
|
/** All child actions modify only the root draft; persistence belongs to Save. */
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { emptyModelProfiles, duplicateModelProfile, normalizeModelProfiles, validateModelProfiles } from '../../config/model-profiles.js';
|
|
3
|
+
import { parseContextWindowInput } from '../../config/schema.js';
|
|
4
|
+
/** The picker's built-in `Fast tier model` row; never a profile id on disk. */
|
|
5
|
+
export const SUBAGENT_FAST_ROW_ID = '__fast__';
|
|
3
6
|
/** Keep the original secret separate from the empty replacement field. */
|
|
4
7
|
export function createProfileEditor(profile) {
|
|
5
8
|
return { original: { ...profile }, name: profile.name, provider: profile.provider,
|
|
6
9
|
model: profile.model, baseUrl: profile.baseUrl ?? '', apiKey: '',
|
|
10
|
+
contextWindow: profile.contextWindow === null ? '' : String(profile.contextWindow),
|
|
7
11
|
credentialMode: profile.apiKey ? 'keep' : 'shared', endpointChanged: false };
|
|
8
12
|
}
|
|
9
13
|
/** Validate locally before merging the child form into the root transaction. */
|
|
10
14
|
export function finishProfileEditor(editor) {
|
|
15
|
+
// STRICT IN THE UI, TOLERANT ON DISK (§6.5 / invariant 4): a typo is refused
|
|
16
|
+
// here with an inline error and the focus jump its `field` enables; it never
|
|
17
|
+
// reaches the draft. The message prefix matches the editor's LABELS row.
|
|
18
|
+
const window = parseContextWindowInput(editor.contextWindow);
|
|
19
|
+
if (window.kind === 'invalid') {
|
|
20
|
+
return { error: 'Context window: use a number or auto.', field: 'contextWindow' };
|
|
21
|
+
}
|
|
11
22
|
const profile = { id: editor.original.id, name: editor.name.trim(),
|
|
12
23
|
provider: editor.provider, model: editor.model.trim(), baseUrl: editor.baseUrl.trim() || null,
|
|
13
24
|
apiKey: editor.credentialMode === 'keep' ? editor.original.apiKey
|
|
14
|
-
: editor.credentialMode === 'replace' ? editor.apiKey.trim() : null
|
|
25
|
+
: editor.credentialMode === 'replace' ? editor.apiKey.trim() : null,
|
|
26
|
+
contextWindow: window.kind === 'auto' ? null : window.value };
|
|
15
27
|
const library = { ...emptyModelProfiles(), entries: [profile] };
|
|
16
28
|
const issue = validateModelProfiles(library)[0];
|
|
17
29
|
if (issue)
|
|
@@ -30,10 +42,52 @@ export function createModelProfileState(draft) {
|
|
|
30
42
|
bindNew: false, dirty: false };
|
|
31
43
|
}
|
|
32
44
|
/** Describe references using text so ASCII and no-color modes remain usable. */
|
|
33
|
-
export function profileUsers(profiles, id) {
|
|
34
|
-
return [profiles?.mainId === id ? 'Main' : '', profiles?.fastId === id ? 'Fast' : ''
|
|
45
|
+
export function profileUsers(profiles, id, subagentProfileId) {
|
|
46
|
+
return [profiles?.mainId === id ? 'Main' : '', profiles?.fastId === id ? 'Fast' : '',
|
|
47
|
+
subagentProfileId === id ? 'Subagent' : '']
|
|
35
48
|
.filter(Boolean).join(' + ');
|
|
36
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The team section a draft would save: the patch first, then the disk baseline
|
|
52
|
+
* (subagent-model §3.6). The explicit-`undefined` test is what makes an
|
|
53
|
+
* explicit `null` in the patch win over the baseline - `??` would fold it away.
|
|
54
|
+
*/
|
|
55
|
+
export function draftTeam(draft) {
|
|
56
|
+
const patch = draft.patch.team;
|
|
57
|
+
const baseline = draft.baseline.team;
|
|
58
|
+
return {
|
|
59
|
+
model: patch?.model !== undefined ? patch.model : baseline?.model ?? 'main',
|
|
60
|
+
modelProfileId: patch?.modelProfileId !== undefined ? patch.modelProfileId
|
|
61
|
+
: baseline?.modelProfileId ?? null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The profile id the draft's team section binds, for reference checks (3.7). */
|
|
65
|
+
export function draftSubagentProfileId(draft) {
|
|
66
|
+
return draftTeam(draft).model === 'profile' ? draftTeam(draft).modelProfileId : null;
|
|
67
|
+
}
|
|
68
|
+
/** The projected picker selection for the subagent role (6.2). */
|
|
69
|
+
export function subagentSelectedId(draft) {
|
|
70
|
+
const team = draftTeam(draft);
|
|
71
|
+
if (team.model === 'profile')
|
|
72
|
+
return team.modelProfileId;
|
|
73
|
+
return team.model === 'fast' ? SUBAGENT_FAST_ROW_ID : null;
|
|
74
|
+
}
|
|
75
|
+
export function subagentModelSummary(input) {
|
|
76
|
+
if (input.mode === 'main')
|
|
77
|
+
return `Same as main (${input.mainConnection})`;
|
|
78
|
+
if (input.mode === 'fast') {
|
|
79
|
+
return input.fastConnection
|
|
80
|
+
? `Fast tier (${input.fastConnection})`
|
|
81
|
+
: 'Fast tier (off - falls back to main)';
|
|
82
|
+
}
|
|
83
|
+
const entry = input.modelProfileId
|
|
84
|
+
? input.profiles?.entries.find((candidate) => candidate.id === input.modelProfileId)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (!entry)
|
|
87
|
+
return 'Profile missing - falls back to main';
|
|
88
|
+
const missing = !entry.apiKey && !input.hasKey(entry.provider);
|
|
89
|
+
return `${entry.name} ${entry.provider}:${entry.model}${missing ? ' [Missing API key]' : ''}`;
|
|
90
|
+
}
|
|
37
91
|
/** Adopt persisted fields only, resolving the old fast inheritance once. */
|
|
38
92
|
export function createCustomProfile(draft, role) {
|
|
39
93
|
const file = draft.baseline;
|
|
@@ -45,7 +99,11 @@ export function createCustomProfile(draft, role) {
|
|
|
45
99
|
|| (provider === (file.provider || 'anthropic') ? file.baseUrl : null)
|
|
46
100
|
: file.baseUrl) || null;
|
|
47
101
|
return { id: randomUUID(), name: '', provider, model, baseUrl,
|
|
48
|
-
apiKey: role === 'fast' && !model ? null : file.apiKeys?.[provider] || null
|
|
102
|
+
apiKey: role === 'fast' && !model ? null : file.apiKeys?.[provider] || null,
|
|
103
|
+
// Carry the user's existing top-level correction into the first profile
|
|
104
|
+
// (§6.5): binding an AUTO profile would otherwise silently retire it
|
|
105
|
+
// (tradeoff B's migration risk, mitigated exactly here).
|
|
106
|
+
contextWindow: file.contextWindow ?? null };
|
|
49
107
|
}
|
|
50
108
|
function editField(state, action) {
|
|
51
109
|
if (!state.editor)
|
|
@@ -85,9 +143,24 @@ export function modelProfileReducer(state, action) {
|
|
|
85
143
|
case 'reload': return createModelProfileState(action.draft);
|
|
86
144
|
case 'page': return { ...state, page: action.page, role: action.role ?? state.role,
|
|
87
145
|
error: undefined };
|
|
88
|
-
case 'select':
|
|
89
|
-
|
|
90
|
-
|
|
146
|
+
case 'select': {
|
|
147
|
+
// THE SUBAGENT BRANCH (D-5): selection writes the DRAFT's team patch -
|
|
148
|
+
// `profiles` and `activateRoles` are untouched, because 'subagent' is a
|
|
149
|
+
// picker-only role with no `${role}Id` library key (6.2). Save is the
|
|
150
|
+
// one place the patch ever reaches disk (I-5).
|
|
151
|
+
if (action.role === 'subagent') {
|
|
152
|
+
const team = action.id === null
|
|
153
|
+
? { model: 'main', modelProfileId: null }
|
|
154
|
+
: action.id === SUBAGENT_FAST_ROW_ID
|
|
155
|
+
? { model: 'fast', modelProfileId: null }
|
|
156
|
+
: { model: 'profile', modelProfileId: action.id };
|
|
157
|
+
return { ...state, page: 'root', dirty: true, error: undefined,
|
|
158
|
+
draft: { ...state.draft, patch: { ...state.draft.patch, team } } };
|
|
159
|
+
}
|
|
160
|
+
return { ...state, page: 'root', dirty: true, error: undefined,
|
|
161
|
+
draft: { ...state.draft, profiles: { ...library, [`${action.role}Id`]: action.id },
|
|
162
|
+
activateRoles: [...new Set([...state.draft.activateRoles, action.role])] } };
|
|
163
|
+
}
|
|
91
164
|
case 'edit': return { ...state, editor: createProfileEditor(action.profile), page: 'editor',
|
|
92
165
|
returnPage: state.page === 'picker' ? 'picker' : 'manager',
|
|
93
166
|
bindNew: action.bindNew ?? false, error: undefined };
|
|
@@ -95,6 +168,16 @@ export function modelProfileReducer(state, action) {
|
|
|
95
168
|
case 'retain-key': return state.editor?.original.apiKey ? { ...state,
|
|
96
169
|
editor: { ...state.editor, credentialMode: 'keep', apiKey: '' } } : state;
|
|
97
170
|
case 'merge-editor': return mergeEditor(state);
|
|
171
|
+
case 'choose-model': {
|
|
172
|
+
// Legal ONLY from the list page (§7). `editor-field` would do the same thing,
|
|
173
|
+
// but routing the list page through the generic field action would also let a
|
|
174
|
+
// future `editField` side effect (key reset, endpoint flag) fire on a choice
|
|
175
|
+
// that never touched the endpoint.
|
|
176
|
+
if (!state.editor || state.page !== 'models')
|
|
177
|
+
return state;
|
|
178
|
+
return { ...state, page: 'editor', error: undefined,
|
|
179
|
+
editor: { ...state.editor, model: action.model } };
|
|
180
|
+
}
|
|
98
181
|
case 'cancel-editor': return { ...state, editor: undefined, page: state.returnPage,
|
|
99
182
|
error: undefined };
|
|
100
183
|
case 'duplicate': {
|
|
@@ -107,13 +190,37 @@ export function modelProfileReducer(state, action) {
|
|
|
107
190
|
: state;
|
|
108
191
|
}
|
|
109
192
|
case 'delete': {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
193
|
+
// UNBIND-AND-DELETE (D-6): being referenced no longer refuses the flow.
|
|
194
|
+
// The references - subagent included - are computed here, shown on the
|
|
195
|
+
// confirm page, and cleared by `confirm-delete` inside the draft.
|
|
196
|
+
const users = profileUsers(library, action.id, draftSubagentProfileId(state.draft));
|
|
197
|
+
return { ...state, page: 'delete-confirm', deleteId: action.id,
|
|
198
|
+
deleteUsers: users, error: undefined };
|
|
199
|
+
}
|
|
200
|
+
case 'confirm-delete': {
|
|
201
|
+
const id = state.deleteId;
|
|
202
|
+
if (!id)
|
|
203
|
+
return state;
|
|
204
|
+
// THE DRAFT-SIDE UNBIND: Main/Fast bindings pointing at the entry are
|
|
205
|
+
// nulled here (not refused) and the subagent binding resets to main
|
|
206
|
+
// when it named THIS entry. `removeModelProfile`'s throw stays as the
|
|
207
|
+
// bottom-line defense - the UI has already unbound, so it cannot fire
|
|
208
|
+
// on this path.
|
|
209
|
+
const profiles = {
|
|
210
|
+
...library,
|
|
211
|
+
mainId: library.mainId === id ? null : library.mainId,
|
|
212
|
+
fastId: library.fastId === id ? null : library.fastId,
|
|
213
|
+
entries: library.entries.filter((entry) => entry.id !== id),
|
|
214
|
+
};
|
|
215
|
+
const teamReset = draftSubagentProfileId(state.draft) === id
|
|
216
|
+
? { team: { model: 'main', modelProfileId: null } }
|
|
217
|
+
: {};
|
|
218
|
+
return { ...state, page: 'manager', dirty: true,
|
|
219
|
+
draft: { ...state.draft, profiles,
|
|
220
|
+
patch: { ...state.draft.patch, ...teamReset } },
|
|
221
|
+
deleteId: undefined, deleteUsers: undefined,
|
|
222
|
+
// No orphan editor for a deleted entry (3.7).
|
|
223
|
+
editor: state.editor?.original.id === id ? undefined : state.editor };
|
|
113
224
|
}
|
|
114
|
-
case 'confirm-delete': return { ...state, page: 'manager', dirty: true,
|
|
115
|
-
draft: { ...state.draft, profiles: { ...library,
|
|
116
|
-
entries: library.entries.filter((entry) => entry.id !== state.deleteId) } },
|
|
117
|
-
deleteId: undefined };
|
|
118
225
|
}
|
|
119
226
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The probe task hook (model-profile-connection-test §6.2).
|
|
3
|
+
*
|
|
4
|
+
* IT LIVES IN `SettingsScreen`, ONCE, AND NOWHERE ELSE. The editor unmounts whenever
|
|
5
|
+
* the page changes (`SettingsScreen` renders it only for `page === 'editor'`), so a
|
|
6
|
+
* hook inside the editor would abort its own discovery the moment `Discover models`
|
|
7
|
+
* switched to the list page - the feature cancelling itself.
|
|
8
|
+
*
|
|
9
|
+
* TWO SLOTS, ONE IN FLIGHT. A test verdict and a discovered list answer different
|
|
10
|
+
* questions, so they are stored separately: opening the list must not erase a verdict
|
|
11
|
+
* the user still wants to read when they press Esc back (AC-27). Starting either task
|
|
12
|
+
* aborts whatever is running - one request at a time.
|
|
13
|
+
*
|
|
14
|
+
* `test()` AND `discover()` NEVER REJECT. The only callers are Ink input handlers,
|
|
15
|
+
* which have nowhere to await a `catch`; the hook folds every exception into an
|
|
16
|
+
* `unknown` failure so no frame can be left on `testing` forever.
|
|
17
|
+
*
|
|
18
|
+
* ASCII ONLY (`ui/` is inside `glyphs.test.ts::inScope`).
|
|
19
|
+
*/
|
|
20
|
+
import type { ModelProbe, ProbeTarget } from '../../agent/model-probe.js';
|
|
21
|
+
import type { ProbeDiscoveryPhase, ProbeTestPhase } from './model-probe-view.js';
|
|
22
|
+
export interface ModelProbeTasks {
|
|
23
|
+
test: ProbeTestPhase;
|
|
24
|
+
discovery: ProbeDiscoveryPhase;
|
|
25
|
+
busy: boolean;
|
|
26
|
+
startTest(target: ProbeTarget): void;
|
|
27
|
+
startDiscovery(target: ProbeTarget): void;
|
|
28
|
+
cancel(): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function useModelProbe(probe?: ModelProbe): ModelProbeTasks;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The probe task hook (model-profile-connection-test §6.2).
|
|
3
|
+
*
|
|
4
|
+
* IT LIVES IN `SettingsScreen`, ONCE, AND NOWHERE ELSE. The editor unmounts whenever
|
|
5
|
+
* the page changes (`SettingsScreen` renders it only for `page === 'editor'`), so a
|
|
6
|
+
* hook inside the editor would abort its own discovery the moment `Discover models`
|
|
7
|
+
* switched to the list page - the feature cancelling itself.
|
|
8
|
+
*
|
|
9
|
+
* TWO SLOTS, ONE IN FLIGHT. A test verdict and a discovered list answer different
|
|
10
|
+
* questions, so they are stored separately: opening the list must not erase a verdict
|
|
11
|
+
* the user still wants to read when they press Esc back (AC-27). Starting either task
|
|
12
|
+
* aborts whatever is running - one request at a time.
|
|
13
|
+
*
|
|
14
|
+
* `test()` AND `discover()` NEVER REJECT. The only callers are Ink input handlers,
|
|
15
|
+
* which have nowhere to await a `catch`; the hook folds every exception into an
|
|
16
|
+
* `unknown` failure so no frame can be left on `testing` forever.
|
|
17
|
+
*
|
|
18
|
+
* ASCII ONLY (`ui/` is inside `glyphs.test.ts::inScope`).
|
|
19
|
+
*/
|
|
20
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
21
|
+
const TEST_FAILED_DETAIL = 'The connection test could not run.';
|
|
22
|
+
const DISCOVERY_FAILED_DETAIL = 'The model list could not be fetched.';
|
|
23
|
+
function unknownTest(targetId) {
|
|
24
|
+
return { ok: false, targetId, latencyMs: 0, kind: 'unknown', detail: TEST_FAILED_DETAIL };
|
|
25
|
+
}
|
|
26
|
+
function unknownDiscovery(connectionId) {
|
|
27
|
+
return { ok: false, connectionId, kind: 'unknown', detail: DISCOVERY_FAILED_DETAIL,
|
|
28
|
+
models: [], truncated: false, total: 0 };
|
|
29
|
+
}
|
|
30
|
+
/** Run one probe, guaranteeing the promise settles and no rejection escapes. */
|
|
31
|
+
function settle(work, onValue, onFailure) {
|
|
32
|
+
let pending;
|
|
33
|
+
try {
|
|
34
|
+
pending = work();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
onFailure();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
void pending.then(onValue, onFailure);
|
|
41
|
+
}
|
|
42
|
+
export function useModelProbe(probe) {
|
|
43
|
+
const [test, setTest] = useState({ phase: 'idle' });
|
|
44
|
+
const [discovery, setDiscovery] = useState({ phase: 'idle' });
|
|
45
|
+
const controller = useRef(null);
|
|
46
|
+
const inFlight = useRef(null);
|
|
47
|
+
const abortCurrent = useCallback(() => {
|
|
48
|
+
controller.current?.abort();
|
|
49
|
+
controller.current = null;
|
|
50
|
+
inFlight.current = null;
|
|
51
|
+
}, []);
|
|
52
|
+
useEffect(() => abortCurrent, [abortCurrent]);
|
|
53
|
+
const begin = useCallback((kind) => {
|
|
54
|
+
abortCurrent();
|
|
55
|
+
const next = new AbortController();
|
|
56
|
+
controller.current = next;
|
|
57
|
+
inFlight.current = kind;
|
|
58
|
+
return next;
|
|
59
|
+
}, [abortCurrent]);
|
|
60
|
+
/**
|
|
61
|
+
* Land one run's result and drop the in-flight refs with it.
|
|
62
|
+
*
|
|
63
|
+
* Clearing them here is what keeps `cancel()` honest: it is the ONLY caller of
|
|
64
|
+
* `abortCurrent`, and an Escape on a settled slot (a frame where `busy` is stale)
|
|
65
|
+
* must not erase a verdict the user is still reading.
|
|
66
|
+
*/
|
|
67
|
+
const finish = useCallback((run, commit) => {
|
|
68
|
+
if (run.signal.aborted)
|
|
69
|
+
return;
|
|
70
|
+
controller.current = null;
|
|
71
|
+
inFlight.current = null;
|
|
72
|
+
commit();
|
|
73
|
+
}, []);
|
|
74
|
+
const startTest = useCallback((target) => {
|
|
75
|
+
if (!probe)
|
|
76
|
+
return;
|
|
77
|
+
const run = begin('test');
|
|
78
|
+
const targetId = probe.targetFingerprint(target);
|
|
79
|
+
setTest({ phase: 'testing', targetId });
|
|
80
|
+
settle(() => probe.test({ ...target, signal: run.signal }), (outcome) => {
|
|
81
|
+
finish(run, () => setTest({ phase: 'tested', outcome }));
|
|
82
|
+
}, () => {
|
|
83
|
+
finish(run, () => setTest({ phase: 'tested', outcome: unknownTest(targetId) }));
|
|
84
|
+
});
|
|
85
|
+
}, [probe, begin, finish]);
|
|
86
|
+
const startDiscovery = useCallback((target) => {
|
|
87
|
+
if (!probe)
|
|
88
|
+
return;
|
|
89
|
+
const run = begin('discovery');
|
|
90
|
+
const connectionId = probe.connectionFingerprint(target);
|
|
91
|
+
setDiscovery({ phase: 'discovering', connectionId });
|
|
92
|
+
settle(() => probe.discover({ ...target, signal: run.signal }), (outcome) => {
|
|
93
|
+
finish(run, () => setDiscovery({ phase: 'discovered', outcome }));
|
|
94
|
+
}, () => {
|
|
95
|
+
finish(run, () => setDiscovery({ phase: 'discovered',
|
|
96
|
+
outcome: unknownDiscovery(connectionId) }));
|
|
97
|
+
});
|
|
98
|
+
}, [probe, begin, finish]);
|
|
99
|
+
const cancel = useCallback(() => {
|
|
100
|
+
const kind = inFlight.current;
|
|
101
|
+
abortCurrent();
|
|
102
|
+
if (kind === 'test')
|
|
103
|
+
setTest({ phase: 'idle' });
|
|
104
|
+
if (kind === 'discovery')
|
|
105
|
+
setDiscovery({ phase: 'idle' });
|
|
106
|
+
}, [abortCurrent]);
|
|
107
|
+
const busy = test.phase === 'testing' || discovery.phase === 'discovering';
|
|
108
|
+
return { test, discovery, busy, startTest, startDiscovery, cancel };
|
|
109
|
+
}
|