@aragon-agent/cli 0.6.24 → 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 -2562
- package/dist/agent/controller.d.ts +49 -3
- package/dist/agent/controller.js +78 -3
- 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/reducer.js +45 -13
- package/dist/commands/builtins.js +14 -34
- 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/config/model-profiles.d.ts +8 -0
- package/dist/config/model-profiles.js +10 -0
- package/dist/session/clear-archive.d.ts +51 -0
- package/dist/session/clear-archive.js +160 -0
- package/dist/todo/types.d.ts +5 -3
- package/dist/ui/App.js +39 -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 -68
- package/dist/ui/overlays/SettingsScreen.d.ts +8 -0
- package/dist/ui/overlays/SettingsScreen.js +34 -2
- 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 +9 -1
- package/dist/ui/overlays/model-profile-state.js +10 -0
- 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,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/clear` - start a new conversation - plus its `/reset` alias and the
|
|
3
|
+
* `/clear screen` form that keeps the old "just wipe the screen" meaning
|
|
4
|
+
* (clear-command-context-reset §3.5 / D-1 / D-2 / D-3).
|
|
5
|
+
*
|
|
6
|
+
* ASCII ONLY: `src/commands/**` is inside `glyphs.test.ts::inScope`. The one
|
|
7
|
+
* localized string below is written with `\u` escapes for that reason, exactly
|
|
8
|
+
* as the `/reset` body it replaces did.
|
|
9
|
+
*
|
|
10
|
+
* WHY THE BODY LIVES HERE AND THE REGISTRATION STAYS IN `builtins.ts`: the same
|
|
11
|
+
* reason `/compact` and `/context` do (D-8). `builtins.ts` is 1511 lines against
|
|
12
|
+
* `CLAUDE.md`'s 1000-line cap, and this command is no longer a two-line
|
|
13
|
+
* projection reset - it interrupts a run, waits for it, archives a conversation
|
|
14
|
+
* and then clears. A two-line edit belongs inline; this does not.
|
|
15
|
+
*
|
|
16
|
+
* THE COMMAND'S ONLY JOB IS SEQUENCING AND SPEAKING. Every state change happens
|
|
17
|
+
* on the controller port (`AgentController.resetConversation`, which delegates
|
|
18
|
+
* the ORDER to `agent/conversation-reset.ts`), and every word the user reads is
|
|
19
|
+
* decided here, in one place, in the exact order they will read it.
|
|
20
|
+
*/
|
|
21
|
+
import { getLogger } from '../logging/logger.js';
|
|
22
|
+
/** The toast a successful clear shows. Pinned verbatim by an existing test. */
|
|
23
|
+
const CLEARED_TOAST = 'Started a new conversation.';
|
|
24
|
+
/**
|
|
25
|
+
* Run `/clear` or `/reset`. Both registry entries point HERE (D-2), which is
|
|
26
|
+
* what stops the two from drifting back into "one clears the screen, one clears
|
|
27
|
+
* the session" - the drift that produced the defect this feature fixes.
|
|
28
|
+
*/
|
|
29
|
+
export async function runClearCommand(ctx) {
|
|
30
|
+
const verb = ctx.args.trim().toLowerCase();
|
|
31
|
+
if (verb === 'screen') {
|
|
32
|
+
// The old behaviour, byte for byte (I-3): silent, transcript plus the todo
|
|
33
|
+
// panel, history untouched.
|
|
34
|
+
clearScreenOnly(ctx);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (verb !== '') {
|
|
38
|
+
// Rejected rather than ignored. A command that silently accepts and
|
|
39
|
+
// discards an argument is a surface that lies about what it understood.
|
|
40
|
+
ctx.notify('warn', `Unknown argument "${ctx.args.trim()}" - use /clear [screen].`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// READ BEFORE CLEARING (P1-2 / I-9). `clearAllQueues()` empties
|
|
44
|
+
// `pendingUserSteering`, so a count taken afterwards is always zero - and a
|
|
45
|
+
// notice that says "0 messages cancelled" is worse than no notice at all.
|
|
46
|
+
const cancelled = ctx.state.pendingSteering.length;
|
|
47
|
+
const result = await ctx.controller.resetConversation({
|
|
48
|
+
interrupt: true,
|
|
49
|
+
// A THUNK, NOT A VALUE (P0-1 / §3.3). `ctx.state` is the view as of command
|
|
50
|
+
// entry, and the archive happens AFTER the interrupted engine unwinds - so
|
|
51
|
+
// the last tool cards of the abandoned run are written to the transcript in
|
|
52
|
+
// between. A value captured here would archive everything except the part
|
|
53
|
+
// the user was looking at when they typed the command.
|
|
54
|
+
readTranscript: () => ctx.transcriptEntries?.() ?? ctx.state.entries,
|
|
55
|
+
});
|
|
56
|
+
if (!result.ok) {
|
|
57
|
+
// ZERO SIDE EFFECTS (I-4), and a trace (P1-5). The user gets one sentence
|
|
58
|
+
// naming the next action; the log gets the reason, because "my /clear did
|
|
59
|
+
// nothing" is otherwise unanswerable after the fact.
|
|
60
|
+
getLogger().warn('agent', 'clear_still_running', { reason: result.reason });
|
|
61
|
+
ctx.notify('warn', result.message);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// THE VIEW GOES LAST (B-5 / §3.3). `App`'s dispatch wrapper zeroes the run
|
|
65
|
+
// metrics, the interaction phase and the scroll anchor on this action, so
|
|
66
|
+
// dispatching it after the engine has settled is what makes "cleared" mean
|
|
67
|
+
// "nothing is still writing into it".
|
|
68
|
+
ctx.dispatch({ type: 'resetConversation' });
|
|
69
|
+
// THE ONE CONSUMER OF `messagesCleared` (P2-1). It cannot join the toast:
|
|
70
|
+
// `clear-command-todo.test.ts` pins that string verbatim, and a count nobody
|
|
71
|
+
// asked for is noise in the one place the user reads confirmations.
|
|
72
|
+
getLogger().info('agent', 'conversation_cleared', {
|
|
73
|
+
messagesCleared: result.messagesCleared,
|
|
74
|
+
interrupted: result.interrupted,
|
|
75
|
+
});
|
|
76
|
+
ctx.toast('info', CLEARED_TOAST);
|
|
77
|
+
// REINSTATED FROM THE OLD `/reset` (P1-1 / C-3). Dropping it would silently
|
|
78
|
+
// delete a user-visible acknowledgement that already existed and was already
|
|
79
|
+
// pinned by test.
|
|
80
|
+
if (cancelled) {
|
|
81
|
+
ctx.notify('warn', `\u5df2\u53d6\u6d88 ${cancelled} \u6761\u672a\u63a5\u6536\u6d88\u606f`);
|
|
82
|
+
}
|
|
83
|
+
if (result.archivedPath) {
|
|
84
|
+
// A PATH RATHER THAN A PROMISE: the user can copy it, and `/resume` accepts
|
|
85
|
+
// an absolute path. "Archived" with no way to find the file is a reassurance,
|
|
86
|
+
// not a recovery.
|
|
87
|
+
ctx.notify('info', `Previous conversation archived: ${result.archivedPath} (/resume <path> to restore)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Today's `/clear`, unchanged and silent (D-3 / I-3).
|
|
92
|
+
*
|
|
93
|
+
* Kept as a real form rather than as a separate command because "clear the
|
|
94
|
+
* screen" and "clear the conversation" are two different intentions, and a user
|
|
95
|
+
* who wants the first must not have to risk the second. It also gives
|
|
96
|
+
* `clearTranscript` a live dispatcher: `CLAUDE.md` forbids orphaned actions, and
|
|
97
|
+
* this was the last one that would have had none.
|
|
98
|
+
*/
|
|
99
|
+
function clearScreenOnly(ctx) {
|
|
100
|
+
// A USER OVERRIDE OF A PROJECTION, the same class as `/todo clear`, so it
|
|
101
|
+
// reuses reason `'user'`; the distinction against `clearMessages()`'s `'reset'`
|
|
102
|
+
// is recorded on `TodoStore`. Clearing the STORE and not merely the view
|
|
103
|
+
// mirror is what keeps `/save`, `/todo status|continue` and follow-through
|
|
104
|
+
// agreeing with the screen - they read the store directly.
|
|
105
|
+
ctx.controller.clearTodos();
|
|
106
|
+
ctx.dispatch({ type: 'clearTranscript' });
|
|
107
|
+
}
|
|
@@ -6,7 +6,7 @@ import type { SubmitMessageOptions } from '../agent/prompt-options.js';
|
|
|
6
6
|
import type { CopyRequest, CopyRequestResult } from '../ui/clipboard-task.js';
|
|
7
7
|
import type { AgentController } from '../agent/controller.js';
|
|
8
8
|
import type { AgentMode } from '../agent/agent-mode.js';
|
|
9
|
-
import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
|
|
9
|
+
import type { Entry, NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
|
|
10
10
|
import type { PersistedConfig } from '../config/schema.js';
|
|
11
11
|
import type { FollowThroughBudget } from '../todo/follow-through.js';
|
|
12
12
|
import type { UpdateCommandPort } from '../update/types.js';
|
|
@@ -108,6 +108,25 @@ export interface CommandContext {
|
|
|
108
108
|
* stream is handed over directly.
|
|
109
109
|
*/
|
|
110
110
|
writeForeign?: (text: string) => void;
|
|
111
|
+
/**
|
|
112
|
+
* The LIVE transcript rows, for `/clear`'s archive (clear-command-context-reset
|
|
113
|
+
* P0-1 / §3.5).
|
|
114
|
+
*
|
|
115
|
+
* A THUNK, AND THAT IS THE WHOLE POINT. The `state` field above is a snapshot
|
|
116
|
+
* of `ViewState` taken when the command started; `/clear` interrupts a run and
|
|
117
|
+
* then waits for the engine to unwind, so the last tool cards of that run are
|
|
118
|
+
* written to the transcript AFTER `state` was captured. Reading `state.entries`
|
|
119
|
+
* would archive everything except the part the user was just looking at.
|
|
120
|
+
*
|
|
121
|
+
* OPTIONAL, like `followBudget` / `update` / `mouse`: every existing
|
|
122
|
+
* `CommandContext` fixture in this package builds the object by literal, and a
|
|
123
|
+
* required field would break all of them at compile time for a feature none of
|
|
124
|
+
* them are about. Absent means the archive degrades to history-only.
|
|
125
|
+
*
|
|
126
|
+
* `Entry` arrives as a TYPE-ONLY import so this module keeps its runtime shape
|
|
127
|
+
* (the same rule the `update/types.js` import above follows).
|
|
128
|
+
*/
|
|
129
|
+
transcriptEntries?: () => Entry[];
|
|
111
130
|
}
|
|
112
131
|
/** What `/mouse` can see and do. Read at command time, never captured. */
|
|
113
132
|
export interface MouseCommandPort {
|
|
@@ -34,6 +34,14 @@ export interface ProfileValidationIssue {
|
|
|
34
34
|
export declare function emptyModelProfiles(): ModelProfilesConfig;
|
|
35
35
|
/** Return independently mutable role state. */
|
|
36
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;
|
|
37
45
|
/** Validate untrusted input; diagnostics never include supplied values. */
|
|
38
46
|
export declare function validateModelProfiles(value: unknown): ProfileValidationIssue[];
|
|
39
47
|
/**
|
|
@@ -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) => ({
|
|
@@ -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
|
+
}
|
package/dist/todo/types.d.ts
CHANGED
|
@@ -42,9 +42,11 @@ export interface TodoSnapshot {
|
|
|
42
42
|
/**
|
|
43
43
|
* Why a list went away.
|
|
44
44
|
*
|
|
45
|
-
* `user` - `/todo clear`, or `/clear
|
|
46
|
-
* which is I-2's standing exception;
|
|
47
|
-
*
|
|
45
|
+
* `user` - `/todo clear`, or `/clear screen` (the screen-only form). Both are
|
|
46
|
+
* an explicit user instruction, which is I-2's standing exception;
|
|
47
|
+
* neither touches `messages`.
|
|
48
|
+
* `reset` - `/clear` (or its alias `/reset`) through `clearMessages()`, or a
|
|
49
|
+
* `/resume` whose file carried no list (§3.13).
|
|
48
50
|
* `turn` - a new TUI task started, or a legacy caller began a turn after
|
|
49
51
|
* every item was completed.
|
|
50
52
|
* `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
|
package/dist/ui/App.js
CHANGED
|
@@ -55,6 +55,7 @@ import { publishExitSnapshot } from './exit-snapshot.js';
|
|
|
55
55
|
import { OverlayFrame } from './layout/OverlayFrame.js';
|
|
56
56
|
import { helpRows } from './overlays/HelpOverlay.js';
|
|
57
57
|
import { ModelPicker } from './overlays/ModelPicker.js';
|
|
58
|
+
import { createModelProbe } from '../agent/model-probe.js';
|
|
58
59
|
import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
|
|
59
60
|
import { ConfirmDialog } from './overlays/ConfirmDialog.js';
|
|
60
61
|
import { QuestionOverlay } from './overlays/QuestionOverlay.js';
|
|
@@ -1388,6 +1389,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1388
1389
|
requestCopy: copyCoordinator.requestCopy,
|
|
1389
1390
|
controller,
|
|
1390
1391
|
state: stateRef.current,
|
|
1392
|
+
// THE ARCHIVE'S ONLY WAY TO SEE THE SCREEN (P0-1 / §3.7), and a thunk for a
|
|
1393
|
+
// reason `state` above illustrates: `state` is a snapshot of this render,
|
|
1394
|
+
// while `/clear` interrupts a run and then waits for it to unwind, so the last
|
|
1395
|
+
// tool cards of the abandoned run are appended AFTER the command starts. A
|
|
1396
|
+
// value here would archive the screen minus the part the user was watching.
|
|
1397
|
+
transcriptEntries: () => stateRef.current.entries,
|
|
1391
1398
|
dispatch: (action) => {
|
|
1392
1399
|
if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
|
|
1393
1400
|
metrics.current = createRunMetrics();
|
|
@@ -1535,12 +1542,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1535
1542
|
};
|
|
1536
1543
|
submitRef.current = submitMessage;
|
|
1537
1544
|
const executeSlashInput = async (raw) => {
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1545
|
+
// A CONVERSATION SWITCH OWNS THE QUEUE FOR THE WHOLE COMMAND (B-6 / §3.7).
|
|
1546
|
+
//
|
|
1547
|
+
// The guard used to be raised only when a compaction happened to be busy,
|
|
1548
|
+
// which left the one window this feature is about unwatched: `/clear` on an
|
|
1549
|
+
// IDLE session took the flag nowhere, and `drainCompactionQueue` was still
|
|
1550
|
+
// free to `continue()` a message queued in the previous conversation into
|
|
1551
|
+
// the one the user had just started. Whether there is work to cancel is a
|
|
1552
|
+
// separate question, so the two are now separate values: `switching` decides
|
|
1553
|
+
// the lock, `switchWhileBusy` decides the cancellation.
|
|
1554
|
+
//
|
|
1555
|
+
// `/resume` is still refused mid-run by `handleSubmit` below, so for it the
|
|
1556
|
+
// lock only ever matters while idle - that is the minimum this invariant
|
|
1557
|
+
// asks for, not a contradiction.
|
|
1558
|
+
const switching = /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
|
|
1559
|
+
const switchWhileBusy = switching && Boolean(controller.isCompactionBusy?.());
|
|
1560
|
+
if (switching)
|
|
1541
1561
|
switchingConversation.current = true;
|
|
1562
|
+
if (switchWhileBusy)
|
|
1542
1563
|
await controller.cancelCompaction();
|
|
1543
|
-
}
|
|
1544
1564
|
// Retain a recoverable command before any asynchronous handler can fail.
|
|
1545
1565
|
const safe = redactText(raw, getSecrets());
|
|
1546
1566
|
recordPrompt(safe);
|
|
@@ -1553,7 +1573,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1553
1573
|
notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
|
|
1554
1574
|
}
|
|
1555
1575
|
finally {
|
|
1556
|
-
if (
|
|
1576
|
+
if (switching) {
|
|
1557
1577
|
switchingConversation.current = false;
|
|
1558
1578
|
drainCompactionQueue.current();
|
|
1559
1579
|
}
|
|
@@ -1563,7 +1583,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
1563
1583
|
const trimmed = raw.trimStart();
|
|
1564
1584
|
const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
|
|
1565
1585
|
if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
|
|
1566
|
-
/^\/
|
|
1586
|
+
/^\/resume(?:\s|$)/.test(trimmed)) {
|
|
1567
1587
|
return { accepted: false,
|
|
1568
1588
|
reason: 'Interrupt the current run before switching conversations.' };
|
|
1569
1589
|
}
|
|
@@ -2050,6 +2070,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2050
2070
|
const overlay = state.overlay;
|
|
2051
2071
|
const settingsDraft = useMemo(() => overlay === 'settings'
|
|
2052
2072
|
? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
|
|
2073
|
+
// ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
|
|
2074
|
+
// controller's provider registry, which the overlay has no business owning.
|
|
2075
|
+
// Built only while the settings overlay is open, so no other screen pays for it.
|
|
2076
|
+
const probe = useMemo(() => {
|
|
2077
|
+
if (overlay !== 'settings')
|
|
2078
|
+
return undefined;
|
|
2079
|
+
// A test double or an embedded host may predate this port; absent means the
|
|
2080
|
+
// two probe rows read `(unavailable)` rather than the screen crashing.
|
|
2081
|
+
if (typeof controller.getProviderRegistry !== 'function')
|
|
2082
|
+
return undefined;
|
|
2083
|
+
return createModelProbe({ registry: controller.getProviderRegistry() });
|
|
2084
|
+
}, [controller, overlay]);
|
|
2053
2085
|
const hasKey = controller.hasApiKey();
|
|
2054
2086
|
const empty = state.entries.length === 0;
|
|
2055
2087
|
// RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
|
|
@@ -2155,7 +2187,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
|
|
|
2155
2187
|
// the save handler cannot disagree about what `(inherit)` means.
|
|
2156
2188
|
...fastSettingsFrom(cfg.fast),
|
|
2157
2189
|
...compactionSettingsFrom(cfg.compaction),
|
|
2158
|
-
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
|
|
2190
|
+
}, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, probe: probe, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
|
|
2159
2191
|
// Read from `cfg` (render scope, re-read every frame), so `/retry off`
|
|
2160
2192
|
// while the screen is open shows the new value on the next paint.
|
|
2161
2193
|
retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
|
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* kind — without it the card renders a hint that does nothing and expands an
|
|
15
15
|
* older tool card instead (C-15 / P1-8 / AC-21).
|
|
16
16
|
*
|
|
17
|
-
* EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
|
|
18
|
-
* empties the transcript view but not `messages`, so this card
|
|
19
|
-
*
|
|
20
|
-
* "above" or "earlier in this transcript".
|
|
17
|
+
* EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
|
|
18
|
+
* `/clear screen` empties the transcript view but not `messages`, so this card
|
|
19
|
+
* can legitimately report a message count the visible rows do not add up to.
|
|
20
|
+
* Nothing here says "above" or "earlier in this transcript". `/clear` itself is
|
|
21
|
+
* the conversation boundary now: it drops the history and this card with it.
|
|
21
22
|
*/
|
|
22
23
|
import React from 'react';
|
|
23
24
|
import type { Theme } from '../theme.js';
|
|
@@ -15,10 +15,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
15
15
|
* kind — without it the card renders a hint that does nothing and expands an
|
|
16
16
|
* older tool card instead (C-15 / P1-8 / AC-21).
|
|
17
17
|
*
|
|
18
|
-
* EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
|
|
19
|
-
* empties the transcript view but not `messages`, so this card
|
|
20
|
-
*
|
|
21
|
-
* "above" or "earlier in this transcript".
|
|
18
|
+
* EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9).
|
|
19
|
+
* `/clear screen` empties the transcript view but not `messages`, so this card
|
|
20
|
+
* can legitimately report a message count the visible rows do not add up to.
|
|
21
|
+
* Nothing here says "above" or "earlier in this transcript". `/clear` itself is
|
|
22
|
+
* the conversation boundary now: it drops the history and this card with it.
|
|
22
23
|
*/
|
|
23
24
|
import React from 'react';
|
|
24
25
|
import { Box, Text } from 'ink';
|
|
@@ -68,8 +68,14 @@ const COMMANDS = [
|
|
|
68
68
|
'Todo planning: status, switches, the rail, follow-through',
|
|
69
69
|
],
|
|
70
70
|
['/tools', 'List active tools'],
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// `/clear` IS NOT "CLEAR THE SCREEN" ANY MORE (clear-command-context-reset D-1).
|
|
72
|
+
// Two rows, not one, because the intentions differ: `/clear` starts a NEW
|
|
73
|
+
// CONVERSATION (interrupt the run, archive the old one, drop the history), while
|
|
74
|
+
// `/clear screen` only wipes what is on screen. A user who wants the second has
|
|
75
|
+
// to be able to see that it exists before reaching for the first.
|
|
76
|
+
['/clear', 'Start a new conversation: archive, then clear context and transcript'],
|
|
77
|
+
['/clear screen', 'Clear the visible transcript only; keeps the conversation'],
|
|
78
|
+
['/reset', 'Alias of /clear'],
|
|
73
79
|
['/cwd [dir]', 'Show or change the tool working directory'],
|
|
74
80
|
['/save [file]', 'Save the session to JSON'],
|
|
75
81
|
['/resume [file]', 'Load a saved session'],
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ProfileFrameProps } from './ModelProfilePicker.js';
|
|
3
|
+
import { type DiscoveryView } from './model-probe-view.js';
|
|
4
|
+
interface ModelDiscoveryListProps extends ProfileFrameProps {
|
|
5
|
+
provider: string;
|
|
6
|
+
/** The draft's current model: the cursor starts on it when the list has it. */
|
|
7
|
+
currentModel: string;
|
|
8
|
+
view: DiscoveryView;
|
|
9
|
+
busy: boolean;
|
|
10
|
+
onChoose: (model: string) => void;
|
|
11
|
+
onRetry: () => void;
|
|
12
|
+
onBack: () => void;
|
|
13
|
+
onCancelProbe?: () => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The discovered-model page (model-profile-connection-test §3.5).
|
|
17
|
+
*
|
|
18
|
+
* Pure display plus input: it receives the discovery outcome and three intents and
|
|
19
|
+
* issues no request itself. No spinner - the census of animated sites is a closed
|
|
20
|
+
* list, and this overlay can be open while a run is in flight.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ModelDiscoveryList(props: ModelDiscoveryListProps): React.ReactElement;
|
|
23
|
+
export {};
|