lazyclaw 6.4.0 → 6.6.0
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/agents.mjs +68 -1
- package/commands/agents.mjs +3 -194
- package/commands/agents_registry.mjs +227 -0
- package/commands/automation.mjs +8 -89
- package/commands/automation_loops.mjs +94 -0
- package/commands/chat.mjs +46 -680
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/help_text.mjs +78 -0
- package/commands/setup.mjs +1 -73
- package/daemon/lib/cost.mjs +5 -0
- package/daemon/route_table.mjs +1 -0
- package/daemon/routes/meta.mjs +29 -0
- package/daemon.mjs +1 -1
- package/lib/args.mjs +1 -1
- package/mas/agent_turn.mjs +7 -0
- package/package.json +1 -1
- package/providers/claude_cli.mjs +1 -1
- package/providers/claude_cli_session.mjs +21 -4
- package/providers/codex_cli.mjs +8 -5
- package/providers/gemini.mjs +4 -1
- package/providers/gemini_cli.mjs +17 -3
- package/providers/tool_use/gemini.mjs +12 -1
- package/providers/tool_use/openai.mjs +7 -0
- package/tui/banner.mjs +72 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +34 -180
- package/tui/repl.mjs +12 -165
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +12 -640
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_trainer.mjs +173 -0
- package/web/dashboard.js +7 -1
package/tui/repl.mjs
CHANGED
|
@@ -43,171 +43,18 @@ import { ModalPicker, filterModalItems, resolveModalPick } from './modal_picker.
|
|
|
43
43
|
import { theme } from './theme.mjs';
|
|
44
44
|
import { StatusBar } from './status_bar.mjs';
|
|
45
45
|
import { onConversationReset, clearTerminalScreen } from './repl_reset.mjs'; export { StatusBar };
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// We deliberately do NOT install an uncaughtException handler — Ink
|
|
59
|
-
// already installs one and re-throws; ours would swallow the stack
|
|
60
|
-
// trace (violates §1 Truthfulness / no silent catch).
|
|
61
|
-
//
|
|
62
|
-
// `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
|
|
63
|
-
// the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
|
|
64
|
-
// no escape sequences leak into stdout.
|
|
65
|
-
export const ALT_BUFFER_ENTER = '\x1b[?1049h';
|
|
66
|
-
export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
|
|
67
|
-
export const CURSOR_VISIBLE = '\x1b[?25h';
|
|
68
|
-
|
|
69
|
-
// Rendering-mode decision. Default = Static scrollback (no flicker; splash
|
|
70
|
-
// prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
|
|
71
|
-
// LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
|
|
72
|
-
export function computeAltEnabled(env, hasTTY) {
|
|
73
|
-
const e = env || {};
|
|
74
|
-
return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function FullScreen({ enabled, children }) {
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
if (!enabled) return undefined;
|
|
80
|
-
// Mount: enter alternate screen buffer.
|
|
81
|
-
try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
|
|
82
|
-
|
|
83
|
-
// Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
|
|
84
|
-
// terminal is restored even if React never gets a chance to unmount
|
|
85
|
-
// (e.g. parent process kills us with SIGTERM).
|
|
86
|
-
const restore = () => {
|
|
87
|
-
try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
|
|
88
|
-
};
|
|
89
|
-
const onExit = () => { restore(); };
|
|
90
|
-
const onSignal = () => { restore(); };
|
|
91
|
-
process.once('exit', onExit);
|
|
92
|
-
process.once('SIGINT', onSignal);
|
|
93
|
-
process.once('SIGTERM', onSignal);
|
|
94
|
-
process.once('SIGHUP', onSignal);
|
|
95
|
-
|
|
96
|
-
return () => {
|
|
97
|
-
// React unmount: restore primary buffer.
|
|
98
|
-
restore();
|
|
99
|
-
process.removeListener('exit', onExit);
|
|
100
|
-
process.removeListener('SIGINT', onSignal);
|
|
101
|
-
process.removeListener('SIGTERM', onSignal);
|
|
102
|
-
process.removeListener('SIGHUP', onSignal);
|
|
103
|
-
};
|
|
104
|
-
}, [enabled]);
|
|
105
|
-
return children;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ─── Pure state ──────────────────────────────────────────────────────────
|
|
109
|
-
//
|
|
110
|
-
// makeReplState stays callable with zero args (existing tests rely on it).
|
|
111
|
-
// The new fields default to empty so legacy callers see no behavior change.
|
|
112
|
-
export function makeReplState(opts) {
|
|
113
|
-
const splashItem = opts && opts.splashItem ? opts.splashItem : null;
|
|
114
|
-
return {
|
|
115
|
-
streaming: false,
|
|
116
|
-
controller: null,
|
|
117
|
-
pendingPrepend: null,
|
|
118
|
-
nextTurnFirstMessage: null,
|
|
119
|
-
history: [],
|
|
120
|
-
scrollback: splashItem ? [splashItem] : [],
|
|
121
|
-
liveAssistant: '',
|
|
122
|
-
turnCounter: 0,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export function onUserInput(state, { text, controller }) {
|
|
127
|
-
if (state.streaming && state.controller) {
|
|
128
|
-
// mid-stream interrupt — abort current turn, queue text for next turn.
|
|
129
|
-
try { state.controller.abort(); } catch {}
|
|
130
|
-
return { ...state, pendingPrepend: text };
|
|
131
|
-
}
|
|
132
|
-
// idle — start a new turn. Append a 'user' entry to scrollback so the
|
|
133
|
-
// sticky-layout caller sees the prompt history above the live stream.
|
|
134
|
-
const id = `u-${state.turnCounter}`;
|
|
135
|
-
return {
|
|
136
|
-
...state,
|
|
137
|
-
streaming: true,
|
|
138
|
-
controller,
|
|
139
|
-
history: [...state.history, text],
|
|
140
|
-
scrollback: [...state.scrollback, { kind: 'user', id, text }],
|
|
141
|
-
turnCounter: state.turnCounter + 1,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function onEscape(state) {
|
|
146
|
-
if (state.streaming && state.controller) {
|
|
147
|
-
try { state.controller.abort(); } catch {}
|
|
148
|
-
}
|
|
149
|
-
// Drop any partial live assistant text on explicit Esc — the user is
|
|
150
|
-
// telling us to discard, not to keep.
|
|
151
|
-
return {
|
|
152
|
-
...state,
|
|
153
|
-
streaming: false,
|
|
154
|
-
controller: null,
|
|
155
|
-
pendingPrepend: null,
|
|
156
|
-
liveAssistant: '',
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Stream chunk arrives. Completed lines are committed to the <Static>
|
|
161
|
-
// scrollback immediately (so they scroll up ABOVE the sticky editor), and only
|
|
162
|
-
// the in-progress trailing partial stays in the live region. Without this, a
|
|
163
|
-
// reply taller than the terminal grew the live frame past the viewport and
|
|
164
|
-
// spilled BELOW the input box (long orchestrator replies). Chunks without a
|
|
165
|
-
// newline still just accumulate (the prior behaviour), so short replies and the
|
|
166
|
-
// existing reducer tests are unchanged.
|
|
167
|
-
export function onStreamChunk(state, { chunk }) {
|
|
168
|
-
const buf = state.liveAssistant + chunk;
|
|
169
|
-
const nl = buf.lastIndexOf('\n');
|
|
170
|
-
if (nl < 0) return { ...state, liveAssistant: buf };
|
|
171
|
-
const complete = buf.slice(0, nl); // one or more whole lines
|
|
172
|
-
const remainder = buf.slice(nl + 1); // trailing partial (may be '')
|
|
173
|
-
const id = `as-${state.turnCounter}-${state.scrollback.length}`;
|
|
174
|
-
return {
|
|
175
|
-
...state,
|
|
176
|
-
scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
|
|
177
|
-
liveAssistant: remainder,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function onTurnComplete(state, { reason, error } = {}) {
|
|
182
|
-
const promoted = state.pendingPrepend;
|
|
183
|
-
const suffix = reason === 'aborted' ? ' [aborted]'
|
|
184
|
-
: reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
|
|
185
|
-
: '';
|
|
186
|
-
const text = (state.liveAssistant || '') + suffix;
|
|
187
|
-
// Commit any accumulated live text to scrollback. If the turn produced
|
|
188
|
-
// nothing AND wasn't an error/abort, skip the empty append.
|
|
189
|
-
const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
|
|
190
|
-
const id = `a-${state.turnCounter}`;
|
|
191
|
-
const kind = reason === 'error' ? 'error' : 'assistant';
|
|
192
|
-
const nextScrollback = shouldCommit
|
|
193
|
-
? [...state.scrollback, { kind, id, text }]
|
|
194
|
-
: state.scrollback;
|
|
195
|
-
return {
|
|
196
|
-
...state,
|
|
197
|
-
streaming: false,
|
|
198
|
-
controller: null,
|
|
199
|
-
pendingPrepend: null,
|
|
200
|
-
nextTurnFirstMessage: promoted,
|
|
201
|
-
liveAssistant: '',
|
|
202
|
-
scrollback: nextScrollback,
|
|
203
|
-
turnCounter: state.turnCounter + 1,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function consumeNextTurnFirstMessage(state) {
|
|
208
|
-
const msg = state.nextTurnFirstMessage;
|
|
209
|
-
return [{ ...state, nextTurnFirstMessage: null }, msg];
|
|
210
|
-
}
|
|
46
|
+
// Alt-buffer (DEC 1049) mount cluster moved to ./repl_altbuffer.mjs and pure
|
|
47
|
+
// state reducers moved to ./repl_reducers.mjs (file-size gate). Re-exported so
|
|
48
|
+
// every existing caller + test sees them on repl.mjs, and imported locally
|
|
49
|
+
// because the ReplApp body binds them directly.
|
|
50
|
+
import { computeAltEnabled, FullScreen } from './repl_altbuffer.mjs';
|
|
51
|
+
export { ALT_BUFFER_ENTER, ALT_BUFFER_LEAVE, CURSOR_VISIBLE, computeAltEnabled, FullScreen } from './repl_altbuffer.mjs';
|
|
52
|
+
import {
|
|
53
|
+
makeReplState, onUserInput, onEscape, onStreamChunk, onTurnComplete,
|
|
54
|
+
} from './repl_reducers.mjs';
|
|
55
|
+
export {
|
|
56
|
+
makeReplState, onUserInput, onEscape, onStreamChunk, onTurnComplete, consumeNextTurnFirstMessage,
|
|
57
|
+
} from './repl_reducers.mjs';
|
|
211
58
|
|
|
212
59
|
// ─── React mount ─────────────────────────────────────────────────────────
|
|
213
60
|
//
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// tui/repl_altbuffer.mjs — alt-buffer (DEC 1049) mount cluster for the REPL.
|
|
2
|
+
//
|
|
3
|
+
// Extracted verbatim from repl.mjs (file-size gate). Wraps the React tree
|
|
4
|
+
// with mount/unmount side-effects that enable the terminal alternate screen
|
|
5
|
+
// buffer. Three-layer cleanup so the user never gets stranded on the alt
|
|
6
|
+
// canvas:
|
|
7
|
+
// 1. React unmount → useEffect return-fn writes \x1b[?1049l
|
|
8
|
+
// 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
|
|
9
|
+
// process-level listeners that we install + remove on unmount.
|
|
10
|
+
// 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
|
|
11
|
+
// below us turned it off.
|
|
12
|
+
//
|
|
13
|
+
// We deliberately do NOT install an uncaughtException handler — Ink
|
|
14
|
+
// already installs one and re-throws; ours would swallow the stack
|
|
15
|
+
// trace (violates §1 Truthfulness / no silent catch).
|
|
16
|
+
//
|
|
17
|
+
// `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
|
|
18
|
+
// the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
|
|
19
|
+
// no escape sequences leak into stdout.
|
|
20
|
+
import { useEffect } from 'react';
|
|
21
|
+
|
|
22
|
+
export const ALT_BUFFER_ENTER = '\x1b[?1049h';
|
|
23
|
+
export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
|
|
24
|
+
export const CURSOR_VISIBLE = '\x1b[?25h';
|
|
25
|
+
|
|
26
|
+
// Rendering-mode decision. Default = Static scrollback (no flicker; splash
|
|
27
|
+
// prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
|
|
28
|
+
// LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
|
|
29
|
+
export function computeAltEnabled(env, hasTTY) {
|
|
30
|
+
const e = env || {};
|
|
31
|
+
return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function FullScreen({ enabled, children }) {
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!enabled) return undefined;
|
|
37
|
+
// Mount: enter alternate screen buffer.
|
|
38
|
+
try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
|
|
39
|
+
|
|
40
|
+
// Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
|
|
41
|
+
// terminal is restored even if React never gets a chance to unmount
|
|
42
|
+
// (e.g. parent process kills us with SIGTERM).
|
|
43
|
+
const restore = () => {
|
|
44
|
+
try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
|
|
45
|
+
};
|
|
46
|
+
const onExit = () => { restore(); };
|
|
47
|
+
const onSignal = () => { restore(); };
|
|
48
|
+
process.once('exit', onExit);
|
|
49
|
+
process.once('SIGINT', onSignal);
|
|
50
|
+
process.once('SIGTERM', onSignal);
|
|
51
|
+
process.once('SIGHUP', onSignal);
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
// React unmount: restore primary buffer.
|
|
55
|
+
restore();
|
|
56
|
+
process.removeListener('exit', onExit);
|
|
57
|
+
process.removeListener('SIGINT', onSignal);
|
|
58
|
+
process.removeListener('SIGTERM', onSignal);
|
|
59
|
+
process.removeListener('SIGHUP', onSignal);
|
|
60
|
+
};
|
|
61
|
+
}, [enabled]);
|
|
62
|
+
return children;
|
|
63
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// tui/repl_reducers.mjs — pure state reducers for the REPL host.
|
|
2
|
+
//
|
|
3
|
+
// Extracted verbatim from repl.mjs (file-size gate). These are pure
|
|
4
|
+
// functions: no shared mutable module state, no JSX. They keep their
|
|
5
|
+
// pre-v5.3 shapes — tests/phaseC-repl-interrupt.test.mjs depends on them.
|
|
6
|
+
//
|
|
7
|
+
// Backward-compat contracts (do not break):
|
|
8
|
+
// - makeReplState() — still callable with zero args.
|
|
9
|
+
// - onUserInput, onEscape, onTurnComplete, consumeNextTurnFirstMessage
|
|
10
|
+
// keep their pre-v5.3 shapes.
|
|
11
|
+
|
|
12
|
+
// ─── Pure state ──────────────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// makeReplState stays callable with zero args (existing tests rely on it).
|
|
15
|
+
// The new fields default to empty so legacy callers see no behavior change.
|
|
16
|
+
export function makeReplState(opts) {
|
|
17
|
+
const splashItem = opts && opts.splashItem ? opts.splashItem : null;
|
|
18
|
+
return {
|
|
19
|
+
streaming: false,
|
|
20
|
+
controller: null,
|
|
21
|
+
pendingPrepend: null,
|
|
22
|
+
nextTurnFirstMessage: null,
|
|
23
|
+
history: [],
|
|
24
|
+
scrollback: splashItem ? [splashItem] : [],
|
|
25
|
+
liveAssistant: '',
|
|
26
|
+
turnCounter: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function onUserInput(state, { text, controller }) {
|
|
31
|
+
if (state.streaming && state.controller) {
|
|
32
|
+
// mid-stream interrupt — abort current turn, queue text for next turn.
|
|
33
|
+
try { state.controller.abort(); } catch {}
|
|
34
|
+
return { ...state, pendingPrepend: text };
|
|
35
|
+
}
|
|
36
|
+
// idle — start a new turn. Append a 'user' entry to scrollback so the
|
|
37
|
+
// sticky-layout caller sees the prompt history above the live stream.
|
|
38
|
+
const id = `u-${state.turnCounter}`;
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
streaming: true,
|
|
42
|
+
controller,
|
|
43
|
+
history: [...state.history, text],
|
|
44
|
+
scrollback: [...state.scrollback, { kind: 'user', id, text }],
|
|
45
|
+
turnCounter: state.turnCounter + 1,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function onEscape(state) {
|
|
50
|
+
if (state.streaming && state.controller) {
|
|
51
|
+
try { state.controller.abort(); } catch {}
|
|
52
|
+
}
|
|
53
|
+
// Drop any partial live assistant text on explicit Esc — the user is
|
|
54
|
+
// telling us to discard, not to keep.
|
|
55
|
+
return {
|
|
56
|
+
...state,
|
|
57
|
+
streaming: false,
|
|
58
|
+
controller: null,
|
|
59
|
+
pendingPrepend: null,
|
|
60
|
+
liveAssistant: '',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Stream chunk arrives. Completed lines are committed to the <Static>
|
|
65
|
+
// scrollback immediately (so they scroll up ABOVE the sticky editor), and only
|
|
66
|
+
// the in-progress trailing partial stays in the live region. Without this, a
|
|
67
|
+
// reply taller than the terminal grew the live frame past the viewport and
|
|
68
|
+
// spilled BELOW the input box (long orchestrator replies). Chunks without a
|
|
69
|
+
// newline still just accumulate (the prior behaviour), so short replies and the
|
|
70
|
+
// existing reducer tests are unchanged.
|
|
71
|
+
export function onStreamChunk(state, { chunk }) {
|
|
72
|
+
const buf = state.liveAssistant + chunk;
|
|
73
|
+
const nl = buf.lastIndexOf('\n');
|
|
74
|
+
if (nl < 0) return { ...state, liveAssistant: buf };
|
|
75
|
+
const complete = buf.slice(0, nl); // one or more whole lines
|
|
76
|
+
const remainder = buf.slice(nl + 1); // trailing partial (may be '')
|
|
77
|
+
const id = `as-${state.turnCounter}-${state.scrollback.length}`;
|
|
78
|
+
return {
|
|
79
|
+
...state,
|
|
80
|
+
scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
|
|
81
|
+
liveAssistant: remainder,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function onTurnComplete(state, { reason, error } = {}) {
|
|
86
|
+
const promoted = state.pendingPrepend;
|
|
87
|
+
const suffix = reason === 'aborted' ? ' [aborted]'
|
|
88
|
+
: reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
|
|
89
|
+
: '';
|
|
90
|
+
const text = (state.liveAssistant || '') + suffix;
|
|
91
|
+
// Commit any accumulated live text to scrollback. If the turn produced
|
|
92
|
+
// nothing AND wasn't an error/abort, skip the empty append.
|
|
93
|
+
const shouldCommit = text.length > 0 && (state.liveAssistant.length > 0 || suffix.length > 0);
|
|
94
|
+
const id = `a-${state.turnCounter}`;
|
|
95
|
+
const kind = reason === 'error' ? 'error' : 'assistant';
|
|
96
|
+
const nextScrollback = shouldCommit
|
|
97
|
+
? [...state.scrollback, { kind, id, text }]
|
|
98
|
+
: state.scrollback;
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
streaming: false,
|
|
102
|
+
controller: null,
|
|
103
|
+
pendingPrepend: null,
|
|
104
|
+
nextTurnFirstMessage: promoted,
|
|
105
|
+
liveAssistant: '',
|
|
106
|
+
scrollback: nextScrollback,
|
|
107
|
+
turnCounter: state.turnCounter + 1,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function consumeNextTurnFirstMessage(state) {
|
|
112
|
+
const msg = state.nextTurnFirstMessage;
|
|
113
|
+
return [{ ...state, nextTurnFirstMessage: null }, msg];
|
|
114
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// tui/slash_channels.mjs — the /channels and /context slash-command handlers,
|
|
2
|
+
// extracted verbatim from slash_dispatcher.mjs. Imports the shared masked-prompt
|
|
3
|
+
// helper from slash_helpers.mjs and the dotenv shim from a leaf lib, never the
|
|
4
|
+
// dispatcher (no cycle).
|
|
5
|
+
|
|
6
|
+
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
7
|
+
import { _promptText } from './slash_helpers.mjs';
|
|
8
|
+
|
|
9
|
+
// /channels — view configured channels and toggle them. `/channels` lists;
|
|
10
|
+
// `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
|
|
11
|
+
// available, else lib/config directly, so it works on both REPL paths.
|
|
12
|
+
export async function _channels(args, ctx = {}) {
|
|
13
|
+
const cf = await import('../config_features.mjs');
|
|
14
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
15
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
16
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
17
|
+
const toks = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
18
|
+
const [name, action] = toks;
|
|
19
|
+
|
|
20
|
+
// `/channels [<name>] setup` — set the channel's credentials (bot token,
|
|
21
|
+
// homeserver, …) from chat instead of redirecting to /config. Reuses the
|
|
22
|
+
// masked modal prompt (_promptText) so secrets are never echoed. No modal →
|
|
23
|
+
// fall back to the readline channel step (same as /config's channel item).
|
|
24
|
+
const wantSetup = toks.some((t) => /^setup$/i.test(t));
|
|
25
|
+
if (wantSetup) {
|
|
26
|
+
const channelMod = await import('../commands/setup_channels.mjs');
|
|
27
|
+
const picked = toks.find((t) => !/^setup$/i.test(t));
|
|
28
|
+
if (typeof ctx.openPicker !== 'function') {
|
|
29
|
+
// Readline path: hand off to the existing channel wizard step.
|
|
30
|
+
ctx.requestConfigStep = 'channel';
|
|
31
|
+
return 'EXIT';
|
|
32
|
+
}
|
|
33
|
+
let chName = picked && picked.toLowerCase();
|
|
34
|
+
if (!chName) {
|
|
35
|
+
const sel = await ctx.openPicker({
|
|
36
|
+
kind: 'menu',
|
|
37
|
+
title: 'channel — set credentials',
|
|
38
|
+
subtitle: 'pick a channel to configure',
|
|
39
|
+
items: channelMod.CHANNEL_CATALOG.map((c) => ({
|
|
40
|
+
id: c.name,
|
|
41
|
+
label: c.label,
|
|
42
|
+
desc: c.builtin ? '' : `needs: ${(c.deps && c.deps.length) ? c.deps.join(', ') : (c.binary || 'creds only')}`,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
45
|
+
chName = sel && typeof sel === 'object' ? sel.id : sel;
|
|
46
|
+
if (!chName || typeof chName !== 'string') return 'channel setup: cancelled';
|
|
47
|
+
}
|
|
48
|
+
const spec = channelMod.channelByName(chName);
|
|
49
|
+
if (!spec) return `unknown channel: ${chName} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
50
|
+
if (!spec.fields.length) {
|
|
51
|
+
// No creds (e.g. http / whatsapp) — just enable it.
|
|
52
|
+
const cfgDirX = ctx.cfgDir || (await import('node:path')).dirname(cfgMod.configPath());
|
|
53
|
+
channelMod.persistChannel(cfgDirX, chName, {});
|
|
54
|
+
if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: true }; }
|
|
55
|
+
return `channel ${chName} → enabled (no credentials needed)`;
|
|
56
|
+
}
|
|
57
|
+
const answers = {};
|
|
58
|
+
for (const f of spec.fields) {
|
|
59
|
+
const v = await _promptText(ctx, {
|
|
60
|
+
title: `${spec.label} — ${f.prompt}`,
|
|
61
|
+
subtitle: f.optional ? 'optional · Esc to skip' : 'Esc cancels',
|
|
62
|
+
secret: !!f.secret,
|
|
63
|
+
allowEmpty: !!f.optional,
|
|
64
|
+
});
|
|
65
|
+
if (v === null) {
|
|
66
|
+
if (f.optional) continue; // Esc on an optional field → skip it
|
|
67
|
+
return 'channel setup: cancelled';
|
|
68
|
+
}
|
|
69
|
+
if (v) answers[f.key] = v;
|
|
70
|
+
}
|
|
71
|
+
const path = await import('node:path');
|
|
72
|
+
const cfgDirX = ctx.cfgDir || path.dirname(cfgMod.configPath());
|
|
73
|
+
const entry = channelMod.persistChannel(cfgDirX, chName, answers);
|
|
74
|
+
// Mirror the PERSISTED enabled state onto the in-session cfg so a follow-up
|
|
75
|
+
// list is fresh — an in-tree channel whose runtime dep is missing stays
|
|
76
|
+
// disabled (persistChannel gates it) rather than being force-enabled.
|
|
77
|
+
if (ctx.cfg) { ctx.cfg.channels = ctx.cfg.channels || {}; ctx.cfg.channels[chName] = { ...(ctx.cfg.channels[chName] || {}), enabled: !!entry.ready }; }
|
|
78
|
+
const setKeys = Object.keys(answers);
|
|
79
|
+
let note = '';
|
|
80
|
+
if (!entry.ready) {
|
|
81
|
+
if (entry.missingDeps && entry.missingDeps.length) note += `\n(needs ${entry.missingDeps.join(', ')} — run: lazyclaw channels install ${chName})`;
|
|
82
|
+
if (entry.missingBinary) note += `\n(needs the ${entry.missingBinary} binary on your PATH)`;
|
|
83
|
+
}
|
|
84
|
+
return `✓ ${spec.label} credentials saved (${setKeys.join(', ') || 'none'}) → ${entry.ready ? 'channel enabled' : 'saved (enable once the requirement is installed)'}${note}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `/channels <name> test` — verify the stored credentials with a live call.
|
|
88
|
+
if (name && /^test$/i.test(action || '')) {
|
|
89
|
+
const channelMod = await import('../commands/setup_channels.mjs');
|
|
90
|
+
try { loadDotenvIfAny(ctx.cfgDir); } catch { /* best-effort */ }
|
|
91
|
+
const r = await channelMod.verifyChannel(name.toLowerCase());
|
|
92
|
+
if (r.ok === true) return `✓ ${name} verified — ${r.detail}`;
|
|
93
|
+
if (r.ok === null) return `· ${name}: ${r.detail}`;
|
|
94
|
+
return `✗ ${name}: ${r.detail}${r.hint ? `\n fix: ${r.hint}` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
|
|
98
|
+
const en = /^(on|enable)$/i.test(action);
|
|
99
|
+
const cfg = read();
|
|
100
|
+
const key = name.toLowerCase();
|
|
101
|
+
// Reject unknown names so a typo can't silently create a bogus
|
|
102
|
+
// cfg.channels.<name> section (which would then leak into the list).
|
|
103
|
+
// Stay permissive for pre-existing custom sections.
|
|
104
|
+
const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
105
|
+
if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
|
|
106
|
+
return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
|
|
107
|
+
}
|
|
108
|
+
cf.channelSetEnabled(cfg, key, en); write(cfg);
|
|
109
|
+
// Legacy fallback path: the readline ctx (_legacyCtx) has no
|
|
110
|
+
// readConfig/writeConfig, so we read/wrote disk above against a fresh
|
|
111
|
+
// cfg object. Mirror the toggle onto the in-session ctx.cfg so a
|
|
112
|
+
// follow-up `/channels` (list) or other in-session read stays
|
|
113
|
+
// consistent instead of showing the stale pre-toggle value.
|
|
114
|
+
if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
|
|
115
|
+
cf.channelSetEnabled(ctx.cfg, key, en);
|
|
116
|
+
}
|
|
117
|
+
return en
|
|
118
|
+
? `channel ${key} → enabled`
|
|
119
|
+
: `channel ${key} → disabled (re-enable with /channels ${key} on)`;
|
|
120
|
+
}
|
|
121
|
+
// No-arg + modal → an action menu: each row toggles in place, plus a
|
|
122
|
+
// "set credentials" row. Falls through to the text list when no modal.
|
|
123
|
+
if (!toks.length && typeof ctx.openPicker === 'function') {
|
|
124
|
+
const statusRows = cf.channelStatusList(read());
|
|
125
|
+
const items = statusRows.map((c) => ({
|
|
126
|
+
id: `toggle:${c.name}`,
|
|
127
|
+
label: `${c.name} — ${c.enabled ? 'enabled' : 'disabled'}`,
|
|
128
|
+
desc: c.enabled ? 'Enter to disable' : 'Enter to enable',
|
|
129
|
+
}));
|
|
130
|
+
items.push({ id: 'setup', label: '+ Set credentials…', desc: 'pick a channel and enter bot token / homeserver / …' });
|
|
131
|
+
const picked = await ctx.openPicker({ kind: 'menu', title: 'Channels', subtitle: `${statusRows.length} configured`, items });
|
|
132
|
+
const pid = picked && typeof picked === 'object' ? picked.id : picked;
|
|
133
|
+
if (!pid || typeof pid !== 'string') return 'cancelled';
|
|
134
|
+
if (pid === 'setup') return _channels('setup', ctx);
|
|
135
|
+
if (pid.startsWith('toggle:')) {
|
|
136
|
+
const nm = pid.slice(7);
|
|
137
|
+
const cur = statusRows.find((r) => r.name === nm);
|
|
138
|
+
return _channels(`${nm} ${cur && cur.enabled ? 'off' : 'on'}`, ctx);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const rows = cf.channelStatusList(read());
|
|
142
|
+
if (!rows.length) return 'no channels configured. set credentials with /channels setup (or `lazyclaw setup` for the full wizard).';
|
|
143
|
+
// Cross-reference each channel's required env creds against the loaded env so
|
|
144
|
+
// the list flags a channel that's "enabled" but missing its token.
|
|
145
|
+
let channelMod = null;
|
|
146
|
+
try {
|
|
147
|
+
loadDotenvIfAny(ctx.cfgDir);
|
|
148
|
+
channelMod = await import('../commands/setup_channels.mjs');
|
|
149
|
+
} catch { /* hint is best-effort */ }
|
|
150
|
+
const missingFor = (name) => {
|
|
151
|
+
const spec = channelMod && channelMod.channelByName(name);
|
|
152
|
+
if (!spec) return [];
|
|
153
|
+
return spec.fields.filter((f) => !f.optional && !process.env[f.env]).map((f) => f.env);
|
|
154
|
+
};
|
|
155
|
+
const lines = ['configured channels:'];
|
|
156
|
+
for (const c of rows) {
|
|
157
|
+
const miss = missingFor(c.name);
|
|
158
|
+
const credNote = miss.length ? ` · creds: missing ${miss.join(', ')}` : '';
|
|
159
|
+
lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}${credNote}`);
|
|
160
|
+
}
|
|
161
|
+
lines.push('toggle: /channels <name> on|off · set creds: /channels <name> setup');
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// /context — view/set the chat history window (turns + token budget). This is
|
|
166
|
+
// the sliding history budget sent each turn, NOT the model's hard context
|
|
167
|
+
// limit. ctx-or-lib/config fallback so it works on both REPL paths.
|
|
168
|
+
export async function _context(args, ctx = {}) {
|
|
169
|
+
const cf = await import('../config_features.mjs');
|
|
170
|
+
const cfgMod = await import('../lib/config.mjs');
|
|
171
|
+
const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
|
|
172
|
+
const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
|
|
173
|
+
const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
|
|
174
|
+
const parts = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
175
|
+
const sub = (parts[0] || 'status').toLowerCase();
|
|
176
|
+
const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
|
|
177
|
+
// No-arg + modal → action menu → numeric picker (mirrors orchestrator maxsubtasks).
|
|
178
|
+
if (!parts.length && typeof ctx.openPicker === 'function') {
|
|
179
|
+
const w = cf.chatWindowGet(read());
|
|
180
|
+
const action = await ctx.openPicker({
|
|
181
|
+
kind: 'menu', title: 'Context window (history budget)', subtitle: `now ${w.turns} turns · ${w.tokens} tokens`,
|
|
182
|
+
items: [
|
|
183
|
+
{ id: 'turns', label: 'Set turns…', desc: 'past turns to send' },
|
|
184
|
+
{ id: 'tokens', label: 'Set tokens…', desc: 'token budget (min 256)' },
|
|
185
|
+
{ id: 'status', label: 'Status', desc: 'show current' },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
const aid = action && typeof action === 'object' ? action.id : action;
|
|
189
|
+
if (!aid || typeof aid !== 'string' || aid === 'status') return fmt();
|
|
190
|
+
if (aid === 'turns') {
|
|
191
|
+
const np = await ctx.openPicker({ kind: 'menu', title: 'Turns to keep', subtitle: `currently ${w.turns}`, items: [5, 10, 15, 20, 30, 40, 50].map((x) => ({ id: String(x), label: String(x) })) });
|
|
192
|
+
const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
|
|
193
|
+
if (!Number.isFinite(v)) return 'context turns: cancelled';
|
|
194
|
+
const cfg = read(); cf.chatWindowSet(cfg, { turns: v }); persist(cfg); return fmt();
|
|
195
|
+
}
|
|
196
|
+
if (aid === 'tokens') {
|
|
197
|
+
const np = await ctx.openPicker({ kind: 'menu', title: 'Token budget', subtitle: `currently ${w.tokens}`, items: [2000, 4000, 8000, 12000, 16000, 32000].map((x) => ({ id: String(x), label: String(x) })) });
|
|
198
|
+
const v = parseInt(np && typeof np === 'object' ? np.id : np, 10);
|
|
199
|
+
if (!Number.isFinite(v) || v < 256) return 'context tokens: cancelled';
|
|
200
|
+
const cfg = read(); cf.chatWindowSet(cfg, { tokens: v }); persist(cfg); return fmt();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (sub === 'status') return fmt();
|
|
204
|
+
const n = parseInt(parts[1], 10);
|
|
205
|
+
if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
|
|
206
|
+
if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
|
|
207
|
+
return 'usage: /context [status | turns <N> | tokens <N>]';
|
|
208
|
+
}
|