lazyclaw 5.3.3 → 5.4.1
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/cli.mjs +37 -42
- package/package.json +1 -1
- package/tui/repl.mjs +163 -44
- package/tui/slash_dispatcher.mjs +619 -0
package/cli.mjs
CHANGED
|
@@ -14,6 +14,10 @@ import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOK
|
|
|
14
14
|
// v5 Group C (C7) — shared chat-turn streaming closure. Single source
|
|
15
15
|
// of truth for both the ink REPL path and the legacy readline path.
|
|
16
16
|
import { makeRunTurn as _chatRunTurnFactory } from './tui/run_turn.mjs';
|
|
17
|
+
// v5.4: full slash-command dispatcher (24 commands) for the Ink branch.
|
|
18
|
+
// Lifted from the legacy handleSlash so the Ink REPL stops shipping the
|
|
19
|
+
// "not yet wired" placeholder.
|
|
20
|
+
import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from './tui/slash_dispatcher.mjs';
|
|
17
21
|
|
|
18
22
|
async function loadEngine() {
|
|
19
23
|
return import('./workflow/persistent.mjs');
|
|
@@ -2646,19 +2650,37 @@ async function cmdChat(flags = {}) {
|
|
|
2646
2650
|
}).catch(() => {});
|
|
2647
2651
|
} catch { /* swallow */ }
|
|
2648
2652
|
};
|
|
2653
|
+
// v5.4: chars-sent counter for the Ink chat path. Mirrors the legacy
|
|
2654
|
+
// path's `charsSent` so /usage in Ink reports the same number.
|
|
2655
|
+
let _inkCharsSent = 0;
|
|
2649
2656
|
const _inkCtx = {
|
|
2650
2657
|
cfg,
|
|
2651
2658
|
cfgDir: _inkCfgDir,
|
|
2652
2659
|
sandboxSpec: _inkSandboxSpec,
|
|
2653
2660
|
syntheticChatSessionId: _inkSyntheticChatSessionId,
|
|
2661
|
+
version: readVersionFromRepo(),
|
|
2662
|
+
registryMod: _registryMod,
|
|
2663
|
+
sessionsMod,
|
|
2664
|
+
// Pre-imported so dispatcher avoids a dynamic import per /skill call.
|
|
2665
|
+
skillsMod,
|
|
2654
2666
|
getMessages: () => _inkMessages,
|
|
2667
|
+
setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
|
|
2655
2668
|
getProv: () => prov,
|
|
2669
|
+
setProv: (next) => { prov = next; },
|
|
2656
2670
|
getActiveProvName: () => activeProvName,
|
|
2671
|
+
setActiveProvName: (name) => { activeProvName = name; },
|
|
2657
2672
|
getActiveModel: () => activeModel,
|
|
2673
|
+
setActiveModel: (name) => { activeModel = name; },
|
|
2658
2674
|
getSessionId: () => _inkSessionId,
|
|
2675
|
+
setSessionId: (id) => { _inkSessionId = id; },
|
|
2676
|
+
getCharsSent: () => _inkCharsSent,
|
|
2677
|
+
setCharsSent: (n) => { _inkCharsSent = Number(n) || 0; },
|
|
2678
|
+
getRunningUsage: () => _inkRunningUsage,
|
|
2679
|
+
setRunningUsage: (u) => { _inkRunningUsage = u; },
|
|
2659
2680
|
persistTurn: _inkPersistTurn,
|
|
2660
2681
|
accumulateUsage: _inkAccumulateUsage,
|
|
2661
2682
|
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
2683
|
+
onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
|
|
2662
2684
|
};
|
|
2663
2685
|
// v5.0.10: write streamed chunks straight to process.stdout. Ink
|
|
2664
2686
|
// owns the screen, so interleaved stdout writes can produce some
|
|
@@ -2669,54 +2691,27 @@ async function cmdChat(flags = {}) {
|
|
|
2669
2691
|
ctx: _inkCtx,
|
|
2670
2692
|
writeFn: (chunk) => process.stdout.write(chunk),
|
|
2671
2693
|
});
|
|
2672
|
-
//
|
|
2673
|
-
//
|
|
2674
|
-
//
|
|
2675
|
-
// are intercepted inside ReplApp
|
|
2676
|
-
//
|
|
2677
|
-
//
|
|
2678
|
-
// The full set of mutating commands (/model, /provider, /skill,
|
|
2679
|
-
// /personality, ...) still lives in the legacy readline path and
|
|
2680
|
-
// remains accessible via LAZYCLAW_NO_INK=1. Wiring those through
|
|
2681
|
-
// the Ink branch is a follow-up; today we at least stop sending
|
|
2682
|
-
// them as prompts.
|
|
2694
|
+
// v5.4: full slash-command dispatch via tui/slash_dispatcher.mjs.
|
|
2695
|
+
// Dispatcher returns a string (rendered to scrollback by ReplApp),
|
|
2696
|
+
// 'EXIT' (caller unmounts), or void (streamed via write). /exit and
|
|
2697
|
+
// /quit are also intercepted earlier inside ReplApp.handleSubmit so
|
|
2698
|
+
// either path terminates cleanly.
|
|
2683
2699
|
const _inkSlashHandler = async (line) => {
|
|
2684
|
-
const cmd = line
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
|
|
2689
|
-
return lines.join('\n') + '\n';
|
|
2690
|
-
}
|
|
2691
|
-
case '/status': {
|
|
2692
|
-
const out = {
|
|
2693
|
-
provider: activeProvName,
|
|
2694
|
-
model: activeModel,
|
|
2695
|
-
keyMasked: _registryMod.maskApiKey(cfg['api-key']),
|
|
2696
|
-
messageCount: _inkMessages.length,
|
|
2697
|
-
};
|
|
2698
|
-
return JSON.stringify(out) + '\n';
|
|
2699
|
-
}
|
|
2700
|
-
case '/version': {
|
|
2701
|
-
return `lazyclaw ${readVersionFromRepo()} (node ${process.version}, ${process.platform})\n`;
|
|
2702
|
-
}
|
|
2703
|
-
case '/exit':
|
|
2704
|
-
case '/quit':
|
|
2705
|
-
// ReplApp intercepts these before onSlashCommand fires, but
|
|
2706
|
-
// return EXIT defensively in case that contract ever changes.
|
|
2707
|
-
return 'EXIT';
|
|
2708
|
-
default:
|
|
2709
|
-
// Mutating commands still require the legacy readline path.
|
|
2710
|
-
// Telling the user explicitly beats silently sending the
|
|
2711
|
-
// slash text to the model as a prompt.
|
|
2712
|
-
return `${cmd} is not yet wired into the ink REPL — set LAZYCLAW_NO_INK=1 and restart to use it.\n`;
|
|
2713
|
-
}
|
|
2700
|
+
const { cmd, args } = _parseSlashLine(line);
|
|
2701
|
+
return _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
|
|
2702
|
+
try { process.stdout.write(chunk); } catch { /* swallow */ }
|
|
2703
|
+
});
|
|
2714
2704
|
};
|
|
2705
|
+
// v5.4.1: splash renders INSIDE the alt-buffer (not pre-printed to
|
|
2706
|
+
// primary). The v5.4.0 pre-print made the screen go blank during
|
|
2707
|
+
// chat because alt-buffer cleared it on enter. Splash lives in the
|
|
2708
|
+
// Static scrollback now regardless of alt-buffer state.
|
|
2715
2709
|
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
2716
2710
|
splashProps,
|
|
2711
|
+
statusInfo: { provider: activeProvName, model: activeModel },
|
|
2717
2712
|
runTurn: _inkRunTurn,
|
|
2718
2713
|
onSlashCommand: _inkSlashHandler,
|
|
2719
|
-
}));
|
|
2714
|
+
}), { exitOnCtrlC: true, patchConsole: true });
|
|
2720
2715
|
await ink.waitUntilExit();
|
|
2721
2716
|
return;
|
|
2722
2717
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.1",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/tui/repl.mjs
CHANGED
|
@@ -29,13 +29,66 @@
|
|
|
29
29
|
// sticky layout; ReplApp injects writeFn → scrollback.
|
|
30
30
|
//
|
|
31
31
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
32
|
-
import { Box, Static, Text, useApp } from 'ink';
|
|
32
|
+
import { Box, Static, Text, useApp, useStdout } from 'ink';
|
|
33
33
|
import { Splash, renderSplashToString } from './splash.mjs';
|
|
34
34
|
import { Editor } from './editor.mjs';
|
|
35
35
|
import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
|
|
36
36
|
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
37
37
|
import { theme } from './theme.mjs';
|
|
38
38
|
|
|
39
|
+
// ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
|
|
40
|
+
//
|
|
41
|
+
// Wraps the React tree with mount/unmount side-effects that enable the
|
|
42
|
+
// terminal alternate screen buffer. Three-layer cleanup so the user never
|
|
43
|
+
// gets stranded on the alt canvas:
|
|
44
|
+
// 1. React unmount → useEffect return-fn writes \x1b[?1049l
|
|
45
|
+
// 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
|
|
46
|
+
// process-level listeners that we install + remove on unmount.
|
|
47
|
+
// 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
|
|
48
|
+
// below us turned it off.
|
|
49
|
+
//
|
|
50
|
+
// We deliberately do NOT install an uncaughtException handler — Ink
|
|
51
|
+
// already installs one and re-throws; ours would swallow the stack
|
|
52
|
+
// trace (violates §1 Truthfulness / no silent catch).
|
|
53
|
+
//
|
|
54
|
+
// `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
|
|
55
|
+
// the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
|
|
56
|
+
// no escape sequences leak into stdout.
|
|
57
|
+
export const ALT_BUFFER_ENTER = '\x1b[?1049h';
|
|
58
|
+
export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
|
|
59
|
+
export const CURSOR_VISIBLE = '\x1b[?25h';
|
|
60
|
+
|
|
61
|
+
export function FullScreen({ enabled, children }) {
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!enabled) return undefined;
|
|
64
|
+
// Mount: enter alternate screen buffer.
|
|
65
|
+
try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
|
|
66
|
+
|
|
67
|
+
// Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
|
|
68
|
+
// terminal is restored even if React never gets a chance to unmount
|
|
69
|
+
// (e.g. parent process kills us with SIGTERM).
|
|
70
|
+
const restore = () => {
|
|
71
|
+
try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
|
|
72
|
+
};
|
|
73
|
+
const onExit = () => { restore(); };
|
|
74
|
+
const onSignal = () => { restore(); };
|
|
75
|
+
process.once('exit', onExit);
|
|
76
|
+
process.once('SIGINT', onSignal);
|
|
77
|
+
process.once('SIGTERM', onSignal);
|
|
78
|
+
process.once('SIGHUP', onSignal);
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
// React unmount: restore primary buffer.
|
|
82
|
+
restore();
|
|
83
|
+
process.removeListener('exit', onExit);
|
|
84
|
+
process.removeListener('SIGINT', onSignal);
|
|
85
|
+
process.removeListener('SIGTERM', onSignal);
|
|
86
|
+
process.removeListener('SIGHUP', onSignal);
|
|
87
|
+
};
|
|
88
|
+
}, [enabled]);
|
|
89
|
+
return children;
|
|
90
|
+
}
|
|
91
|
+
|
|
39
92
|
// ─── Pure state ──────────────────────────────────────────────────────────
|
|
40
93
|
//
|
|
41
94
|
// makeReplState stays callable with zero args (existing tests rely on it).
|
|
@@ -130,7 +183,13 @@ export function consumeNextTurnFirstMessage(state) {
|
|
|
130
183
|
// - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
|
|
131
184
|
// - runTurn(text, signal) (legacy, stdout)
|
|
132
185
|
// Legacy mode is preserved verbatim for the existing cli.mjs callsite.
|
|
133
|
-
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand }) {
|
|
186
|
+
export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo }) {
|
|
187
|
+
// statusInfo lets the host supply provider/model/ctx to the StatusBar
|
|
188
|
+
// independently of splashProps. Needed for the alt-buffer hand-off
|
|
189
|
+
// where splashProps is pre-printed to the primary buffer (not the
|
|
190
|
+
// alt canvas) and nulled out here to suppress the in-tree Static
|
|
191
|
+
// splash item — but the StatusBar still needs provider/model strings.
|
|
192
|
+
const _status = statusInfo || splashProps || {};
|
|
134
193
|
// Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
|
|
135
194
|
// so SSR-style imports without a TTY don't crash on process.stdout.
|
|
136
195
|
const splashItemRef = useRef(null);
|
|
@@ -142,6 +201,28 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
142
201
|
const [state, setState] = useState(() => makeReplState({ splashItem: splashItemRef.current }));
|
|
143
202
|
const { exit } = useApp();
|
|
144
203
|
|
|
204
|
+
// Alt-buffer eligibility — TTY only, opt-out via LAZYCLAW_NO_ALT=1.
|
|
205
|
+
// Captured in a ref so the value is stable across renders (it cannot
|
|
206
|
+
// change at runtime; isTTY + env are read once on mount).
|
|
207
|
+
const altEnabledRef = useRef(null);
|
|
208
|
+
if (altEnabledRef.current === null) {
|
|
209
|
+
altEnabledRef.current = !!(process.stdout && process.stdout.isTTY) && !process.env.LAZYCLAW_NO_ALT;
|
|
210
|
+
}
|
|
211
|
+
const altEnabled = altEnabledRef.current;
|
|
212
|
+
|
|
213
|
+
// Pin the outer column height to rows-1 in alt-buffer mode so Ink's
|
|
214
|
+
// sticky-bottom Editor actually pins. Non-alt mode keeps the legacy
|
|
215
|
+
// content-sized layout so existing tests + non-TTY fallbacks behave
|
|
216
|
+
// identically. Listen for SIGWINCH-driven resize events.
|
|
217
|
+
const { stdout } = useStdout();
|
|
218
|
+
const [rows, setRows] = useState(() => (stdout && stdout.rows) || 24);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!stdout) return undefined;
|
|
221
|
+
const onResize = () => setRows((stdout.rows) || 24);
|
|
222
|
+
stdout.on('resize', onResize);
|
|
223
|
+
return () => { stdout.off('resize', onResize); };
|
|
224
|
+
}, [stdout]);
|
|
225
|
+
|
|
145
226
|
// writeFn for run_turn: route chunks into React state instead of stdout.
|
|
146
227
|
// Only used when runTurnFactory is provided; the legacy `runTurn` prop
|
|
147
228
|
// keeps writing wherever its caller wired it.
|
|
@@ -274,51 +355,89 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
|
|
|
274
355
|
const showSlashPopup =
|
|
275
356
|
bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
|
|
276
357
|
|
|
358
|
+
// Outer column height: pinned to rows-1 in alt-buffer mode so the
|
|
359
|
+
// Editor truly sticks to the bottom. Non-alt keeps content-sized layout
|
|
360
|
+
// (legacy behavior — required for existing snapshot tests + non-TTY).
|
|
361
|
+
const outerHeight = altEnabled ? Math.max(1, rows - 1) : undefined;
|
|
362
|
+
|
|
277
363
|
return React.createElement(
|
|
278
|
-
|
|
279
|
-
{
|
|
280
|
-
// 1) Scrollback (write-once via Ink Static)
|
|
364
|
+
FullScreen,
|
|
365
|
+
{ enabled: altEnabled },
|
|
281
366
|
React.createElement(
|
|
282
|
-
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
367
|
+
Box,
|
|
368
|
+
{ flexDirection: 'column', height: outerHeight },
|
|
369
|
+
// 1) Scrollback (write-once via Ink Static).
|
|
370
|
+
// In alt-buffer mode the Static lives inside a flex-grow inner
|
|
371
|
+
// Box so it absorbs slack rather than pushing the editor out of
|
|
372
|
+
// the viewport. In legacy mode it stays sibling-flat so the
|
|
373
|
+
// structural test (Editor must be last sibling) still passes.
|
|
374
|
+
altEnabled
|
|
375
|
+
? React.createElement(
|
|
376
|
+
Box,
|
|
377
|
+
{ flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' },
|
|
378
|
+
React.createElement(
|
|
379
|
+
Static,
|
|
380
|
+
{ items: state.scrollback },
|
|
381
|
+
(item) => React.createElement(ScrollbackItem, { key: item.id, item })
|
|
382
|
+
),
|
|
383
|
+
// Live region — partial assistant stream (inside the scroll
|
|
384
|
+
// region so it grows naturally above the status bar).
|
|
385
|
+
state.liveAssistant
|
|
386
|
+
? React.createElement(
|
|
387
|
+
Box,
|
|
388
|
+
{ flexDirection: 'column' },
|
|
389
|
+
React.createElement(Text, { color: theme.fg }, state.liveAssistant)
|
|
390
|
+
)
|
|
391
|
+
: null,
|
|
392
|
+
)
|
|
393
|
+
: React.createElement(
|
|
394
|
+
Static,
|
|
395
|
+
{ items: state.scrollback },
|
|
396
|
+
(item) => React.createElement(ScrollbackItem, { key: item.id, item })
|
|
397
|
+
),
|
|
398
|
+
// Live region (legacy path only — alt path already rendered it inside the inner Box).
|
|
399
|
+
!altEnabled && state.liveAssistant
|
|
400
|
+
? React.createElement(
|
|
401
|
+
Box,
|
|
402
|
+
{ flexDirection: 'column' },
|
|
403
|
+
React.createElement(Text, { color: theme.fg }, state.liveAssistant)
|
|
404
|
+
)
|
|
405
|
+
: null,
|
|
406
|
+
// 3) Slash popup — flex sibling above the StatusBar; Ink can't
|
|
407
|
+
// absolutely position so this is the "just above input" pattern.
|
|
408
|
+
showSlashPopup
|
|
409
|
+
? React.createElement(SlashPopup, {
|
|
410
|
+
buffer: bufferPeek,
|
|
411
|
+
commands: filtered,
|
|
412
|
+
selectedIndex: selectedSuggestion,
|
|
413
|
+
})
|
|
414
|
+
: null,
|
|
415
|
+
// 4) Status bar (sticky, single row above input). flexShrink:0 so
|
|
416
|
+
// it isn't squeezed when the scrollback grows.
|
|
417
|
+
React.createElement(StatusBar, {
|
|
418
|
+
provider: _status.provider,
|
|
419
|
+
model: _status.model,
|
|
420
|
+
streaming: state.streaming,
|
|
421
|
+
ctxUsed: _status.ctxUsed,
|
|
422
|
+
ctxTotal: _status.ctxTotal,
|
|
423
|
+
}),
|
|
424
|
+
// 5) Editor — sticky bottom, content-sized. Wrapped in a flexShrink:0
|
|
425
|
+
// Box so Yoga doesn't squeeze the input row when scrollback fills.
|
|
426
|
+
React.createElement(
|
|
427
|
+
Box,
|
|
428
|
+
{ flexShrink: 0, flexDirection: 'column' },
|
|
429
|
+
React.createElement(Editor, {
|
|
430
|
+
history: state.history,
|
|
431
|
+
onSubmit: handleSubmit,
|
|
432
|
+
onEscape: onEscapeKey,
|
|
433
|
+
onBufferChange: handleBufferChange,
|
|
434
|
+
slashSuggestions: showSlashPopup ? filtered : null,
|
|
435
|
+
slashSelectedIndex: selectedSuggestion,
|
|
436
|
+
onSlashMove: handleSlashMove,
|
|
437
|
+
onSlashDismiss: handleSlashDismiss,
|
|
301
438
|
})
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
React.createElement(StatusBar, {
|
|
305
|
-
provider: splashProps && splashProps.provider,
|
|
306
|
-
model: splashProps && splashProps.model,
|
|
307
|
-
streaming: state.streaming,
|
|
308
|
-
ctxUsed: splashProps && splashProps.ctxUsed,
|
|
309
|
-
ctxTotal: splashProps && splashProps.ctxTotal,
|
|
310
|
-
}),
|
|
311
|
-
// 5) Editor — sticky bottom, content-sized
|
|
312
|
-
React.createElement(Editor, {
|
|
313
|
-
history: state.history,
|
|
314
|
-
onSubmit: handleSubmit,
|
|
315
|
-
onEscape: onEscapeKey,
|
|
316
|
-
onBufferChange: handleBufferChange,
|
|
317
|
-
slashSuggestions: showSlashPopup ? filtered : null,
|
|
318
|
-
slashSelectedIndex: selectedSuggestion,
|
|
319
|
-
onSlashMove: handleSlashMove,
|
|
320
|
-
onSlashDismiss: handleSlashDismiss,
|
|
321
|
-
})
|
|
439
|
+
)
|
|
440
|
+
)
|
|
322
441
|
);
|
|
323
442
|
}
|
|
324
443
|
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// tui/slash_dispatcher.mjs — single source of slash-command routing for the
|
|
2
|
+
// Ink chat REPL (v5.4). Lifted from cli.mjs's legacy readline handler so the
|
|
3
|
+
// Ink branch stops shipping the "not yet wired" placeholder and so future
|
|
4
|
+
// channels (Slack/Telegram inline commands, /handoff cross-channel control)
|
|
5
|
+
// can share one dispatch table.
|
|
6
|
+
//
|
|
7
|
+
// Contract:
|
|
8
|
+
// dispatchSlash(cmd, args, ctx, write) → Promise<string|'EXIT'|void>
|
|
9
|
+
// · returns 'EXIT' — caller should unmount/exit
|
|
10
|
+
// · returns a string — caller writes it to scrollback verbatim
|
|
11
|
+
// · returns void — handler already streamed via `write(...)`
|
|
12
|
+
// · throws — caller surfaces as an error scrollback item
|
|
13
|
+
//
|
|
14
|
+
// `ctx` shape (see slash_dispatcher.test.mjs for the canonical mock):
|
|
15
|
+
// {
|
|
16
|
+
// cfg, cfgDir, version, syntheticChatSessionId,
|
|
17
|
+
// getMessages / setMessages,
|
|
18
|
+
// getActiveProvName / setActiveProvName,
|
|
19
|
+
// getActiveModel / setActiveModel,
|
|
20
|
+
// getProv / setProv,
|
|
21
|
+
// getSessionId / setSessionId,
|
|
22
|
+
// getCharsSent / setCharsSent,
|
|
23
|
+
// getRunningUsage / setRunningUsage,
|
|
24
|
+
// persistTurn, accumulateUsage,
|
|
25
|
+
// resolveAuthKey,
|
|
26
|
+
// registryMod, // optional — falls back to dynamic import
|
|
27
|
+
// sessionsMod, // optional — falls back to dynamic import
|
|
28
|
+
// skillsMod, // optional — falls back to dynamic import
|
|
29
|
+
// }
|
|
30
|
+
//
|
|
31
|
+
// Interactive sub-menus (provider/model pickers, /personality picker) are
|
|
32
|
+
// readline-coupled in cli.mjs. In Ink we surface a hint instead of crashing;
|
|
33
|
+
// operators can pass an arg form (e.g. `/provider openai`) or fall back to
|
|
34
|
+
// LAZYCLAW_NO_INK=1. Re-implementing these as Ink overlays is a v5.5 item.
|
|
35
|
+
|
|
36
|
+
import { SLASH_COMMANDS } from './slash_commands.mjs';
|
|
37
|
+
|
|
38
|
+
// ─── helpers ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a raw slash line into { cmd, args }. The cmd retains its leading
|
|
42
|
+
* slash; args is everything after the first whitespace, with trailing
|
|
43
|
+
* whitespace stripped. Empty args yields ''.
|
|
44
|
+
*/
|
|
45
|
+
export function parseSlashLine(line) {
|
|
46
|
+
const raw = (line || '').replace(/\s+$/, '');
|
|
47
|
+
const m = raw.match(/^(\/\S+)(\s+(.*))?$/);
|
|
48
|
+
if (!m) return { cmd: raw, args: '' };
|
|
49
|
+
return { cmd: m[1], args: (m[3] || '').trim() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Tiny utility — split args on whitespace, drop empties. Used by sub-command
|
|
53
|
+
// handlers that don't need the loop-engine's full quote-aware splitter.
|
|
54
|
+
function splitWhitespace(s) {
|
|
55
|
+
return (s || '').split(/\s+/).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Best-effort dynamic import. Returns the resolved ctx field if the caller
|
|
59
|
+
// pre-injected it (test hot path), else loads the real module. Throwing is
|
|
60
|
+
// fine — handlers wrap calls in try/catch where appropriate.
|
|
61
|
+
async function _mod(ctx, key, importer) {
|
|
62
|
+
if (ctx && ctx[key]) return ctx[key];
|
|
63
|
+
return importer();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── handlers ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function _help() {
|
|
69
|
+
const lines = ['slash commands:'];
|
|
70
|
+
for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
|
|
71
|
+
return lines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function _status(_args, ctx) {
|
|
75
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
76
|
+
const out = {
|
|
77
|
+
provider: ctx.getActiveProvName(),
|
|
78
|
+
model: ctx.getActiveModel(),
|
|
79
|
+
keyMasked: registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']),
|
|
80
|
+
messageCount: ctx.getMessages().length,
|
|
81
|
+
sessionId: ctx.getSessionId() || null,
|
|
82
|
+
};
|
|
83
|
+
return JSON.stringify(out);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function _version(_args, ctx) {
|
|
87
|
+
const v = ctx.version || '0.0.0';
|
|
88
|
+
return `lazyclaw ${v} (node ${process.version}, ${process.platform})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function _usage(_args, ctx) {
|
|
92
|
+
const msgs = ctx.getMessages();
|
|
93
|
+
const runningUsage = ctx.getRunningUsage && ctx.getRunningUsage();
|
|
94
|
+
const out = {
|
|
95
|
+
messageCount: msgs.length,
|
|
96
|
+
charsSent: (ctx.getCharsSent && ctx.getCharsSent()) || 0,
|
|
97
|
+
};
|
|
98
|
+
if (runningUsage) out.tokens = runningUsage;
|
|
99
|
+
if (runningUsage && ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
|
|
100
|
+
try {
|
|
101
|
+
const { costFromUsage } = await import('../providers/rates.mjs');
|
|
102
|
+
const r = costFromUsage(
|
|
103
|
+
{ provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
|
|
104
|
+
ctx.cfg.rates,
|
|
105
|
+
);
|
|
106
|
+
if (r) out.cost = r;
|
|
107
|
+
} catch { /* never let cost-card lookup fail the slash */ }
|
|
108
|
+
}
|
|
109
|
+
return JSON.stringify(out);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function _newReset(_args, ctx) {
|
|
113
|
+
if (ctx.setMessages) ctx.setMessages([]);
|
|
114
|
+
if (ctx.setCharsSent) ctx.setCharsSent(0);
|
|
115
|
+
if (ctx.setRunningUsage) ctx.setRunningUsage(null);
|
|
116
|
+
const sid = ctx.getSessionId && ctx.getSessionId();
|
|
117
|
+
if (sid) {
|
|
118
|
+
try {
|
|
119
|
+
const sm = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
120
|
+
sm.resetSession(sid, ctx.cfgDir);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return `cleared in-memory; session reset failed: ${e?.message || e}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return 'cleared — new conversation';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function _provider(args, ctx) {
|
|
129
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
130
|
+
const lookup = (name) => {
|
|
131
|
+
if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
|
|
132
|
+
return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
|
|
133
|
+
};
|
|
134
|
+
if (!args) {
|
|
135
|
+
return `provider: ${ctx.getActiveProvName()}\n(interactive picker not available in Ink chat — pass an arg: /provider <name>)`;
|
|
136
|
+
}
|
|
137
|
+
const next = lookup(args);
|
|
138
|
+
if (!next) {
|
|
139
|
+
const known = registry.PROVIDERS ? Object.keys(registry.PROVIDERS).join(', ') : '?';
|
|
140
|
+
return `unknown provider: ${args} (known: ${known})`;
|
|
141
|
+
}
|
|
142
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(args);
|
|
143
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
144
|
+
return `provider → ${args}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function _model(args, ctx) {
|
|
148
|
+
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
149
|
+
if (!args) {
|
|
150
|
+
return `model: ${ctx.getActiveModel() || '(default)'}\n(interactive picker not available in Ink chat — pass an arg: /model <name>)`;
|
|
151
|
+
}
|
|
152
|
+
const { parseSlashProviderModel } = registry;
|
|
153
|
+
const parsed = typeof parseSlashProviderModel === 'function'
|
|
154
|
+
? parseSlashProviderModel(args)
|
|
155
|
+
: { provider: null, model: args };
|
|
156
|
+
if (parsed.provider) {
|
|
157
|
+
const lookup = (name) => (typeof registry.lookupProv === 'function'
|
|
158
|
+
? registry.lookupProv(name)
|
|
159
|
+
: (registry.PROVIDERS ? registry.PROVIDERS[name] : null));
|
|
160
|
+
const next = lookup(parsed.provider);
|
|
161
|
+
if (!next) return `unknown provider: ${parsed.provider}`;
|
|
162
|
+
if (ctx.setActiveProvName) ctx.setActiveProvName(parsed.provider);
|
|
163
|
+
if (ctx.setProv) ctx.setProv(next);
|
|
164
|
+
}
|
|
165
|
+
const finalModel = parsed.model || args;
|
|
166
|
+
if (ctx.setActiveModel) ctx.setActiveModel(finalModel);
|
|
167
|
+
return `model → ${finalModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function _skill(args, ctx) {
|
|
171
|
+
// `/skill name1,name2` — replace the active system message with a
|
|
172
|
+
// composition. No-arg → clear system message. Mirrors cli.mjs:3046.
|
|
173
|
+
const names = args.split(',').map((s) => s.trim()).filter(Boolean);
|
|
174
|
+
const messages = ctx.getMessages().slice(); // mutable copy
|
|
175
|
+
const sysIdx = messages.findIndex((m) => m.role === 'system');
|
|
176
|
+
const sid = ctx.getSessionId && ctx.getSessionId();
|
|
177
|
+
const sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
178
|
+
|
|
179
|
+
if (names.length === 0) {
|
|
180
|
+
if (sysIdx >= 0) messages.splice(sysIdx, 1);
|
|
181
|
+
if (ctx.setMessages) ctx.setMessages(messages);
|
|
182
|
+
if (sid) {
|
|
183
|
+
try {
|
|
184
|
+
sessionsMod.resetSession(sid, ctx.cfgDir);
|
|
185
|
+
for (const m of messages) sessionsMod.appendTurn(sid, m.role, m.content, ctx.cfgDir);
|
|
186
|
+
} catch { /* swallow — disk failure shouldn't lose in-memory state */ }
|
|
187
|
+
}
|
|
188
|
+
return 'cleared system prompt (no active skills)';
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const skillsMod = await _mod(ctx, 'skillsMod', () => import('../skills.mjs'));
|
|
192
|
+
const sys = skillsMod.composeSystemPrompt(names, ctx.cfgDir);
|
|
193
|
+
if (!sys) return 'no skill content composed (empty input?)';
|
|
194
|
+
const nextMsg = { role: 'system', content: sys };
|
|
195
|
+
if (sysIdx >= 0) messages[sysIdx] = nextMsg;
|
|
196
|
+
else messages.unshift(nextMsg);
|
|
197
|
+
if (ctx.setMessages) ctx.setMessages(messages);
|
|
198
|
+
if (sid) {
|
|
199
|
+
try {
|
|
200
|
+
sessionsMod.resetSession(sid, ctx.cfgDir);
|
|
201
|
+
for (const m of messages) sessionsMod.appendTurn(sid, m.role, m.content, ctx.cfgDir);
|
|
202
|
+
} catch { /* swallow */ }
|
|
203
|
+
}
|
|
204
|
+
return `active skills: ${names.join(', ')}`;
|
|
205
|
+
} catch (e) {
|
|
206
|
+
return `skill error: ${e?.message || e}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function _tools(_args) {
|
|
211
|
+
let registry;
|
|
212
|
+
try {
|
|
213
|
+
registry = await import('../mas/tools/registry.mjs');
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return `tools unavailable: ${e?.message || e}`;
|
|
216
|
+
}
|
|
217
|
+
const groups = registry.byCategory ? registry.byCategory() : {};
|
|
218
|
+
const cats = Object.keys(groups).sort();
|
|
219
|
+
if (cats.length === 0) return '(no tools registered)';
|
|
220
|
+
const lines = ['available tools (by category):'];
|
|
221
|
+
for (const cat of cats) {
|
|
222
|
+
const items = groups[cat] || [];
|
|
223
|
+
lines.push(` ${cat}:`);
|
|
224
|
+
for (const t of items) lines.push(` · ${t.name}${t.description ? ' — ' + t.description : ''}`);
|
|
225
|
+
}
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function _recall(args, ctx) {
|
|
230
|
+
if (!args) return 'usage: /recall <query>';
|
|
231
|
+
let mem;
|
|
232
|
+
try {
|
|
233
|
+
mem = await import('../memory.mjs');
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return `recall unavailable: ${e?.message || e}`;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const text = mem.recall(args, { topN: 5 }, ctx.cfgDir);
|
|
239
|
+
if (!text || !text.trim()) return `no matches for "${args}"`;
|
|
240
|
+
return text;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return `recall error: ${e?.message || e}`;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function _memory(args, ctx) {
|
|
247
|
+
let mem;
|
|
248
|
+
try { mem = await import('../memory.mjs'); }
|
|
249
|
+
catch (e) { return `memory unavailable: ${e?.message || e}`; }
|
|
250
|
+
const tokens = splitWhitespace(args);
|
|
251
|
+
const which = tokens[0] || 'core';
|
|
252
|
+
if (which === 'core') {
|
|
253
|
+
const body = mem.loadCore(ctx.cfgDir);
|
|
254
|
+
return body || '(empty core memory)';
|
|
255
|
+
}
|
|
256
|
+
if (which === 'recent') {
|
|
257
|
+
const items = mem.loadRecent(20, ctx.cfgDir);
|
|
258
|
+
return JSON.stringify(items, null, 2);
|
|
259
|
+
}
|
|
260
|
+
if (which === 'episodic') {
|
|
261
|
+
const topic = tokens[1];
|
|
262
|
+
if (topic) {
|
|
263
|
+
const body = mem.loadEpisodic(topic, ctx.cfgDir);
|
|
264
|
+
return body || `(no episodic file "${topic}")`;
|
|
265
|
+
}
|
|
266
|
+
return JSON.stringify(mem.listEpisodic(ctx.cfgDir), null, 2);
|
|
267
|
+
}
|
|
268
|
+
return 'usage: /memory [core|recent|episodic [topic]]';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function _dream(_args, ctx, write) {
|
|
272
|
+
let mem;
|
|
273
|
+
try { mem = await import('../memory.mjs'); }
|
|
274
|
+
catch (e) { return `dream unavailable: ${e?.message || e}`; }
|
|
275
|
+
if (typeof write === 'function') {
|
|
276
|
+
try { write(' ↯ dreaming…\n'); } catch { /* swallow */ }
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const r = await mem.dream(ctx.getSessionId && ctx.getSessionId(), {
|
|
280
|
+
provider: ctx.getProv(),
|
|
281
|
+
model: ctx.getActiveModel(),
|
|
282
|
+
apiKey: ctx.resolveAuthKey ? ctx.resolveAuthKey(ctx.getActiveProvName()) : null,
|
|
283
|
+
}, ctx.cfgDir);
|
|
284
|
+
return `✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}`;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
return `dream error: ${e?.message || e}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function _agent(args, ctx) {
|
|
291
|
+
let agentsMod, loopMod;
|
|
292
|
+
try {
|
|
293
|
+
agentsMod = await import('../agents.mjs');
|
|
294
|
+
loopMod = await import('../loop-engine.mjs');
|
|
295
|
+
} catch (e) { return `/agent unavailable: ${e?.message || e}`; }
|
|
296
|
+
let tokens;
|
|
297
|
+
try { tokens = loopMod.splitArgs(args); }
|
|
298
|
+
catch (e) { return `/agent error: ${e?.message || e}`; }
|
|
299
|
+
const sub = tokens[0];
|
|
300
|
+
const rest = tokens.slice(1);
|
|
301
|
+
const aname = rest[0];
|
|
302
|
+
try {
|
|
303
|
+
if (!sub || sub === 'list') {
|
|
304
|
+
const agents = agentsMod.listAgents(ctx.cfgDir);
|
|
305
|
+
if (agents.length === 0) return 'no agents registered. /agent add <name> [...] to create.';
|
|
306
|
+
return agents.map((a) => {
|
|
307
|
+
const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
|
|
308
|
+
return `• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]`;
|
|
309
|
+
}).join('\n');
|
|
310
|
+
}
|
|
311
|
+
if (sub === 'show') {
|
|
312
|
+
if (!aname) return 'usage: /agent show <name>';
|
|
313
|
+
const a = agentsMod.getAgent(aname, ctx.cfgDir);
|
|
314
|
+
if (!a) return `no agent "${aname}"`;
|
|
315
|
+
return JSON.stringify(a, null, 2);
|
|
316
|
+
}
|
|
317
|
+
if (sub === 'add') {
|
|
318
|
+
if (!aname) return 'usage: /agent add <name> [role text…]';
|
|
319
|
+
const roleText = rest.slice(1).join(' ').trim();
|
|
320
|
+
const a = agentsMod.registerAgent({ name: aname, role: roleText }, ctx.cfgDir);
|
|
321
|
+
return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')})`;
|
|
322
|
+
}
|
|
323
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
324
|
+
if (!aname) return 'usage: /agent remove <name>';
|
|
325
|
+
agentsMod.removeAgent(aname, ctx.cfgDir);
|
|
326
|
+
return `✓ removed agent ${aname}`;
|
|
327
|
+
}
|
|
328
|
+
return `/agent: unknown sub "${sub}" — list|show|add|remove`;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return `/agent error: ${e?.message || e}`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function _team(args, ctx) {
|
|
335
|
+
let teamsMod, loopMod;
|
|
336
|
+
try {
|
|
337
|
+
teamsMod = await import('../teams.mjs');
|
|
338
|
+
loopMod = await import('../loop-engine.mjs');
|
|
339
|
+
} catch (e) { return `/team unavailable: ${e?.message || e}`; }
|
|
340
|
+
let tokens;
|
|
341
|
+
try { tokens = loopMod.splitArgs(args); }
|
|
342
|
+
catch (e) { return `/team error: ${e?.message || e}`; }
|
|
343
|
+
const sub = tokens[0];
|
|
344
|
+
const rest = tokens.slice(1);
|
|
345
|
+
const tname = rest[0];
|
|
346
|
+
try {
|
|
347
|
+
if (!sub || sub === 'list') {
|
|
348
|
+
const teams = teamsMod.listTeams(ctx.cfgDir);
|
|
349
|
+
if (teams.length === 0) return 'no teams registered. /team add <name> --agents a,b --lead a [--channel #x]';
|
|
350
|
+
return teams.map((t) => {
|
|
351
|
+
const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
|
|
352
|
+
return `• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}`;
|
|
353
|
+
}).join('\n');
|
|
354
|
+
}
|
|
355
|
+
if (sub === 'show') {
|
|
356
|
+
if (!tname) return 'usage: /team show <name>';
|
|
357
|
+
const t = teamsMod.getTeam(tname, ctx.cfgDir);
|
|
358
|
+
if (!t) return `no team "${tname}"`;
|
|
359
|
+
return JSON.stringify(t, null, 2);
|
|
360
|
+
}
|
|
361
|
+
if (sub === 'add') {
|
|
362
|
+
if (!tname) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
|
|
363
|
+
let agentsCsv = null, lead = null, channel = '';
|
|
364
|
+
for (let i = 1; i < rest.length; i++) {
|
|
365
|
+
const t = rest[i];
|
|
366
|
+
if (t === '--agents') agentsCsv = rest[++i] || '';
|
|
367
|
+
else if (t === '--lead') lead = rest[++i] || null;
|
|
368
|
+
else if (t === '--channel') channel = rest[++i] || '';
|
|
369
|
+
else return `/team error: unknown token "${t}"`;
|
|
370
|
+
}
|
|
371
|
+
if (!agentsCsv) return '/team add: --agents is required';
|
|
372
|
+
const agentsList = teamsMod.parseListFlag(agentsCsv);
|
|
373
|
+
const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
|
|
374
|
+
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
375
|
+
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
376
|
+
logger: () => {},
|
|
377
|
+
}) : '';
|
|
378
|
+
const team = teamsMod.registerTeam({ name: tname, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
|
|
379
|
+
return `✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})`;
|
|
380
|
+
}
|
|
381
|
+
if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
382
|
+
if (!tname) return 'usage: /team remove <name>';
|
|
383
|
+
teamsMod.removeTeam(tname, ctx.cfgDir);
|
|
384
|
+
return `✓ removed team ${tname}`;
|
|
385
|
+
}
|
|
386
|
+
return `/team: unknown sub "${sub}" — list|show|add|remove`;
|
|
387
|
+
} catch (e) {
|
|
388
|
+
return `/team error: ${e?.message || e}`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function _loop(args, ctx, write) {
|
|
393
|
+
// v5.4 minimal port: parses + reports. The full streaming loop in
|
|
394
|
+
// cli.mjs:3091 needs an in-Ink writeFn + abort wiring; we ship a faithful
|
|
395
|
+
// single-shot iteration via loop-engine.mjs to avoid silent regressions.
|
|
396
|
+
// For multi-iter the operator can still set LAZYCLAW_NO_INK=1.
|
|
397
|
+
let loopMod;
|
|
398
|
+
try { loopMod = await import('../loop-engine.mjs'); }
|
|
399
|
+
catch (e) { return `loop unavailable: ${e?.message || e}`; }
|
|
400
|
+
if (!args) {
|
|
401
|
+
return [
|
|
402
|
+
'usage: /loop <prompt> [--max N] [--until "<regex>"]',
|
|
403
|
+
` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}`,
|
|
404
|
+
` session: ${ctx.getSessionId && ctx.getSessionId() || '(none — turns will not be persisted)'}`,
|
|
405
|
+
' note: Ink chat runs /loop without mid-loop Ctrl-C abort; set LAZYCLAW_NO_INK=1 for the full loop UX.',
|
|
406
|
+
].join('\n');
|
|
407
|
+
}
|
|
408
|
+
let parsed;
|
|
409
|
+
try { parsed = loopMod.parseLoopArgs(args); }
|
|
410
|
+
catch (e) { return `loop error: ${e?.message || e}`; }
|
|
411
|
+
let untilRe = null;
|
|
412
|
+
try { untilRe = loopMod.compileUntil(parsed.until); }
|
|
413
|
+
catch (e) { return `loop error: ${e?.message || e}`; }
|
|
414
|
+
|
|
415
|
+
const prov = ctx.getProv();
|
|
416
|
+
if (!prov || typeof prov.sendMessage !== 'function') {
|
|
417
|
+
return 'loop error: no active provider';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const sendOnce = async (msgs, signal) => {
|
|
421
|
+
let acc = '';
|
|
422
|
+
for await (const chunk of prov.sendMessage(msgs, {
|
|
423
|
+
apiKey: ctx.resolveAuthKey ? ctx.resolveAuthKey(ctx.getActiveProvName()) : null,
|
|
424
|
+
model: ctx.getActiveModel(),
|
|
425
|
+
signal,
|
|
426
|
+
onUsage: ctx.accumulateUsage,
|
|
427
|
+
})) {
|
|
428
|
+
if (typeof write === 'function') { try { write(chunk); } catch {} }
|
|
429
|
+
acc += chunk;
|
|
430
|
+
}
|
|
431
|
+
return acc;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const messages = ctx.getMessages();
|
|
435
|
+
const ac = new AbortController();
|
|
436
|
+
try {
|
|
437
|
+
const result = await loopMod.runLoop({
|
|
438
|
+
prompt: parsed.prompt,
|
|
439
|
+
max: parsed.max,
|
|
440
|
+
until: untilRe,
|
|
441
|
+
messages,
|
|
442
|
+
sendOnce,
|
|
443
|
+
persist: (role, content) => ctx.persistTurn && ctx.persistTurn(role, content),
|
|
444
|
+
onIteration: ({ i, max }) => {
|
|
445
|
+
if (typeof write === 'function') {
|
|
446
|
+
try { write(` ↻ loop iteration ${i}/${max}\n`); } catch {}
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
signal: ac.signal,
|
|
450
|
+
});
|
|
451
|
+
if (ctx.setCharsSent && ctx.getCharsSent) {
|
|
452
|
+
ctx.setCharsSent(ctx.getCharsSent() + parsed.prompt.length * result.iterations);
|
|
453
|
+
}
|
|
454
|
+
const tail = result.stoppedBy === 'until' ? ' (stopped by --until)'
|
|
455
|
+
: result.stoppedBy === 'abort' ? ' (aborted)' : '';
|
|
456
|
+
return `✓ loop done — ${result.iterations}/${parsed.max} iteration(s)${tail}`;
|
|
457
|
+
} catch (err) {
|
|
458
|
+
return `loop error: ${err?.message || String(err)}`;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function _goal(args, ctx) {
|
|
463
|
+
let goalsMod, loopMod, sessionsMod;
|
|
464
|
+
try {
|
|
465
|
+
goalsMod = await import('../goals.mjs');
|
|
466
|
+
loopMod = await import('../loop-engine.mjs');
|
|
467
|
+
sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
|
|
468
|
+
} catch (e) { return `/goal unavailable: ${e?.message || e}`; }
|
|
469
|
+
if (!args) {
|
|
470
|
+
const items = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
|
|
471
|
+
if (!items.length) return 'no active goals';
|
|
472
|
+
return items.map((g) =>
|
|
473
|
+
` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}`
|
|
474
|
+
).join('\n');
|
|
475
|
+
}
|
|
476
|
+
let tokens;
|
|
477
|
+
try { tokens = loopMod.splitArgs(args); }
|
|
478
|
+
catch (e) { return `goal error: ${e?.message || e}`; }
|
|
479
|
+
const sub = tokens[0];
|
|
480
|
+
const rest = tokens.slice(1);
|
|
481
|
+
|
|
482
|
+
if (sub === 'add') {
|
|
483
|
+
let name = null, desc = '', cron = null;
|
|
484
|
+
for (let i = 0; i < rest.length; i++) {
|
|
485
|
+
const t = rest[i];
|
|
486
|
+
if (t === '--desc') desc = rest[++i] || '';
|
|
487
|
+
else if (t === '--cron') cron = rest[++i] || null;
|
|
488
|
+
else if (t.startsWith('--')) return `goal error: unknown flag ${t}`;
|
|
489
|
+
else if (!name) name = t;
|
|
490
|
+
else return `goal error: unexpected arg "${t}"`;
|
|
491
|
+
}
|
|
492
|
+
if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
|
|
493
|
+
try {
|
|
494
|
+
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
|
|
495
|
+
// Cron attach is cli.mjs-internal (_attachGoalCron); Ink chat skips it
|
|
496
|
+
// and the operator can attach via the `lazyclaw goal add --cron` CLI.
|
|
497
|
+
const cronNote = cron ? ' (cron attach via Ink chat is not wired in v5.4 — use the CLI form)' : '';
|
|
498
|
+
return `✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})${cronNote}`;
|
|
499
|
+
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
500
|
+
}
|
|
501
|
+
if (sub === 'list') return JSON.stringify(goalsMod.listGoals(ctx.cfgDir), null, 2);
|
|
502
|
+
if (sub === 'show') {
|
|
503
|
+
const name = rest[0];
|
|
504
|
+
if (!name) return 'usage: /goal show <name>';
|
|
505
|
+
const g = goalsMod.getGoal(name, ctx.cfgDir);
|
|
506
|
+
if (!g) return `no goal "${name}"`;
|
|
507
|
+
return JSON.stringify(g, null, 2);
|
|
508
|
+
}
|
|
509
|
+
if (sub === 'close') {
|
|
510
|
+
const name = rest[0];
|
|
511
|
+
const outcome = rest[1] || 'done';
|
|
512
|
+
if (!name) return 'usage: /goal close <name> [done|abandoned]';
|
|
513
|
+
try {
|
|
514
|
+
const g = goalsMod.closeGoal(name, outcome, ctx.cfgDir);
|
|
515
|
+
return `✓ goal ${g.name} closed (status: ${g.status})`;
|
|
516
|
+
} catch (e) { return `goal error: ${e?.message || e}`; }
|
|
517
|
+
}
|
|
518
|
+
// single-arg branch: switch
|
|
519
|
+
const goalName = sub;
|
|
520
|
+
const g = goalsMod.getGoal(goalName, ctx.cfgDir);
|
|
521
|
+
if (!g) return `no goal "${goalName}" — try: /goal add ${goalName} --desc "..."`;
|
|
522
|
+
if (g.status !== 'active') return `goal "${goalName}" is ${g.status}; cannot switch`;
|
|
523
|
+
if (ctx.setSessionId) ctx.setSessionId(g.sessionId);
|
|
524
|
+
let prior = [];
|
|
525
|
+
try { prior = sessionsMod.loadTurns(g.sessionId, ctx.cfgDir); }
|
|
526
|
+
catch { prior = []; }
|
|
527
|
+
const nextMsgs = prior.map((t) => ({ role: t.role, content: t.content }));
|
|
528
|
+
const sysIdx = nextMsgs.findIndex((m) => m.role === 'system');
|
|
529
|
+
const goalNote = `## Goal: ${g.description || g.name}`;
|
|
530
|
+
if (sysIdx >= 0) nextMsgs[sysIdx] = { role: 'system', content: `${goalNote}\n\n${nextMsgs[sysIdx].content}` };
|
|
531
|
+
else nextMsgs.unshift({ role: 'system', content: goalNote });
|
|
532
|
+
if (ctx.setMessages) ctx.setMessages(nextMsgs);
|
|
533
|
+
return `✓ switched to goal: ${g.name} (session: ${g.sessionId}, ${prior.length} prior turn(s))`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function _handoff(args, ctx) {
|
|
537
|
+
const parts = splitWhitespace(args);
|
|
538
|
+
if (parts.length < 2) return 'usage: /handoff <target-channel> <externalId> [--note=...]';
|
|
539
|
+
const target = parts[0];
|
|
540
|
+
const externalId = parts[1];
|
|
541
|
+
const note = (parts.find((p) => p.startsWith('--note=')) || '').slice(7);
|
|
542
|
+
try {
|
|
543
|
+
const { openThreads } = await import('../channels/threads.mjs');
|
|
544
|
+
const { runHandoff } = await import('../channels/handoff.mjs');
|
|
545
|
+
const threads = openThreads(ctx.cfgDir);
|
|
546
|
+
const replState = globalThis.__lazyclawReplState || {};
|
|
547
|
+
const cur = replState.channel && replState.externalId
|
|
548
|
+
? threads.findByExternal(replState.channel, replState.externalId)
|
|
549
|
+
: null;
|
|
550
|
+
if (!cur) {
|
|
551
|
+
return `handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}`;
|
|
552
|
+
}
|
|
553
|
+
const next = await runHandoff({
|
|
554
|
+
threads, channels: replState.channels || {},
|
|
555
|
+
threadId: cur.threadId, target, externalId, note,
|
|
556
|
+
});
|
|
557
|
+
replState.channel = next.channel;
|
|
558
|
+
replState.externalId = next.externalId;
|
|
559
|
+
return `handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})`;
|
|
560
|
+
} catch (e) {
|
|
561
|
+
return `handoff failed: ${e.code || 'ERR'}: ${e.message}`;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function _personality(_args) {
|
|
566
|
+
// cmdPersonality lives in cli.mjs and is readline-coupled (reads from
|
|
567
|
+
// stdin via the global rl). Lifting it cleanly is a v5.5 follow-up; for
|
|
568
|
+
// v5.4 we surface the CLI alternative so operators aren't stranded.
|
|
569
|
+
return 'personality: interactive picker not yet wired into Ink chat — use `lazyclaw personality list|set <name>` from the shell, or restart with LAZYCLAW_NO_INK=1.';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function _task() {
|
|
573
|
+
return 'task: slash form lands in v5.5 — use the `lazyclaw task` CLI for now.';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function _trainer() {
|
|
577
|
+
return 'trainer: configure via config.json (cfg.trainer) or `lazyclaw trainer` CLI — slash form lands in v5.5.';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ─── dispatch table ──────────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
export const SLASH_HANDLERS = new Map([
|
|
583
|
+
['/help', _help],
|
|
584
|
+
['/status', _status],
|
|
585
|
+
['/version', _version],
|
|
586
|
+
['/usage', _usage],
|
|
587
|
+
['/new', _newReset],
|
|
588
|
+
['/reset', _newReset],
|
|
589
|
+
['/provider', _provider],
|
|
590
|
+
['/model', _model],
|
|
591
|
+
['/skill', _skill],
|
|
592
|
+
['/skills', _skill],
|
|
593
|
+
['/tools', _tools],
|
|
594
|
+
['/recall', _recall],
|
|
595
|
+
['/memory', _memory],
|
|
596
|
+
['/dream', _dream],
|
|
597
|
+
['/agent', _agent],
|
|
598
|
+
['/team', _team],
|
|
599
|
+
['/loop', _loop],
|
|
600
|
+
['/goal', _goal],
|
|
601
|
+
['/handoff', _handoff],
|
|
602
|
+
['/personality', _personality],
|
|
603
|
+
['/task', _task],
|
|
604
|
+
['/trainer', _trainer],
|
|
605
|
+
['/exit', async () => 'EXIT'],
|
|
606
|
+
['/quit', async () => 'EXIT'],
|
|
607
|
+
]);
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Primary entry point. Resolves the command name to a handler in
|
|
611
|
+
* SLASH_HANDLERS and invokes it. Unknown commands return a friendly
|
|
612
|
+
* "unknown" string (caller renders to scrollback) rather than throwing,
|
|
613
|
+
* so the user sees feedback instead of an error toast.
|
|
614
|
+
*/
|
|
615
|
+
export async function dispatchSlash(cmd, args, ctx, write) {
|
|
616
|
+
const handler = SLASH_HANDLERS.get(cmd);
|
|
617
|
+
if (!handler) return `unknown slash command: ${cmd} (try /help)`;
|
|
618
|
+
return handler(args || '', ctx || {}, write);
|
|
619
|
+
}
|