lazyclaw 6.3.1 → 6.5.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/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +7 -193
- package/commands/agents_registry.mjs +205 -0
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +28 -94
- package/commands/automation_loops.mjs +94 -0
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +127 -704
- package/commands/chat_hardening.mjs +23 -0
- package/commands/chat_legacy_slash.mjs +716 -0
- package/commands/config.mjs +36 -2
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/help_text.mjs +78 -0
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -80
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +41 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +27 -3
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +68 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +183 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +70 -12
- package/providers/gemini.mjs +104 -3
- package/providers/gemini_cli.mjs +78 -17
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +29 -2
- package/providers/tool_use/openai.mjs +35 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/banner.mjs +72 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +0 -0
- package/tui/editor_anchor.mjs +46 -0
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/orchestrator_setup.mjs +135 -0
- package/tui/pickers.mjs +218 -248
- package/tui/repl.mjs +118 -209
- package/tui/repl_altbuffer.mjs +63 -0
- package/tui/repl_reducers.mjs +114 -0
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_channels.mjs +208 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dashboard.mjs +220 -0
- package/tui/slash_dispatcher.mjs +339 -774
- package/tui/slash_helpers.mjs +68 -0
- package/tui/slash_popup.mjs +5 -1
- package/tui/slash_trainer.mjs +173 -0
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/commands/chat.mjs
CHANGED
|
@@ -6,45 +6,39 @@
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
8
|
configPath, readConfig, writeConfig,
|
|
9
|
+
persistActiveModel, persistActiveProvider,
|
|
9
10
|
_resolveAuthKey, _resolveBaseUrl, readVersionFromRepo,
|
|
10
11
|
} from '../lib/config.mjs';
|
|
11
12
|
import { ensureRegistry, requireRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
12
13
|
import { SUBCOMMANDS, parseArgs, AGENT_REG_SUBS } from '../lib/args.mjs';
|
|
13
14
|
import {
|
|
14
|
-
_attachGhostAutocomplete, _fetchModelsForProvider,
|
|
15
|
-
|
|
15
|
+
_attachGhostAutocomplete, _fetchModelsForProvider,
|
|
16
|
+
_pickProviderInteractive, _printChatBanner,
|
|
16
17
|
_quickPrompt, _renderBanner, _renderV5Banner,
|
|
17
18
|
} from '../tui/pickers.mjs';
|
|
18
19
|
import { firstRunMode as _firstRunMode, hasConfiguredProvider } from '../first_run.mjs';
|
|
19
|
-
import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
|
|
20
|
+
import { applyChatWindow as _applyChatWindow, estimateMessagesTokens, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOKEN_BUDGET } from '../chat_window.mjs';
|
|
20
21
|
import { makeRunTurn as _chatRunTurnFactory } from '../tui/run_turn.mjs';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
22
|
+
import { hudStatus as _hudStatus } from '../tui/hud.mjs';
|
|
23
|
+
import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine, _makeInkApprove } from '../tui/slash_dispatcher.mjs';
|
|
24
|
+
import { wrapInteractiveProv, makeLegacyApprove } from './chat_hardening.mjs';
|
|
25
|
+
import { makeLegacySlashHandler } from './chat_legacy_slash.mjs';
|
|
26
|
+
// Re-export so tests that import legacySlashRoute from ./chat.mjs keep working
|
|
27
|
+
// after the legacy slash router was extracted to ./chat_legacy_slash.mjs.
|
|
28
|
+
export { legacySlashRoute } from './chat_legacy_slash.mjs';
|
|
23
29
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
switch (cmd) {
|
|
31
|
-
// Legacy readline path has no modal picker, so BOTH /setup and /config
|
|
32
|
-
// route to the full wizard here (the Ink path gives /config its
|
|
33
|
-
// single-setting picker via tui/config_picker.mjs).
|
|
34
|
-
case '/config':
|
|
35
|
-
case '/setup':
|
|
36
|
-
ctx.requestSetup = true;
|
|
37
|
-
return 'EXIT';
|
|
38
|
-
default:
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
30
|
+
// /new, /reset and /clear must signal the Ink REPL to wipe the screen +
|
|
31
|
+
// scrollback via the 'NEW' sentinel (handled in tui/repl.mjs). _newReset itself
|
|
32
|
+
// returns a human string for the string-rendering consumers, so the Ink handler
|
|
33
|
+
// translates these commands here. Exported so the contract is unit-testable.
|
|
34
|
+
export function _isInkResetCmd(cmd) {
|
|
35
|
+
return /^\/(new|reset|clear)$/i.test(String(cmd || ''));
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
const LEGACY_DELEGATED_SLASHES = new Set(['/channels', '/orchestrator', '/context']);
|
|
38
|
+
// The legacy (non-Ink) readline slash router (legacySlashRoute,
|
|
39
|
+
// LEGACY_DELEGATED_SLASHES, and the ~650-line hand-written switch) lives in
|
|
40
|
+
// ./chat_legacy_slash.mjs now. cmdChat builds it via makeLegacySlashHandler
|
|
41
|
+
// (imported above) and legacySlashRoute is re-exported above for tests.
|
|
48
42
|
|
|
49
43
|
export async function cmdChat(flags = {}) {
|
|
50
44
|
await ensureRegistry();
|
|
@@ -89,9 +83,20 @@ export async function cmdChat(flags = {}) {
|
|
|
89
83
|
}
|
|
90
84
|
activeProvName = 'claude-cli';
|
|
91
85
|
}
|
|
92
|
-
let prov = lookupProv(activeProvName);
|
|
86
|
+
let prov = wrapInteractiveProv(lookupProv(activeProvName)); // transient-retry the chat hot path
|
|
93
87
|
if (!prov) { console.error(`unknown provider: ${activeProvName}`); process.exit(2); }
|
|
94
88
|
|
|
89
|
+
// First-turn key preflight: warn up front when the active provider needs an
|
|
90
|
+
// API key but none resolves, instead of letting the first turn fail opaquely.
|
|
91
|
+
// Cheap (no network); TTY-only so pipelines aren't spammed.
|
|
92
|
+
if (process.stdout.isTTY) {
|
|
93
|
+
const _meta = (getRegistry().PROVIDER_INFO || {})[activeProvName] || {};
|
|
94
|
+
if (_meta.requiresApiKey && !_resolveAuthKey(cfg, activeProvName)) {
|
|
95
|
+
process.stdout.write(` ⚠ no API key found for ${activeProvName} — the first message will fail until you set one.\n`);
|
|
96
|
+
process.stdout.write(` fix: /provider (pick + paste a key) · or lazyclaw auth add ${activeProvName}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
95
100
|
// Top-of-session banner so the user can see at a glance what they're
|
|
96
101
|
// talking to. Cheap (no provider call) and TTY-only.
|
|
97
102
|
// v5 ink splash + REPL when stdin is a real TTY and the user has not
|
|
@@ -107,8 +112,7 @@ export async function cmdChat(flags = {}) {
|
|
|
107
112
|
// narrow-terminal fallback: <60 cols falls back to v4
|
|
108
113
|
if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
|
|
109
114
|
|
|
110
|
-
// Tool + skill groups for the splash panel — shared with the setup
|
|
111
|
-
// wizard via tui/splash_props.mjs so both surfaces render the same panel.
|
|
115
|
+
// Tool + skill groups for the splash panel — shared with the setup wizard.
|
|
112
116
|
const { gatherToolAndSkillGroups } = await import('../tui/splash_props.mjs');
|
|
113
117
|
const { tools: toolGroups, skills: skillGroups } =
|
|
114
118
|
await gatherToolAndSkillGroups(path.dirname(configPath()));
|
|
@@ -123,13 +127,9 @@ export async function cmdChat(flags = {}) {
|
|
|
123
127
|
};
|
|
124
128
|
void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
|
|
125
129
|
|
|
126
|
-
// C7 — minimal chat-session state for the ink path so runTurn can
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// remains self-contained and the legacy path stays byte-identical).
|
|
130
|
-
// Slash commands aren't wired into the ink REPL yet (v5.1 follow-up);
|
|
131
|
-
// until then, system-prompt composition / --session resume happen
|
|
132
|
-
// identically to the legacy path.
|
|
130
|
+
// C7 — minimal chat-session state for the ink path so runTurn can talk to
|
|
131
|
+
// the provider. The legacy readline path below sets up the same shape
|
|
132
|
+
// (kept duplicated so each branch stays self-contained).
|
|
133
133
|
let _inkSandboxSpec = null;
|
|
134
134
|
if (flags.sandbox) {
|
|
135
135
|
const sb = await import('../sandbox.mjs');
|
|
@@ -203,8 +203,7 @@ export async function cmdChat(flags = {}) {
|
|
|
203
203
|
}).catch(() => {});
|
|
204
204
|
} catch { /* swallow */ }
|
|
205
205
|
};
|
|
206
|
-
// v5.4: chars-sent counter for the Ink
|
|
207
|
-
// path's `charsSent` so /usage in Ink reports the same number.
|
|
206
|
+
// v5.4: chars-sent counter for the Ink path (mirrors legacy `charsSent`).
|
|
208
207
|
let _inkCharsSent = 0;
|
|
209
208
|
const _inkCtx = {
|
|
210
209
|
cfg,
|
|
@@ -219,11 +218,15 @@ export async function cmdChat(flags = {}) {
|
|
|
219
218
|
getMessages: () => _inkMessages,
|
|
220
219
|
setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
|
|
221
220
|
getProv: () => prov,
|
|
222
|
-
setProv: (next) => { prov = next; },
|
|
221
|
+
setProv: (next) => { prov = wrapInteractiveProv(next); },
|
|
223
222
|
getActiveProvName: () => activeProvName,
|
|
224
|
-
|
|
223
|
+
// Persist provider/model picks so they survive a restart (was
|
|
224
|
+
// in-memory only — a model chosen via /model reverted to cfg.model on
|
|
225
|
+
// the next launch). persistActiveProvider leaves orchestrator routing
|
|
226
|
+
// to /orchestrator on|off.
|
|
227
|
+
setActiveProvName: (name) => { activeProvName = name; persistActiveProvider(cfg, name); },
|
|
225
228
|
getActiveModel: () => activeModel,
|
|
226
|
-
setActiveModel: (name) => { activeModel = name; },
|
|
229
|
+
setActiveModel: (name) => { activeModel = name; persistActiveModel(cfg, name); },
|
|
227
230
|
getSessionId: () => _inkSessionId,
|
|
228
231
|
setSessionId: (id) => { _inkSessionId = id; },
|
|
229
232
|
getCharsSent: () => _inkCharsSent,
|
|
@@ -235,8 +238,7 @@ export async function cmdChat(flags = {}) {
|
|
|
235
238
|
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
236
239
|
resolveBaseUrl: (providerName) => _resolveBaseUrl(providerName),
|
|
237
240
|
onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
|
|
238
|
-
// P2 —
|
|
239
|
-
// by read-merge-writing config.json from inside the Ink session.
|
|
241
|
+
// P2 — /provider add read-merge-writes config.json from the Ink session.
|
|
240
242
|
readConfig: () => readConfig(),
|
|
241
243
|
writeConfig: (next) => writeConfig(next),
|
|
242
244
|
};
|
|
@@ -253,6 +255,7 @@ export async function cmdChat(flags = {}) {
|
|
|
253
255
|
? api.openPicker(opts)
|
|
254
256
|
: Promise.resolve(null);
|
|
255
257
|
};
|
|
258
|
+
_inkCtx.approve = _makeInkApprove(_inkCtx); // agentic sensitive tools → Ink approval modal
|
|
256
259
|
// Route streamed chunks through ReplApp's injected writeFn: chunks
|
|
257
260
|
// land in React state (liveAssistant) → live region → committed to
|
|
258
261
|
// the <Static/> scrollback on turn-complete, so Ink owns all output.
|
|
@@ -271,9 +274,38 @@ export async function cmdChat(flags = {}) {
|
|
|
271
274
|
const { cmd, args } = _parseSlashLine(line);
|
|
272
275
|
// Thread the REPL's abort signal so Esc/Ctrl-C can stop a /loop.
|
|
273
276
|
_inkCtx.loopSignal = signal || null;
|
|
274
|
-
|
|
277
|
+
const result = await _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
|
|
275
278
|
try { process.stdout.write(chunk); } catch { /* swallow */ }
|
|
276
279
|
});
|
|
280
|
+
// _newReset (/new, /reset, /clear) returns a human string, but repl.mjs
|
|
281
|
+
// only wipes the screen + scrollback on the 'NEW' sentinel. Translate
|
|
282
|
+
// here (mirrors the 'EXIT' sentinel) so the real /new actually clears.
|
|
283
|
+
if (_isInkResetCmd(cmd)) return 'NEW';
|
|
284
|
+
return result;
|
|
285
|
+
};
|
|
286
|
+
// v6.x slash-argument completion (two surfaces, see tui/slash_args.mjs):
|
|
287
|
+
// onArgList → inline candidates rendered in the popup (login, hud,
|
|
288
|
+
// memory, config, channels, subcommands, names, …).
|
|
289
|
+
// onArgComplete → Tab opens the drill-in modal for 2-step provider→model
|
|
290
|
+
// specs (/model, /trainer set, /orchestrator planner).
|
|
291
|
+
// Both resolve through _inkCtx (its openPicker IS ReplApp's modal; its
|
|
292
|
+
// cfgDir/registry feed the inline lists).
|
|
293
|
+
const { argSpecFor: _argSpecFor, runArgCompleter: _runArgCompleter, listArgCandidates: _listArgCandidates } = await import('../tui/slash_args.mjs');
|
|
294
|
+
const { SLASH_COMMANDS: _ARG_CATALOG } = await import('../tui/slash_commands.mjs');
|
|
295
|
+
const _argRegistry = await import('../providers/registry.mjs');
|
|
296
|
+
const _inkArgComplete = async (buffer) => {
|
|
297
|
+
try {
|
|
298
|
+
const spec = _argSpecFor(buffer, _ARG_CATALOG);
|
|
299
|
+
if (!spec || spec.kind !== 'modal') return null;
|
|
300
|
+
return await _runArgCompleter(spec, _inkCtx, _argRegistry);
|
|
301
|
+
} catch { return null; }
|
|
302
|
+
};
|
|
303
|
+
const _inkArgList = (buffer) => {
|
|
304
|
+
try {
|
|
305
|
+
const spec = _argSpecFor(buffer, _ARG_CATALOG);
|
|
306
|
+
if (!spec || spec.kind !== 'inline') return [];
|
|
307
|
+
return _listArgCandidates(spec, _inkCtx, _argRegistry);
|
|
308
|
+
} catch { return []; }
|
|
277
309
|
};
|
|
278
310
|
// v5.4.1: splash renders INSIDE the alt-buffer (not pre-printed to
|
|
279
311
|
// primary). The v5.4.0 pre-print made the screen go blank during
|
|
@@ -282,19 +314,21 @@ export async function cmdChat(flags = {}) {
|
|
|
282
314
|
const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
|
|
283
315
|
splashProps,
|
|
284
316
|
statusInfo: { provider: activeProvName, model: activeModel },
|
|
285
|
-
// P3 — live status:
|
|
286
|
-
//
|
|
287
|
-
// turn, instead of showing the values captured at mount.
|
|
317
|
+
// P3 — live status: provider/model + history-based ctx gauge (not the
|
|
318
|
+
// provider's self-reported per-call usage) + the HUD field bundle.
|
|
288
319
|
getStatus: () => ({
|
|
289
320
|
provider: activeProvName,
|
|
290
321
|
model: activeModel,
|
|
291
|
-
ctxUsed:
|
|
322
|
+
ctxUsed: estimateMessagesTokens(_inkMessages),
|
|
292
323
|
ctxTotal: Number((cfg.chat || {}).windowTokens) || CHAT_WINDOW_TOKEN_BUDGET,
|
|
324
|
+
hud: _hudStatus(cfg, _inkRunningUsage),
|
|
293
325
|
}),
|
|
294
326
|
runTurnFactory: _inkRunTurnFactory,
|
|
295
327
|
onSlashCommand: _inkSlashHandler,
|
|
328
|
+
onArgComplete: _inkArgComplete,
|
|
329
|
+
onArgList: _inkArgList,
|
|
296
330
|
pickerRef: _inkPickerRef,
|
|
297
|
-
}), { exitOnCtrlC:
|
|
331
|
+
}), { exitOnCtrlC: false, patchConsole: true }); // false → editor 2-stage Ctrl+C
|
|
298
332
|
await ink.waitUntilExit();
|
|
299
333
|
// /setup → full wizard (then shell). /config single step → run JUST
|
|
300
334
|
// that step now that Ink released stdin, then re-enter chat.
|
|
@@ -302,6 +336,10 @@ export async function cmdChat(flags = {}) {
|
|
|
302
336
|
else if (_inkCtx.requestConfigStep) {
|
|
303
337
|
await (await import('./config_step.mjs')).runConfigStep(_inkCtx.requestConfigStep);
|
|
304
338
|
return cmdChat(flags);
|
|
339
|
+
} else if (_inkCtx.requestLogin) {
|
|
340
|
+
// Connect a keyless CLI provider in the foreground (Ink freed stdin), re-enter.
|
|
341
|
+
await (await import('../providers/cli_login.mjs')).runCliLoginInteractive(_inkCtx.requestLogin);
|
|
342
|
+
return cmdChat(flags);
|
|
305
343
|
}
|
|
306
344
|
return;
|
|
307
345
|
} catch (e) {
|
|
@@ -482,664 +520,13 @@ export async function cmdChat(flags = {}) {
|
|
|
482
520
|
getSessionId: () => sessionId,
|
|
483
521
|
persistTurn,
|
|
484
522
|
accumulateUsage,
|
|
485
|
-
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
|
|
486
|
-
onCharsSent: (n) => { charsSent += n; },
|
|
523
|
+
resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName), onCharsSent: (n) => { charsSent += n; }, approve: makeLegacyApprove(),
|
|
487
524
|
};
|
|
488
525
|
const runTurn = _chatRunTurnFactory({
|
|
489
526
|
ctx: _legacyCtx,
|
|
490
527
|
writeFn: (chunk) => process.stdout.write(chunk),
|
|
491
528
|
});
|
|
492
529
|
|
|
493
|
-
const handleSlash = async (line) => {
|
|
494
|
-
const cmd = line.split(/\s+/)[0];
|
|
495
|
-
switch (cmd) {
|
|
496
|
-
case '/help': {
|
|
497
|
-
process.stdout.write('slash commands:\n');
|
|
498
|
-
for (const c of SLASH_COMMANDS) process.stdout.write(` ${c.cmd.padEnd(8)} — ${c.help}\n`);
|
|
499
|
-
return true;
|
|
500
|
-
}
|
|
501
|
-
case '/status': {
|
|
502
|
-
const out = {
|
|
503
|
-
provider: activeProvName,
|
|
504
|
-
model: activeModel,
|
|
505
|
-
keyMasked: getRegistry().maskApiKey(cfg['api-key']),
|
|
506
|
-
messageCount: messages.length,
|
|
507
|
-
};
|
|
508
|
-
process.stdout.write(JSON.stringify(out) + '\n');
|
|
509
|
-
return true;
|
|
510
|
-
}
|
|
511
|
-
case '/provider': {
|
|
512
|
-
// `/provider <name>` switches the active provider for subsequent
|
|
513
|
-
// turns. The conversation history stays put — the next user
|
|
514
|
-
// message goes to the new provider with the existing context.
|
|
515
|
-
// `/provider` (no arg) opens the family/provider/model picker so
|
|
516
|
-
// the user can switch with arrow keys instead of memorising names.
|
|
517
|
-
const arg = line.slice('/provider'.length).trim();
|
|
518
|
-
if (!arg) {
|
|
519
|
-
if (!useTerminal) {
|
|
520
|
-
process.stdout.write(`provider: ${activeProvName}\n`);
|
|
521
|
-
return true;
|
|
522
|
-
}
|
|
523
|
-
await _pauseChatForSubMenu(rl, _ghost, async () => {
|
|
524
|
-
const picked = await _pickProviderInteractive();
|
|
525
|
-
if (picked && picked.provider) {
|
|
526
|
-
const next = lookupProv(picked.provider);
|
|
527
|
-
if (!next) {
|
|
528
|
-
process.stdout.write(`unknown provider: ${picked.provider}\n`);
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
activeProvName = picked.provider;
|
|
532
|
-
prov = next;
|
|
533
|
-
if (picked.model) activeModel = picked.model;
|
|
534
|
-
process.stdout.write(`provider → ${activeProvName}${picked.model ? ` · model → ${picked.model}` : ''}\n`);
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
return true;
|
|
538
|
-
}
|
|
539
|
-
const next = lookupProv(arg);
|
|
540
|
-
if (!next) {
|
|
541
|
-
process.stdout.write(`unknown provider: ${arg} (known: ${Object.keys(getRegistry().PROVIDERS).join(', ')})\n`);
|
|
542
|
-
return true;
|
|
543
|
-
}
|
|
544
|
-
activeProvName = arg;
|
|
545
|
-
prov = next;
|
|
546
|
-
process.stdout.write(`provider → ${arg}\n`);
|
|
547
|
-
return true;
|
|
548
|
-
}
|
|
549
|
-
case '/model': {
|
|
550
|
-
// `/model <name>` updates the active model without touching the
|
|
551
|
-
// provider. `/model` (no arg) opens the per-provider model picker
|
|
552
|
-
// — same UX as setup step 3, scoped to the active provider.
|
|
553
|
-
const arg = line.slice('/model'.length).trim();
|
|
554
|
-
if (!arg) {
|
|
555
|
-
if (!useTerminal) {
|
|
556
|
-
process.stdout.write(`model: ${activeModel || '(default)'}\n`);
|
|
557
|
-
return true;
|
|
558
|
-
}
|
|
559
|
-
await _pauseChatForSubMenu(rl, _ghost, async () => {
|
|
560
|
-
const chosen = await _pickModelInteractive(activeProvName, { titlePrefix: 'LazyClaw chat —' });
|
|
561
|
-
if (chosen === 'CANCEL' || chosen === 'BACK' || !chosen) return;
|
|
562
|
-
activeModel = chosen;
|
|
563
|
-
process.stdout.write(`model → ${activeModel}\n`);
|
|
564
|
-
});
|
|
565
|
-
return true;
|
|
566
|
-
}
|
|
567
|
-
// Honor unified provider/model: `/model anthropic/claude-opus-4-7`
|
|
568
|
-
// splits and switches both.
|
|
569
|
-
const { parseSlashProviderModel } = getRegistry();
|
|
570
|
-
const parsed = parseSlashProviderModel(arg);
|
|
571
|
-
if (parsed.provider) {
|
|
572
|
-
const next = lookupProv(parsed.provider);
|
|
573
|
-
if (!next) {
|
|
574
|
-
process.stdout.write(`unknown provider: ${parsed.provider}\n`);
|
|
575
|
-
return true;
|
|
576
|
-
}
|
|
577
|
-
activeProvName = parsed.provider;
|
|
578
|
-
prov = next;
|
|
579
|
-
}
|
|
580
|
-
activeModel = parsed.model || arg;
|
|
581
|
-
process.stdout.write(`model → ${activeModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}\n`);
|
|
582
|
-
return true;
|
|
583
|
-
}
|
|
584
|
-
case '/new':
|
|
585
|
-
case '/reset':
|
|
586
|
-
case '/clear': {
|
|
587
|
-
// /clear is dispatcher-only (no explicit legacy case before this),
|
|
588
|
-
// so without it /clear fell through to `default:` → _dispatchSlash →
|
|
589
|
-
// _newReset, which clears via ctx.set* setters that _legacyCtx does
|
|
590
|
-
// NOT expose — returning 'cleared' while the closure's messages/
|
|
591
|
-
// charsSent/runningUsage stayed intact (a lying no-op). Alias /clear
|
|
592
|
-
// to the /new+/reset direct-mutation body, matching the dispatcher's
|
|
593
|
-
// /clear → /new/reset session-reset aliasing.
|
|
594
|
-
messages = [];
|
|
595
|
-
charsSent = 0;
|
|
596
|
-
runningUsage = null;
|
|
597
|
-
if (sessionId) {
|
|
598
|
-
const sm = await import('../sessions.mjs');
|
|
599
|
-
sm.resetSession(sessionId, cfgDir);
|
|
600
|
-
}
|
|
601
|
-
process.stdout.write('cleared — new conversation\n');
|
|
602
|
-
return true;
|
|
603
|
-
}
|
|
604
|
-
case '/usage': {
|
|
605
|
-
const out = { messageCount: messages.length, charsSent };
|
|
606
|
-
if (runningUsage) out.tokens = runningUsage;
|
|
607
|
-
// When cfg.rates has a card for the active provider/model AND
|
|
608
|
-
// we accumulated real usage, surface the running cost too. The
|
|
609
|
-
// computation is local (pure arithmetic), no extra network.
|
|
610
|
-
if (runningUsage && cfg.rates && typeof cfg.rates === 'object') {
|
|
611
|
-
try {
|
|
612
|
-
const { costFromUsage } = await import('../providers/rates.mjs');
|
|
613
|
-
const r = costFromUsage(
|
|
614
|
-
{ provider: activeProvName, model: activeModel, usage: runningUsage },
|
|
615
|
-
cfg.rates,
|
|
616
|
-
);
|
|
617
|
-
if (r) out.cost = r;
|
|
618
|
-
} catch { /* never let cost-card lookup fail the slash */ }
|
|
619
|
-
}
|
|
620
|
-
process.stdout.write(JSON.stringify(out) + '\n');
|
|
621
|
-
return true;
|
|
622
|
-
}
|
|
623
|
-
case '/skill': {
|
|
624
|
-
// `/skill name1,name2` — replace the active system message with a
|
|
625
|
-
// composition of the named skills. `/skill` (no arg) clears the
|
|
626
|
-
// system message. The replacement happens in-place on the
|
|
627
|
-
// messages array; the prior system turn (if any) is dropped so
|
|
628
|
-
// we don't end up with two stacked system messages talking past
|
|
629
|
-
// each other. When --session is set we persist the new system
|
|
630
|
-
// message so the next invocation resumes with the same context.
|
|
631
|
-
const arg = line.slice('/skill'.length).trim();
|
|
632
|
-
const names = arg.split(',').map(s => s.trim()).filter(Boolean);
|
|
633
|
-
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
634
|
-
if (names.length === 0) {
|
|
635
|
-
if (sysIdx >= 0) messages.splice(sysIdx, 1);
|
|
636
|
-
if (sessionId) {
|
|
637
|
-
// Persistent session: rewrite the file from scratch so the
|
|
638
|
-
// dropped system turn doesn't linger as a stale entry.
|
|
639
|
-
const sm = await import('../sessions.mjs');
|
|
640
|
-
sm.resetSession(sessionId, cfgDir);
|
|
641
|
-
for (const m of messages) sm.appendTurn(sessionId, m.role, m.content, cfgDir);
|
|
642
|
-
}
|
|
643
|
-
process.stdout.write('cleared system prompt (no active skills)\n');
|
|
644
|
-
return true;
|
|
645
|
-
}
|
|
646
|
-
try {
|
|
647
|
-
const sys = await (async () => {
|
|
648
|
-
const mod = await import('../skills.mjs');
|
|
649
|
-
return mod.composeSystemPrompt(names, cfgDir);
|
|
650
|
-
})();
|
|
651
|
-
if (!sys) {
|
|
652
|
-
process.stdout.write('no skill content composed (empty input?)\n');
|
|
653
|
-
return true;
|
|
654
|
-
}
|
|
655
|
-
if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: sys };
|
|
656
|
-
else messages.unshift({ role: 'system', content: sys });
|
|
657
|
-
if (sessionId) {
|
|
658
|
-
const sm = await import('../sessions.mjs');
|
|
659
|
-
sm.resetSession(sessionId, cfgDir);
|
|
660
|
-
for (const m of messages) sm.appendTurn(sessionId, m.role, m.content, cfgDir);
|
|
661
|
-
}
|
|
662
|
-
process.stdout.write(`active skills: ${names.join(', ')}\n`);
|
|
663
|
-
} catch (e) {
|
|
664
|
-
process.stdout.write(`skill error: ${e?.message || e}\n`);
|
|
665
|
-
}
|
|
666
|
-
return true;
|
|
667
|
-
}
|
|
668
|
-
case '/loop': {
|
|
669
|
-
// `/loop <prompt> [--max N] [--until "<regex>"]` — repeats one
|
|
670
|
-
// user prompt against the active provider in the current session.
|
|
671
|
-
// Default --max 3, hard cap 50. --until short-circuits when its
|
|
672
|
-
// regex matches the latest assistant turn. Ctrl+C aborts the
|
|
673
|
-
// current stream AND the whole loop (not just the in-flight
|
|
674
|
-
// turn). Implementation lives in loop-engine.mjs; here we wire
|
|
675
|
-
// it to the same provider streaming + buffered-writer used by a
|
|
676
|
-
// normal user turn.
|
|
677
|
-
const arg = line.slice('/loop'.length).trim();
|
|
678
|
-
const loopMod = await import('../loop-engine.mjs');
|
|
679
|
-
if (!arg) {
|
|
680
|
-
process.stdout.write(`usage: /loop <prompt> [--max N] [--until "<regex>"]\n`);
|
|
681
|
-
process.stdout.write(` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}\n`);
|
|
682
|
-
process.stdout.write(` session: ${sessionId || '(none — turns will not be persisted)'}\n`);
|
|
683
|
-
return true;
|
|
684
|
-
}
|
|
685
|
-
let parsed;
|
|
686
|
-
try { parsed = loopMod.parseLoopArgs(arg); }
|
|
687
|
-
catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
|
|
688
|
-
let untilRe = null;
|
|
689
|
-
try { untilRe = loopMod.compileUntil(parsed.until); }
|
|
690
|
-
catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
|
|
691
|
-
|
|
692
|
-
// Per-loop AbortController. Ctrl+C aborts the current provider
|
|
693
|
-
// call (via signal) AND prevents the next iteration (the engine
|
|
694
|
-
// sees signal.aborted on its loop check). Same handler shape as
|
|
695
|
-
// the normal-turn path; symmetry keeps `/exit` clean afterwards.
|
|
696
|
-
const loopAc = new AbortController();
|
|
697
|
-
const onSigint = () => {
|
|
698
|
-
loopAc.abort();
|
|
699
|
-
process.stdout.write('\n^C interrupted — loop aborted\n');
|
|
700
|
-
};
|
|
701
|
-
process.on('SIGINT', onSigint);
|
|
702
|
-
|
|
703
|
-
const sendOnce = async (msgs, signal) => {
|
|
704
|
-
let acc = '';
|
|
705
|
-
let _writeBuf = '';
|
|
706
|
-
let _writeTimer = null;
|
|
707
|
-
const _flush = () => {
|
|
708
|
-
if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
|
|
709
|
-
_writeTimer = null;
|
|
710
|
-
};
|
|
711
|
-
const _writeChunk = (s) => {
|
|
712
|
-
_writeBuf += s;
|
|
713
|
-
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
714
|
-
};
|
|
715
|
-
try {
|
|
716
|
-
for await (const chunk of prov.sendMessage(msgs, {
|
|
717
|
-
apiKey: _resolveAuthKey(cfg, activeProvName),
|
|
718
|
-
model: activeModel,
|
|
719
|
-
sandbox: sandboxSpec,
|
|
720
|
-
signal,
|
|
721
|
-
onUsage: accumulateUsage,
|
|
722
|
-
})) {
|
|
723
|
-
_writeChunk(chunk);
|
|
724
|
-
acc += chunk;
|
|
725
|
-
}
|
|
726
|
-
if (_writeTimer) clearTimeout(_writeTimer);
|
|
727
|
-
_flush();
|
|
728
|
-
process.stdout.write('\n');
|
|
729
|
-
return acc;
|
|
730
|
-
} catch (err) {
|
|
731
|
-
if (_writeTimer) clearTimeout(_writeTimer);
|
|
732
|
-
_flush();
|
|
733
|
-
throw err;
|
|
734
|
-
}
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
if (useTerminal) _ghost.suspend();
|
|
738
|
-
// Capture the chat's existing system message (workspace / skill
|
|
739
|
-
// composition) before we let the engine touch it; we restore it
|
|
740
|
-
// after the loop so the chat continues with the same system.
|
|
741
|
-
const _sysBefore = messages.find(m => m.role === 'system')?.content ?? null;
|
|
742
|
-
const memMod = (parsed.useMemory || parsed.recall) ? await import('../memory.mjs') : null;
|
|
743
|
-
const buildSystem = memMod ? (() => {
|
|
744
|
-
// Called per iteration: memory.loadCore + recall re-read from
|
|
745
|
-
// disk every call so a parallel writer mutating core.md /
|
|
746
|
-
// episodic/* between iterations is reflected immediately.
|
|
747
|
-
const parts = [];
|
|
748
|
-
if (parsed.useMemory) {
|
|
749
|
-
const core = memMod.loadCore(cfgDir);
|
|
750
|
-
if (core && core.trim()) parts.push(core);
|
|
751
|
-
}
|
|
752
|
-
if (parsed.recall) {
|
|
753
|
-
const text = memMod.recall(parsed.recall, { topN: 3 }, cfgDir);
|
|
754
|
-
if (text && text.trim()) parts.push(text);
|
|
755
|
-
}
|
|
756
|
-
if (_sysBefore) parts.push(_sysBefore);
|
|
757
|
-
return parts.join('\n\n---\n\n');
|
|
758
|
-
}) : null;
|
|
759
|
-
try {
|
|
760
|
-
const result = await loopMod.runLoop({
|
|
761
|
-
prompt: parsed.prompt,
|
|
762
|
-
max: parsed.max,
|
|
763
|
-
until: untilRe,
|
|
764
|
-
messages,
|
|
765
|
-
sendOnce,
|
|
766
|
-
persist: (role, content) => persistTurn(role, content),
|
|
767
|
-
onIteration: ({ i, max }) => {
|
|
768
|
-
process.stderr.write(`\x1b[2m ↻ loop iteration ${i}/${max}\x1b[22m\n`);
|
|
769
|
-
},
|
|
770
|
-
signal: loopAc.signal,
|
|
771
|
-
buildSystem,
|
|
772
|
-
});
|
|
773
|
-
charsSent += parsed.prompt.length * result.iterations;
|
|
774
|
-
if (result.stoppedBy === 'until') {
|
|
775
|
-
process.stderr.write(`\x1b[2m ✓ loop stopped by --until\x1b[22m\n`);
|
|
776
|
-
} else if (result.stoppedBy === 'abort') {
|
|
777
|
-
process.stderr.write(`\x1b[2m ⊘ loop aborted after ${result.iterations}/${parsed.max} iteration(s)\x1b[22m\n`);
|
|
778
|
-
}
|
|
779
|
-
} catch (err) {
|
|
780
|
-
process.stdout.write(`loop error: ${err?.message || String(err)}\n`);
|
|
781
|
-
} finally {
|
|
782
|
-
process.off('SIGINT', onSigint);
|
|
783
|
-
if (useTerminal) _ghost.resume();
|
|
784
|
-
// Restore the chat's prior system message. The engine may have
|
|
785
|
-
// overwritten messages[0] with the per-iter memory composition;
|
|
786
|
-
// we put the original (workspace / skill) back so the
|
|
787
|
-
// subsequent free-form chat turn sees the same system the user
|
|
788
|
-
// configured before /loop ran.
|
|
789
|
-
if (buildSystem) {
|
|
790
|
-
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
791
|
-
if (_sysBefore) {
|
|
792
|
-
if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
|
|
793
|
-
else messages.unshift({ role: 'system', content: _sysBefore });
|
|
794
|
-
} else if (sysIdx >= 0) {
|
|
795
|
-
messages.splice(sysIdx, 1);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
return true;
|
|
800
|
-
}
|
|
801
|
-
case '/goal': {
|
|
802
|
-
// /goal → list active goals
|
|
803
|
-
// /goal <name> → switch chat context to goal:<name>
|
|
804
|
-
// /goal add <name> [--desc "..."] [--cron "<spec>"]
|
|
805
|
-
// /goal list → JSON of all goals
|
|
806
|
-
// /goal show <name> → JSON of one
|
|
807
|
-
// /goal close <name> [done|abandoned]
|
|
808
|
-
const rawArg = line.slice('/goal'.length).trim();
|
|
809
|
-
const goalsMod = await import('../goals.mjs');
|
|
810
|
-
const loopMod = await import('../loop-engine.mjs');
|
|
811
|
-
if (!rawArg) {
|
|
812
|
-
const items = goalsMod.listGoals(cfgDir).filter(g => g.status === 'active');
|
|
813
|
-
if (!items.length) { process.stdout.write('no active goals\n'); }
|
|
814
|
-
else {
|
|
815
|
-
for (const g of items) {
|
|
816
|
-
process.stdout.write(` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}\n`);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
return true;
|
|
820
|
-
}
|
|
821
|
-
let tokens;
|
|
822
|
-
try { tokens = loopMod.splitArgs(rawArg); }
|
|
823
|
-
catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); return true; }
|
|
824
|
-
const sub = tokens[0];
|
|
825
|
-
const rest = tokens.slice(1);
|
|
826
|
-
if (sub === 'add') {
|
|
827
|
-
let name = null, desc = '', cron = null;
|
|
828
|
-
for (let i = 0; i < rest.length; i++) {
|
|
829
|
-
const t = rest[i];
|
|
830
|
-
if (t === '--desc') desc = rest[++i] || '';
|
|
831
|
-
else if (t === '--cron') cron = rest[++i] || null;
|
|
832
|
-
else if (t.startsWith('--')) { process.stdout.write(`goal error: unknown flag ${t}\n`); return true; }
|
|
833
|
-
else if (!name) name = t;
|
|
834
|
-
else { process.stdout.write(`goal error: unexpected arg "${t}"\n`); return true; }
|
|
835
|
-
}
|
|
836
|
-
if (!name) { process.stdout.write('usage: /goal add <name> [--desc "..."] [--cron "<spec>"]\n'); return true; }
|
|
837
|
-
try {
|
|
838
|
-
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, cfgDir);
|
|
839
|
-
if (cron) {
|
|
840
|
-
try { await (await import('../commands/automation.mjs'))._attachGoalCron(name, cron); }
|
|
841
|
-
catch (e) { process.stdout.write(`goal warning: cron attach failed (${e?.message || e})\n`); }
|
|
842
|
-
}
|
|
843
|
-
process.stdout.write(`✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})\n`);
|
|
844
|
-
} catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
|
|
845
|
-
return true;
|
|
846
|
-
}
|
|
847
|
-
if (sub === 'list') {
|
|
848
|
-
process.stdout.write(JSON.stringify(goalsMod.listGoals(cfgDir), null, 2) + '\n');
|
|
849
|
-
return true;
|
|
850
|
-
}
|
|
851
|
-
if (sub === 'show') {
|
|
852
|
-
const name = rest[0];
|
|
853
|
-
if (!name) { process.stdout.write('usage: /goal show <name>\n'); return true; }
|
|
854
|
-
const g = goalsMod.getGoal(name, cfgDir);
|
|
855
|
-
if (!g) { process.stdout.write(`no goal "${name}"\n`); return true; }
|
|
856
|
-
process.stdout.write(JSON.stringify(g, null, 2) + '\n');
|
|
857
|
-
return true;
|
|
858
|
-
}
|
|
859
|
-
if (sub === 'close') {
|
|
860
|
-
const name = rest[0];
|
|
861
|
-
const outcome = rest[1] || 'done';
|
|
862
|
-
if (!name) { process.stdout.write('usage: /goal close <name> [done|abandoned]\n'); return true; }
|
|
863
|
-
try {
|
|
864
|
-
const g = goalsMod.closeGoal(name, outcome, cfgDir);
|
|
865
|
-
try { await (await import('../commands/automation.mjs'))._detachGoalCron(name); }
|
|
866
|
-
catch (e) { process.stdout.write(`goal warning: cron detach failed (${e?.message || e})\n`); }
|
|
867
|
-
process.stdout.write(`✓ goal ${g.name} closed (status: ${g.status})\n`);
|
|
868
|
-
} catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
|
|
869
|
-
return true;
|
|
870
|
-
}
|
|
871
|
-
// Single-arg branch: switch context to goal:<name>.
|
|
872
|
-
const goalName = sub;
|
|
873
|
-
const g = goalsMod.getGoal(goalName, cfgDir);
|
|
874
|
-
if (!g) {
|
|
875
|
-
process.stdout.write(`no goal "${goalName}" — try: /goal add ${goalName} --desc "..."\n`);
|
|
876
|
-
return true;
|
|
877
|
-
}
|
|
878
|
-
if (g.status !== 'active') {
|
|
879
|
-
process.stdout.write(`goal "${goalName}" is ${g.status}; cannot switch\n`);
|
|
880
|
-
return true;
|
|
881
|
-
}
|
|
882
|
-
// Switch: replace the chat's active session id and reload turns
|
|
883
|
-
// from the goal's session. The provider, model, workspace, and
|
|
884
|
-
// skill state stay put — only the conversation surface changes.
|
|
885
|
-
sessionId = g.sessionId;
|
|
886
|
-
activeGoalName = g.name;
|
|
887
|
-
const prior = sessionsMod.loadTurns(sessionId, cfgDir);
|
|
888
|
-
messages = prior.map(t => ({ role: t.role, content: t.content }));
|
|
889
|
-
// Prepend a one-line goal note to the system message so the
|
|
890
|
-
// model sees the current objective without us having to mutate
|
|
891
|
-
// any persistent record on every switch.
|
|
892
|
-
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
893
|
-
const goalNote = `## Goal: ${g.description || g.name}`;
|
|
894
|
-
if (sysIdx >= 0) {
|
|
895
|
-
messages[sysIdx] = { role: 'system', content: `${goalNote}\n\n${messages[sysIdx].content}` };
|
|
896
|
-
} else {
|
|
897
|
-
messages.unshift({ role: 'system', content: goalNote });
|
|
898
|
-
}
|
|
899
|
-
process.stdout.write(`✓ switched to goal: ${g.name} (session: ${sessionId}, ${prior.length} prior turn(s))\n`);
|
|
900
|
-
return true;
|
|
901
|
-
}
|
|
902
|
-
case '/memory': {
|
|
903
|
-
const arg = line.slice('/memory'.length).trim();
|
|
904
|
-
const memMod = await import('../memory.mjs');
|
|
905
|
-
const tokens = arg.split(/\s+/).filter(Boolean);
|
|
906
|
-
const which = tokens[0] || 'core';
|
|
907
|
-
if (which === 'core') {
|
|
908
|
-
const body = memMod.loadCore(cfgDir);
|
|
909
|
-
process.stdout.write(body || '(empty core memory)\n');
|
|
910
|
-
return true;
|
|
911
|
-
}
|
|
912
|
-
if (which === 'recent') {
|
|
913
|
-
const items = memMod.loadRecent(20, cfgDir);
|
|
914
|
-
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
|
|
915
|
-
return true;
|
|
916
|
-
}
|
|
917
|
-
if (which === 'episodic') {
|
|
918
|
-
const topic = tokens[1];
|
|
919
|
-
if (topic) {
|
|
920
|
-
const body = memMod.loadEpisodic(topic, cfgDir);
|
|
921
|
-
process.stdout.write(body || `(no episodic file "${topic}")\n`);
|
|
922
|
-
} else {
|
|
923
|
-
process.stdout.write(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2) + '\n');
|
|
924
|
-
}
|
|
925
|
-
return true;
|
|
926
|
-
}
|
|
927
|
-
process.stdout.write('usage: /memory [core|recent|episodic [topic]]\n');
|
|
928
|
-
return true;
|
|
929
|
-
}
|
|
930
|
-
case '/dream': {
|
|
931
|
-
const memMod = await import('../memory.mjs');
|
|
932
|
-
process.stdout.write(' ↯ dreaming…\n');
|
|
933
|
-
try {
|
|
934
|
-
const r = await memMod.dream(sessionId, {
|
|
935
|
-
provider: prov,
|
|
936
|
-
model: activeModel,
|
|
937
|
-
apiKey: _resolveAuthKey(cfg, activeProvName),
|
|
938
|
-
}, cfgDir);
|
|
939
|
-
process.stdout.write(`✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}\n`);
|
|
940
|
-
} catch (e) { process.stdout.write(`dream error: ${e?.message || e}\n`); }
|
|
941
|
-
return true;
|
|
942
|
-
}
|
|
943
|
-
case '/agent': {
|
|
944
|
-
const rawArg = line.slice('/agent'.length).trim();
|
|
945
|
-
const agentsMod = await import('../agents.mjs');
|
|
946
|
-
const loopMod = await import('../loop-engine.mjs');
|
|
947
|
-
let tokens;
|
|
948
|
-
try { tokens = loopMod.splitArgs(rawArg); }
|
|
949
|
-
catch (e) { process.stdout.write(`/agent error: ${e?.message || e}\n`); return true; }
|
|
950
|
-
const sub = tokens[0];
|
|
951
|
-
const rest = tokens.slice(1);
|
|
952
|
-
const aname = rest[0];
|
|
953
|
-
try {
|
|
954
|
-
if (!sub || sub === 'list') {
|
|
955
|
-
const agents = agentsMod.listAgents(cfgDir);
|
|
956
|
-
if (agents.length === 0) process.stdout.write('no agents registered. /agent add <name> [...] to create.\n');
|
|
957
|
-
else for (const a of agents) {
|
|
958
|
-
const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
|
|
959
|
-
process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
|
|
960
|
-
}
|
|
961
|
-
} else if (sub === 'show') {
|
|
962
|
-
if (!aname) { process.stdout.write('usage: /agent show <name>\n'); return true; }
|
|
963
|
-
const a = agentsMod.getAgent(aname, cfgDir);
|
|
964
|
-
if (!a) process.stdout.write(`no agent "${aname}"\n`);
|
|
965
|
-
else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
|
|
966
|
-
} else if (sub === 'add') {
|
|
967
|
-
if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
|
|
968
|
-
const roleText = rest.slice(1).join(' ').trim();
|
|
969
|
-
const a = agentsMod.registerAgent({ name: aname, role: roleText }, cfgDir);
|
|
970
|
-
process.stdout.write(`✓ added agent ${a.name} (tools=${a.tools.join(',')})\n`);
|
|
971
|
-
} else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
972
|
-
if (!aname) { process.stdout.write('usage: /agent remove <name>\n'); return true; }
|
|
973
|
-
agentsMod.removeAgent(aname, cfgDir);
|
|
974
|
-
process.stdout.write(`✓ removed agent ${aname}\n`);
|
|
975
|
-
} else {
|
|
976
|
-
process.stdout.write(`/agent: unknown sub "${sub}" — list|show|add|remove\n`);
|
|
977
|
-
}
|
|
978
|
-
} catch (e) {
|
|
979
|
-
process.stdout.write(`/agent error: ${e?.message || e}\n`);
|
|
980
|
-
}
|
|
981
|
-
return true;
|
|
982
|
-
}
|
|
983
|
-
case '/team': {
|
|
984
|
-
const rawArg = line.slice('/team'.length).trim();
|
|
985
|
-
const teamsMod = await import('../teams.mjs');
|
|
986
|
-
const loopMod = await import('../loop-engine.mjs');
|
|
987
|
-
let tokens;
|
|
988
|
-
try { tokens = loopMod.splitArgs(rawArg); }
|
|
989
|
-
catch (e) { process.stdout.write(`/team error: ${e?.message || e}\n`); return true; }
|
|
990
|
-
const sub = tokens[0];
|
|
991
|
-
const rest = tokens.slice(1);
|
|
992
|
-
const tname = rest[0];
|
|
993
|
-
try {
|
|
994
|
-
if (!sub || sub === 'list') {
|
|
995
|
-
const teams = teamsMod.listTeams(cfgDir);
|
|
996
|
-
if (teams.length === 0) process.stdout.write('no teams registered. /team add <name> --agents a,b --lead a [--channel #x]\n');
|
|
997
|
-
else for (const t of teams) {
|
|
998
|
-
const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
|
|
999
|
-
process.stdout.write(`• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}\n`);
|
|
1000
|
-
}
|
|
1001
|
-
} else if (sub === 'show') {
|
|
1002
|
-
if (!tname) { process.stdout.write('usage: /team show <name>\n'); return true; }
|
|
1003
|
-
const t = teamsMod.getTeam(tname, cfgDir);
|
|
1004
|
-
if (!t) process.stdout.write(`no team "${tname}"\n`);
|
|
1005
|
-
else process.stdout.write(JSON.stringify(t, null, 2) + '\n');
|
|
1006
|
-
} else if (sub === 'add') {
|
|
1007
|
-
// /team add <name> --agents a,b,c [--lead a] [--channel #x]
|
|
1008
|
-
if (!tname) { process.stdout.write('usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]\n'); return true; }
|
|
1009
|
-
let agentsCsv = null, lead = null, channel = '';
|
|
1010
|
-
for (let i = 1; i < rest.length; i++) {
|
|
1011
|
-
const t = rest[i];
|
|
1012
|
-
if (t === '--agents') agentsCsv = rest[++i] || '';
|
|
1013
|
-
else if (t === '--lead') lead = rest[++i] || null;
|
|
1014
|
-
else if (t === '--channel') channel = rest[++i] || '';
|
|
1015
|
-
else { process.stdout.write(`/team error: unknown token "${t}"\n`); return true; }
|
|
1016
|
-
}
|
|
1017
|
-
if (!agentsCsv) { process.stdout.write('/team add: --agents is required\n'); return true; }
|
|
1018
|
-
const agents = teamsMod.parseListFlag(agentsCsv);
|
|
1019
|
-
const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
|
|
1020
|
-
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
1021
|
-
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
1022
|
-
logger: () => {},
|
|
1023
|
-
}) : '';
|
|
1024
|
-
const team = teamsMod.registerTeam({ name: tname, agents, lead, slackChannel: ch }, cfgDir);
|
|
1025
|
-
process.stdout.write(`✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})\n`);
|
|
1026
|
-
} else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
1027
|
-
if (!tname) { process.stdout.write('usage: /team remove <name>\n'); return true; }
|
|
1028
|
-
teamsMod.removeTeam(tname, cfgDir);
|
|
1029
|
-
process.stdout.write(`✓ removed team ${tname}\n`);
|
|
1030
|
-
} else {
|
|
1031
|
-
process.stdout.write(`/team: unknown sub "${sub}" — list|show|add|remove\n`);
|
|
1032
|
-
}
|
|
1033
|
-
} catch (e) {
|
|
1034
|
-
process.stdout.write(`/team error: ${e?.message || e}\n`);
|
|
1035
|
-
}
|
|
1036
|
-
return true;
|
|
1037
|
-
}
|
|
1038
|
-
case '/handoff': {
|
|
1039
|
-
// /handoff <target-channel> <externalId> [--note=...] — migrates the
|
|
1040
|
-
// active thread (bound to replState.channel / replState.externalId)
|
|
1041
|
-
// to a new channel and posts transition stubs on both sides. In the
|
|
1042
|
-
// local-only chat REPL there is no bound channel, so we surface a
|
|
1043
|
-
// clear error and stay in the REPL (acceptance test §F).
|
|
1044
|
-
const parts = line.trim().split(/\s+/).slice(1);
|
|
1045
|
-
if (parts.length < 2) {
|
|
1046
|
-
process.stderr.write('usage: /handoff <target-channel> <externalId> [--note=...]\n');
|
|
1047
|
-
return true;
|
|
1048
|
-
}
|
|
1049
|
-
const target = parts[0];
|
|
1050
|
-
const externalId = parts[1];
|
|
1051
|
-
const note = (parts.find(p => p.startsWith('--note=')) || '').slice(7);
|
|
1052
|
-
try {
|
|
1053
|
-
const { openThreads } = await import('../channels/threads.mjs');
|
|
1054
|
-
const { runHandoff } = await import('../channels/handoff.mjs');
|
|
1055
|
-
const threads = openThreads(cfgDir);
|
|
1056
|
-
const replState = globalThis.__lazyclawReplState || {};
|
|
1057
|
-
const cur = replState.channel && replState.externalId
|
|
1058
|
-
? threads.findByExternal(replState.channel, replState.externalId)
|
|
1059
|
-
: null;
|
|
1060
|
-
if (!cur) {
|
|
1061
|
-
process.stderr.write(
|
|
1062
|
-
`handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}\n`,
|
|
1063
|
-
);
|
|
1064
|
-
return true;
|
|
1065
|
-
}
|
|
1066
|
-
const next = await runHandoff({
|
|
1067
|
-
threads, channels: replState.channels || {},
|
|
1068
|
-
threadId: cur.threadId, target, externalId, note,
|
|
1069
|
-
});
|
|
1070
|
-
process.stdout.write(`handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})\n`);
|
|
1071
|
-
replState.channel = next.channel;
|
|
1072
|
-
replState.externalId = next.externalId;
|
|
1073
|
-
} catch (e) {
|
|
1074
|
-
process.stderr.write(`handoff failed: ${e.code || 'ERR'}: ${e.message}\n`);
|
|
1075
|
-
}
|
|
1076
|
-
return true;
|
|
1077
|
-
}
|
|
1078
|
-
case '/personality': {
|
|
1079
|
-
// Phase G: thin slash wrapper over cmdPersonality.
|
|
1080
|
-
const tail = line.slice('/personality'.length).trim();
|
|
1081
|
-
const parts = tail.split(/\s+/).filter(Boolean);
|
|
1082
|
-
await (await import('../commands/config.mjs')).cmdPersonality(parts[0] || 'list', parts[1], parts[2]);
|
|
1083
|
-
return true;
|
|
1084
|
-
}
|
|
1085
|
-
case '/exit': {
|
|
1086
|
-
// v5 Group A (C4): fire one updateUserModel call before exit so
|
|
1087
|
-
// the Honcho-style USER.md captures the durable facts surfaced
|
|
1088
|
-
// in this session. Wrapped in a 3-second timeout so a slow
|
|
1089
|
-
// trainer never makes /exit hang. Best-effort: failure logs are
|
|
1090
|
-
// suppressed so we don't disturb the clean shutdown.
|
|
1091
|
-
try {
|
|
1092
|
-
const turns = sessionId
|
|
1093
|
-
? sessionsMod.loadTurns(sessionId, cfgDir)
|
|
1094
|
-
: messages.map((t) => ({ role: t.role, content: t.content }));
|
|
1095
|
-
if (turns && turns.length) {
|
|
1096
|
-
const trainer = (typeof getRegistry()?.resolveTrainer === 'function')
|
|
1097
|
-
? getRegistry().resolveTrainer(cfg)
|
|
1098
|
-
: { provider: activeProvName, model: activeModel };
|
|
1099
|
-
const userModelPromise = import('../mas/user_modeler.mjs').then((m) =>
|
|
1100
|
-
m.updateUserModel({
|
|
1101
|
-
sessionTurns: turns,
|
|
1102
|
-
provider: trainer.provider,
|
|
1103
|
-
model: trainer.model,
|
|
1104
|
-
apiKey: _resolveAuthKey(cfg, trainer.provider),
|
|
1105
|
-
baseUrl: _resolveBaseUrl(trainer.provider),
|
|
1106
|
-
configDir: cfgDir,
|
|
1107
|
-
}),
|
|
1108
|
-
).catch(() => null);
|
|
1109
|
-
await Promise.race([
|
|
1110
|
-
userModelPromise,
|
|
1111
|
-
new Promise((resolve) => setTimeout(resolve, 3000)),
|
|
1112
|
-
]);
|
|
1113
|
-
}
|
|
1114
|
-
} catch { /* /exit must never hang or throw */ }
|
|
1115
|
-
return 'EXIT';
|
|
1116
|
-
}
|
|
1117
|
-
case '/config':
|
|
1118
|
-
case '/setup': {
|
|
1119
|
-
// Shared legacySlashRoute wiring (tests/f-config-slash-splash.test.mjs):
|
|
1120
|
-
// sets requestSetup + 'EXIT'; the post-loop guard runs the wizard.
|
|
1121
|
-
return legacySlashRoute(cmd, _legacyCtx);
|
|
1122
|
-
}
|
|
1123
|
-
default: {
|
|
1124
|
-
// Delegate ONLY ctx-safe dispatcher commands to the shared handler.
|
|
1125
|
-
// _legacyCtx is intentionally thin (no setters / openPicker / version),
|
|
1126
|
-
// so a blanket delegation would silently degrade picker- or setter-
|
|
1127
|
-
// driven handlers (/menu, /clear, /version, …). /channels is ctx-safe
|
|
1128
|
-
// (it has a lib/config fallback); everything else stays an honest
|
|
1129
|
-
// "unknown slash". 'EXIT' breaks the loop; a returned string prints.
|
|
1130
|
-
if (LEGACY_DELEGATED_SLASHES.has(cmd)) {
|
|
1131
|
-
const { args } = _parseSlashLine(line);
|
|
1132
|
-
const r = await _dispatchSlash(cmd, args, _legacyCtx, (s) => process.stdout.write(s));
|
|
1133
|
-
if (r === 'EXIT') return 'EXIT';
|
|
1134
|
-
if (typeof r === 'string' && r.length) process.stdout.write(r + '\n');
|
|
1135
|
-
return true;
|
|
1136
|
-
}
|
|
1137
|
-
process.stdout.write(`unknown slash: ${cmd} (try /help)\n`);
|
|
1138
|
-
return true;
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
};
|
|
1142
|
-
|
|
1143
530
|
// Create the readline interface here — immediately before iterating, with
|
|
1144
531
|
// no `await` between — so a non-TTY pipe's buffered lines reach the async
|
|
1145
532
|
// iterator (see the note at the rl/_ghost declaration above).
|
|
@@ -1157,11 +544,46 @@ export async function cmdChat(flags = {}) {
|
|
|
1157
544
|
_ghost = _attachGhostAutocomplete(rl) || _ghost;
|
|
1158
545
|
rl.prompt();
|
|
1159
546
|
}
|
|
547
|
+
// Build the legacy readline slash router now that rl/_ghost exist (the
|
|
548
|
+
// handler reads them at call time). It lives in ./chat_legacy_slash.mjs to
|
|
549
|
+
// keep this file under its size ceiling; getX/setX accessors keep cmdChat's
|
|
550
|
+
// mutable chat state (provider, model, messages, sessionId, …) live so a
|
|
551
|
+
// mid-session /provider or /goal switch takes effect on the very next turn.
|
|
552
|
+
const _legacyHandleSlash = makeLegacySlashHandler({
|
|
553
|
+
cfg,
|
|
554
|
+
cfgDir,
|
|
555
|
+
lookupProv,
|
|
556
|
+
persistTurn,
|
|
557
|
+
accumulateUsage,
|
|
558
|
+
legacyCtx: _legacyCtx,
|
|
559
|
+
sessionsMod,
|
|
560
|
+
useTerminal,
|
|
561
|
+
sandboxSpec,
|
|
562
|
+
rl,
|
|
563
|
+
ghost: _ghost,
|
|
564
|
+
getActiveProvName: () => activeProvName,
|
|
565
|
+
setActiveProvName: (v) => { activeProvName = v; },
|
|
566
|
+
getActiveModel: () => activeModel,
|
|
567
|
+
setActiveModel: (v) => { activeModel = v; },
|
|
568
|
+
getProv: () => prov,
|
|
569
|
+
setProv: (v) => { prov = v; },
|
|
570
|
+
getMessages: () => messages,
|
|
571
|
+
setMessages: (v) => { messages = v; },
|
|
572
|
+
getCharsSent: () => charsSent,
|
|
573
|
+
setCharsSent: (v) => { charsSent = v; },
|
|
574
|
+
getRunningUsage: () => runningUsage,
|
|
575
|
+
setRunningUsage: (v) => { runningUsage = v; },
|
|
576
|
+
getSessionId: () => sessionId,
|
|
577
|
+
setSessionId: (v) => { sessionId = v; },
|
|
578
|
+
getActiveGoalName: () => activeGoalName,
|
|
579
|
+
setActiveGoalName: (v) => { activeGoalName = v; },
|
|
580
|
+
});
|
|
581
|
+
|
|
1160
582
|
try { for await (const line of rl) {
|
|
1161
583
|
const text = line.trim();
|
|
1162
584
|
if (!text) { if (useTerminal) rl.prompt(); continue; }
|
|
1163
585
|
if (text.startsWith('/')) {
|
|
1164
|
-
const r = await
|
|
586
|
+
const r = await _legacyHandleSlash(text);
|
|
1165
587
|
if (r === 'EXIT') break;
|
|
1166
588
|
if (useTerminal) rl.prompt();
|
|
1167
589
|
continue;
|
|
@@ -1210,7 +632,8 @@ export async function cmdChat(flags = {}) {
|
|
|
1210
632
|
try { process.stdin.pause(); } catch (_) {}
|
|
1211
633
|
try { process.stdin.unref(); } catch (_) {}
|
|
1212
634
|
}
|
|
1213
|
-
// /config
|
|
635
|
+
// /config legacy path: re-run the wizard after the readline loop closes.
|
|
636
|
+
// (Inline login is Ink-only — legacy ctx has no openPicker — so no requestLogin.)
|
|
1214
637
|
if (_legacyCtx.requestSetup) await (await import('./setup.mjs')).cmdSetup(undefined, [], {});
|
|
1215
638
|
}
|
|
1216
639
|
|