lazyclaw 3.99.27 → 4.2.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.md +128 -2
- package/agents.mjs +179 -0
- package/channels/base.mjs +120 -0
- package/channels/http.mjs +54 -0
- package/channels/slack.mjs +386 -0
- package/channels/stub.mjs +52 -0
- package/cli.mjs +1636 -77
- package/daemon.mjs +171 -0
- package/docs/multi-agent.md +256 -0
- package/goals.mjs +128 -0
- package/loop-engine.mjs +182 -0
- package/loops.mjs +135 -0
- package/mas/agent_memory.mjs +188 -0
- package/mas/agent_turn.mjs +141 -0
- package/mas/audit.mjs +62 -0
- package/mas/mention_router.mjs +360 -0
- package/mas/tool_runner.mjs +87 -0
- package/mas/tools/bash.mjs +78 -0
- package/mas/tools/grep.mjs +91 -0
- package/mas/tools/read.mjs +45 -0
- package/mas/tools/write.mjs +42 -0
- package/memory.mjs +193 -0
- package/package.json +26 -6
- package/providers/registry.mjs +8 -1
- package/providers/tool_use/anthropic.mjs +151 -0
- package/providers/tool_use/gemini.mjs +189 -0
- package/providers/tool_use/openai.mjs +140 -0
- package/scripts/loop-worker.mjs +160 -0
- package/sessions.mjs +5 -0
- package/tasks.mjs +220 -0
- package/teams.mjs +199 -0
- package/web/dashboard.html +166 -0
package/cli.mjs
CHANGED
|
@@ -857,6 +857,10 @@ const SLASH_COMMANDS = [
|
|
|
857
857
|
{ cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
|
|
858
858
|
{ cmd: '/provider', help: 'switch provider: /provider openai (no arg → print current)' },
|
|
859
859
|
{ cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
|
|
860
|
+
{ cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
|
|
861
|
+
{ cmd: '/goal', help: 'register/switch goal: /goal add NAME [--desc "..."] / /goal NAME / /goal list' },
|
|
862
|
+
{ cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
|
|
863
|
+
{ cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
|
|
860
864
|
{ cmd: '/exit', help: 'leave the chat' },
|
|
861
865
|
];
|
|
862
866
|
|
|
@@ -916,6 +920,12 @@ const SUBCOMMANDS = [
|
|
|
916
920
|
'setup', 'dashboard',
|
|
917
921
|
// v3.99.22 — multi-agent orchestrator config
|
|
918
922
|
'orchestrator',
|
|
923
|
+
// v3.99.30 — /loop and /goal slash commands (in-session + detached)
|
|
924
|
+
'loop', 'loops', 'goal', 'memory',
|
|
925
|
+
// v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
|
|
926
|
+
'slack',
|
|
927
|
+
// v4.1.0 — multi-agent slack system (Phase 9+)
|
|
928
|
+
'agent', 'team', 'task',
|
|
919
929
|
];
|
|
920
930
|
|
|
921
931
|
const SUBCOMMAND_SUBS = {
|
|
@@ -932,6 +942,13 @@ const SUBCOMMAND_SUBS = {
|
|
|
932
942
|
workspace: ['list', 'init', 'show', 'remove', 'path'],
|
|
933
943
|
cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
|
|
934
944
|
orchestrator: ['status', 'set-planner', 'workers', 'set-max-subtasks', 'clear'],
|
|
945
|
+
loops: ['list', 'show', 'kill', 'tail'],
|
|
946
|
+
goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
|
|
947
|
+
memory: ['show', 'dream', 'edit'],
|
|
948
|
+
slack: ['listen'],
|
|
949
|
+
agent: ['add', 'list', 'show', 'edit', 'remove'],
|
|
950
|
+
team: ['add', 'list', 'show', 'edit', 'remove'],
|
|
951
|
+
task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
|
|
935
952
|
};
|
|
936
953
|
|
|
937
954
|
function bashCompletion() {
|
|
@@ -1469,104 +1486,107 @@ function _attachGhostAutocomplete(rl) {
|
|
|
1469
1486
|
// border off the box (which is exactly the bug v3.99.5 shipped:
|
|
1470
1487
|
// two of the inner lines were 33 cols vs the others' 32, so the
|
|
1471
1488
|
// ╮ rendered into the next line).
|
|
1472
|
-
// v3.99.
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1489
|
+
// v3.99.29 — 8-bit crab mascot (Claude Design crab sheet). Strict
|
|
1490
|
+
// 17-wide canvas, 12 rows, so every row aligns in any monospace font.
|
|
1491
|
+
// Layout: short eye-stalks (●) at cols 4 & 12, a red carapace dome
|
|
1492
|
+
// (╭──╮ … ╰──╯) worn like a hood, an orange face box at cols 4-12
|
|
1493
|
+
// with two dot-eyes + a mouth, two yellow legs (┃) below. State →
|
|
1494
|
+
// expression: idle 기본 · working 화남(연기) focused+smoke · done
|
|
1495
|
+
// 미소 smile+sparkle · error 경고/놀람 shocked+alert. Colour is
|
|
1496
|
+
// two-tone (red shell / orange face / yellow legs) — NOT white — so
|
|
1497
|
+
// it reads like the pixel sprite even in a plain terminal.
|
|
1498
|
+
const _MASCOT_W = 17;
|
|
1479
1499
|
const _MASCOT_BIG = {
|
|
1480
1500
|
idle: [
|
|
1481
|
-
'
|
|
1501
|
+
' ╷ ╷ ',
|
|
1502
|
+
' ● ● ',
|
|
1503
|
+
' ╭───────────╮ ',
|
|
1482
1504
|
' │ │ ',
|
|
1483
1505
|
' │ │ ',
|
|
1484
|
-
'
|
|
1485
|
-
'
|
|
1486
|
-
'
|
|
1487
|
-
'
|
|
1488
|
-
'
|
|
1489
|
-
'
|
|
1490
|
-
'
|
|
1491
|
-
' ┃ ┃ ',
|
|
1492
|
-
' ┃ ┃ ',
|
|
1506
|
+
' ╰───────────╯ ',
|
|
1507
|
+
' ╭───────╮ ',
|
|
1508
|
+
' │ ● ● │ ',
|
|
1509
|
+
' │ ─── │ ',
|
|
1510
|
+
' ╰───────╯ ',
|
|
1511
|
+
' ┃ ┃ ',
|
|
1512
|
+
' ┗┛ ┗┛ ',
|
|
1493
1513
|
],
|
|
1494
1514
|
working: [
|
|
1495
|
-
'
|
|
1496
|
-
'
|
|
1515
|
+
' ° ╷ ╷ ° ',
|
|
1516
|
+
' ● ● ',
|
|
1517
|
+
' ╭───────────╮ ',
|
|
1518
|
+
' ╱╲│ │╱╲ ',
|
|
1497
1519
|
' │ │ ',
|
|
1498
|
-
'
|
|
1499
|
-
'
|
|
1500
|
-
'
|
|
1501
|
-
'
|
|
1502
|
-
'
|
|
1503
|
-
'
|
|
1504
|
-
'
|
|
1505
|
-
' ┃ ┃ ',
|
|
1506
|
-
' ┃ ┃ ',
|
|
1520
|
+
' ╰───────────╯ ',
|
|
1521
|
+
' ╭───────╮ ',
|
|
1522
|
+
' │ ─ ─ │ ',
|
|
1523
|
+
' │ ═══ │ ',
|
|
1524
|
+
' ╰───────╯ ',
|
|
1525
|
+
' ┃ ┃ ',
|
|
1526
|
+
' ┗┛ ┗┛ ',
|
|
1507
1527
|
],
|
|
1508
1528
|
done: [
|
|
1509
|
-
'✦
|
|
1529
|
+
' ✦ ╷ ╷ ✦ ',
|
|
1530
|
+
' ● ● ',
|
|
1531
|
+
' ╭───────────╮ ',
|
|
1510
1532
|
' │ │ ',
|
|
1511
1533
|
' │ │ ',
|
|
1512
|
-
'
|
|
1513
|
-
'
|
|
1514
|
-
'
|
|
1515
|
-
'
|
|
1516
|
-
'
|
|
1517
|
-
'
|
|
1518
|
-
'
|
|
1519
|
-
' ┃ ┃ ',
|
|
1520
|
-
' ┃ ┃ ',
|
|
1534
|
+
' ╰───────────╯ ',
|
|
1535
|
+
' ╭───────╮ ',
|
|
1536
|
+
' │ ^ ^ │ ',
|
|
1537
|
+
' │ ‿‿‿ │ ',
|
|
1538
|
+
' ╰───────╯ ',
|
|
1539
|
+
' ┃ ┃ ',
|
|
1540
|
+
' ┗┛ ┗┛ ',
|
|
1521
1541
|
],
|
|
1522
1542
|
error: [
|
|
1523
|
-
'
|
|
1543
|
+
' \\ ╷ ╷ / ',
|
|
1544
|
+
' ! ● ● ! ',
|
|
1545
|
+
' ╭───────────╮ ',
|
|
1524
1546
|
' │ │ ',
|
|
1525
1547
|
' │ │ ',
|
|
1526
|
-
'
|
|
1527
|
-
'
|
|
1528
|
-
'
|
|
1529
|
-
'
|
|
1530
|
-
'
|
|
1531
|
-
'
|
|
1532
|
-
'
|
|
1533
|
-
' ┃ ┃ ',
|
|
1534
|
-
' ┃ ┃ ',
|
|
1548
|
+
' ╰───────────╯ ',
|
|
1549
|
+
' ╭───────╮ ',
|
|
1550
|
+
' │ O O │ ',
|
|
1551
|
+
' │ ╭─╮ │ ',
|
|
1552
|
+
' ╰───────╯ ',
|
|
1553
|
+
' ┃ ┃ ',
|
|
1554
|
+
' ┗┛ ┗┛ ',
|
|
1535
1555
|
],
|
|
1536
1556
|
};
|
|
1537
1557
|
const _MASCOT_TINY = {
|
|
1538
|
-
idle: '
|
|
1539
|
-
working: '
|
|
1540
|
-
done: '
|
|
1541
|
-
error: '
|
|
1558
|
+
idle: ' ● ● \n(│ ─ │)\n ┃ ┃ ',
|
|
1559
|
+
working: ' ● ● \n(│ ═ │)°\n ┃ ┃ ',
|
|
1560
|
+
done: ' ^ ^ \n(│ ‿ │)✦\n ┃ ┃ ',
|
|
1561
|
+
error: ' O O \n(│╭╮│)!\n ┃ ┃ ',
|
|
1542
1562
|
};
|
|
1543
1563
|
|
|
1544
|
-
//
|
|
1545
|
-
//
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
return (s) => helmet(s);
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1564
|
+
// Crab palette — true colour. Shell red, face orange, legs yellow.
|
|
1565
|
+
// Kept as wrappers so the banner caller can compose rows freely.
|
|
1566
|
+
const _MC_RED = (s) => `\x1b[38;2;219;59;43m${s}\x1b[0m`;
|
|
1567
|
+
const _MC_ORG = (s) => `\x1b[38;2;239;131;48m${s}\x1b[0m`;
|
|
1568
|
+
const _MC_YEL = (s) => `\x1b[38;2;242;169;59m${s}\x1b[0m`;
|
|
1569
|
+
|
|
1570
|
+
// Two-tone renderer: rows 6-9 are the orange face box (interior cols
|
|
1571
|
+
// 4-12 inked orange, the rest of the row red); rows 10-11 are the
|
|
1572
|
+
// yellow legs; everything else is the red carapace. Box-drawing and
|
|
1573
|
+
// the accent glyphs are all single BMP code points, so slice() index
|
|
1574
|
+
// == visual column and the alignment survives the colour wrap.
|
|
1558
1575
|
function _renderMascot(state) {
|
|
1559
1576
|
const rows = _MASCOT_BIG[state] || _MASCOT_BIG.idle;
|
|
1560
|
-
|
|
1561
|
-
|
|
1577
|
+
return rows.map((r, i) => {
|
|
1578
|
+
if (i >= 6 && i <= 9) return _MC_RED(r.slice(0, 4)) + _MC_ORG(r.slice(4, 13)) + _MC_RED(r.slice(13));
|
|
1579
|
+
if (i >= 10) return _MC_YEL(r);
|
|
1580
|
+
return _MC_RED(r);
|
|
1581
|
+
});
|
|
1562
1582
|
}
|
|
1563
1583
|
|
|
1564
1584
|
// Tiny inline mascot — picked up by chat/agent helpers when they want
|
|
1565
1585
|
// to flash a one-line status without re-rendering the whole banner.
|
|
1566
|
-
// Returns a string; callers add their own newline.
|
|
1586
|
+
// Returns a string; callers add their own newline. Inked crab-red so
|
|
1587
|
+
// it stays on-brand wherever it's spliced in.
|
|
1567
1588
|
function _renderMascotTiny(state) {
|
|
1568
|
-
|
|
1569
|
-
return ink((_MASCOT_TINY[state] || _MASCOT_TINY.idle));
|
|
1589
|
+
return _MC_RED(_MASCOT_TINY[state] || _MASCOT_TINY.idle);
|
|
1570
1590
|
}
|
|
1571
1591
|
|
|
1572
1592
|
function _renderBanner(version) {
|
|
@@ -2366,7 +2386,12 @@ async function cmdChat(flags = {}) {
|
|
|
2366
2386
|
// Persistent session ID. When --session is set we hydrate prior turns from
|
|
2367
2387
|
// <configDir>/sessions/<id>.jsonl and append every new turn back to it.
|
|
2368
2388
|
// Without --session, chat is in-memory only (matches phase 4 behavior).
|
|
2369
|
-
|
|
2389
|
+
// Mutable so /goal <name> can switch the working context mid-session.
|
|
2390
|
+
let sessionId = flags.session || null;
|
|
2391
|
+
// Currently-active goal name when the user has switched context via
|
|
2392
|
+
// /goal <name>. Tracked so /status can surface it and so future ticks
|
|
2393
|
+
// know which goal to attribute new turns to.
|
|
2394
|
+
let activeGoalName = null;
|
|
2370
2395
|
const cfgDir = path.dirname(configPath());
|
|
2371
2396
|
let messages = sessionId
|
|
2372
2397
|
? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
|
|
@@ -2597,6 +2622,376 @@ async function cmdChat(flags = {}) {
|
|
|
2597
2622
|
}
|
|
2598
2623
|
return true;
|
|
2599
2624
|
}
|
|
2625
|
+
case '/loop': {
|
|
2626
|
+
// `/loop <prompt> [--max N] [--until "<regex>"]` — repeats one
|
|
2627
|
+
// user prompt against the active provider in the current session.
|
|
2628
|
+
// Default --max 3, hard cap 50. --until short-circuits when its
|
|
2629
|
+
// regex matches the latest assistant turn. Ctrl+C aborts the
|
|
2630
|
+
// current stream AND the whole loop (not just the in-flight
|
|
2631
|
+
// turn). Implementation lives in loop-engine.mjs; here we wire
|
|
2632
|
+
// it to the same provider streaming + buffered-writer used by a
|
|
2633
|
+
// normal user turn.
|
|
2634
|
+
const arg = line.slice('/loop'.length).trim();
|
|
2635
|
+
const loopMod = await import('./loop-engine.mjs');
|
|
2636
|
+
if (!arg) {
|
|
2637
|
+
process.stdout.write(`usage: /loop <prompt> [--max N] [--until "<regex>"]\n`);
|
|
2638
|
+
process.stdout.write(` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}\n`);
|
|
2639
|
+
process.stdout.write(` session: ${sessionId || '(none — turns will not be persisted)'}\n`);
|
|
2640
|
+
return true;
|
|
2641
|
+
}
|
|
2642
|
+
let parsed;
|
|
2643
|
+
try { parsed = loopMod.parseLoopArgs(arg); }
|
|
2644
|
+
catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
|
|
2645
|
+
let untilRe = null;
|
|
2646
|
+
try { untilRe = loopMod.compileUntil(parsed.until); }
|
|
2647
|
+
catch (e) { process.stdout.write(`loop error: ${e?.message || e}\n`); return true; }
|
|
2648
|
+
|
|
2649
|
+
// Per-loop AbortController. Ctrl+C aborts the current provider
|
|
2650
|
+
// call (via signal) AND prevents the next iteration (the engine
|
|
2651
|
+
// sees signal.aborted on its loop check). Same handler shape as
|
|
2652
|
+
// the normal-turn path; symmetry keeps `/exit` clean afterwards.
|
|
2653
|
+
const loopAc = new AbortController();
|
|
2654
|
+
const onSigint = () => {
|
|
2655
|
+
loopAc.abort();
|
|
2656
|
+
process.stdout.write('\n^C interrupted — loop aborted\n');
|
|
2657
|
+
};
|
|
2658
|
+
process.on('SIGINT', onSigint);
|
|
2659
|
+
|
|
2660
|
+
const sendOnce = async (msgs, signal) => {
|
|
2661
|
+
let acc = '';
|
|
2662
|
+
let _writeBuf = '';
|
|
2663
|
+
let _writeTimer = null;
|
|
2664
|
+
const _flush = () => {
|
|
2665
|
+
if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
|
|
2666
|
+
_writeTimer = null;
|
|
2667
|
+
};
|
|
2668
|
+
const _writeChunk = (s) => {
|
|
2669
|
+
_writeBuf += s;
|
|
2670
|
+
if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
|
|
2671
|
+
};
|
|
2672
|
+
try {
|
|
2673
|
+
for await (const chunk of prov.sendMessage(msgs, {
|
|
2674
|
+
apiKey: _resolveAuthKey(cfg, activeProvName),
|
|
2675
|
+
model: activeModel,
|
|
2676
|
+
sandbox: sandboxSpec,
|
|
2677
|
+
signal,
|
|
2678
|
+
onUsage: accumulateUsage,
|
|
2679
|
+
})) {
|
|
2680
|
+
_writeChunk(chunk);
|
|
2681
|
+
acc += chunk;
|
|
2682
|
+
}
|
|
2683
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
2684
|
+
_flush();
|
|
2685
|
+
process.stdout.write('\n');
|
|
2686
|
+
return acc;
|
|
2687
|
+
} catch (err) {
|
|
2688
|
+
if (_writeTimer) clearTimeout(_writeTimer);
|
|
2689
|
+
_flush();
|
|
2690
|
+
throw err;
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2694
|
+
if (useTerminal) _ghost.suspend();
|
|
2695
|
+
// Capture the chat's existing system message (workspace / skill
|
|
2696
|
+
// composition) before we let the engine touch it; we restore it
|
|
2697
|
+
// after the loop so the chat continues with the same system.
|
|
2698
|
+
const _sysBefore = messages.find(m => m.role === 'system')?.content ?? null;
|
|
2699
|
+
const memMod = (parsed.useMemory || parsed.recall) ? await import('./memory.mjs') : null;
|
|
2700
|
+
const buildSystem = memMod ? (() => {
|
|
2701
|
+
// Called per iteration: memory.loadCore + recall re-read from
|
|
2702
|
+
// disk every call so a parallel writer mutating core.md /
|
|
2703
|
+
// episodic/* between iterations is reflected immediately.
|
|
2704
|
+
const parts = [];
|
|
2705
|
+
if (parsed.useMemory) {
|
|
2706
|
+
const core = memMod.loadCore(cfgDir);
|
|
2707
|
+
if (core && core.trim()) parts.push(core);
|
|
2708
|
+
}
|
|
2709
|
+
if (parsed.recall) {
|
|
2710
|
+
const text = memMod.recall(parsed.recall, { topN: 3 }, cfgDir);
|
|
2711
|
+
if (text && text.trim()) parts.push(text);
|
|
2712
|
+
}
|
|
2713
|
+
if (_sysBefore) parts.push(_sysBefore);
|
|
2714
|
+
return parts.join('\n\n---\n\n');
|
|
2715
|
+
}) : null;
|
|
2716
|
+
try {
|
|
2717
|
+
const result = await loopMod.runLoop({
|
|
2718
|
+
prompt: parsed.prompt,
|
|
2719
|
+
max: parsed.max,
|
|
2720
|
+
until: untilRe,
|
|
2721
|
+
messages,
|
|
2722
|
+
sendOnce,
|
|
2723
|
+
persist: (role, content) => persistTurn(role, content),
|
|
2724
|
+
onIteration: ({ i, max }) => {
|
|
2725
|
+
process.stderr.write(`\x1b[2m ↻ loop iteration ${i}/${max}\x1b[22m\n`);
|
|
2726
|
+
},
|
|
2727
|
+
signal: loopAc.signal,
|
|
2728
|
+
buildSystem,
|
|
2729
|
+
});
|
|
2730
|
+
charsSent += parsed.prompt.length * result.iterations;
|
|
2731
|
+
if (result.stoppedBy === 'until') {
|
|
2732
|
+
process.stderr.write(`\x1b[2m ✓ loop stopped by --until\x1b[22m\n`);
|
|
2733
|
+
} else if (result.stoppedBy === 'abort') {
|
|
2734
|
+
process.stderr.write(`\x1b[2m ⊘ loop aborted after ${result.iterations}/${parsed.max} iteration(s)\x1b[22m\n`);
|
|
2735
|
+
}
|
|
2736
|
+
} catch (err) {
|
|
2737
|
+
process.stdout.write(`loop error: ${err?.message || String(err)}\n`);
|
|
2738
|
+
} finally {
|
|
2739
|
+
process.off('SIGINT', onSigint);
|
|
2740
|
+
if (useTerminal) _ghost.resume();
|
|
2741
|
+
// Restore the chat's prior system message. The engine may have
|
|
2742
|
+
// overwritten messages[0] with the per-iter memory composition;
|
|
2743
|
+
// we put the original (workspace / skill) back so the
|
|
2744
|
+
// subsequent free-form chat turn sees the same system the user
|
|
2745
|
+
// configured before /loop ran.
|
|
2746
|
+
if (buildSystem) {
|
|
2747
|
+
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
2748
|
+
if (_sysBefore) {
|
|
2749
|
+
if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: _sysBefore };
|
|
2750
|
+
else messages.unshift({ role: 'system', content: _sysBefore });
|
|
2751
|
+
} else if (sysIdx >= 0) {
|
|
2752
|
+
messages.splice(sysIdx, 1);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
return true;
|
|
2757
|
+
}
|
|
2758
|
+
case '/goal': {
|
|
2759
|
+
// /goal → list active goals
|
|
2760
|
+
// /goal <name> → switch chat context to goal:<name>
|
|
2761
|
+
// /goal add <name> [--desc "..."] [--cron "<spec>"]
|
|
2762
|
+
// /goal list → JSON of all goals
|
|
2763
|
+
// /goal show <name> → JSON of one
|
|
2764
|
+
// /goal close <name> [done|abandoned]
|
|
2765
|
+
const rawArg = line.slice('/goal'.length).trim();
|
|
2766
|
+
const goalsMod = await import('./goals.mjs');
|
|
2767
|
+
const loopMod = await import('./loop-engine.mjs');
|
|
2768
|
+
if (!rawArg) {
|
|
2769
|
+
const items = goalsMod.listGoals(cfgDir).filter(g => g.status === 'active');
|
|
2770
|
+
if (!items.length) { process.stdout.write('no active goals\n'); }
|
|
2771
|
+
else {
|
|
2772
|
+
for (const g of items) {
|
|
2773
|
+
process.stdout.write(` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}\n`);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
return true;
|
|
2777
|
+
}
|
|
2778
|
+
let tokens;
|
|
2779
|
+
try { tokens = loopMod.splitArgs(rawArg); }
|
|
2780
|
+
catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); return true; }
|
|
2781
|
+
const sub = tokens[0];
|
|
2782
|
+
const rest = tokens.slice(1);
|
|
2783
|
+
if (sub === 'add') {
|
|
2784
|
+
let name = null, desc = '', cron = null;
|
|
2785
|
+
for (let i = 0; i < rest.length; i++) {
|
|
2786
|
+
const t = rest[i];
|
|
2787
|
+
if (t === '--desc') desc = rest[++i] || '';
|
|
2788
|
+
else if (t === '--cron') cron = rest[++i] || null;
|
|
2789
|
+
else if (t.startsWith('--')) { process.stdout.write(`goal error: unknown flag ${t}\n`); return true; }
|
|
2790
|
+
else if (!name) name = t;
|
|
2791
|
+
else { process.stdout.write(`goal error: unexpected arg "${t}"\n`); return true; }
|
|
2792
|
+
}
|
|
2793
|
+
if (!name) { process.stdout.write('usage: /goal add <name> [--desc "..."] [--cron "<spec>"]\n'); return true; }
|
|
2794
|
+
try {
|
|
2795
|
+
const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, cfgDir);
|
|
2796
|
+
if (cron) {
|
|
2797
|
+
try { await _attachGoalCron(name, cron); }
|
|
2798
|
+
catch (e) { process.stdout.write(`goal warning: cron attach failed (${e?.message || e})\n`); }
|
|
2799
|
+
}
|
|
2800
|
+
process.stdout.write(`✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})\n`);
|
|
2801
|
+
} catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
|
|
2802
|
+
return true;
|
|
2803
|
+
}
|
|
2804
|
+
if (sub === 'list') {
|
|
2805
|
+
process.stdout.write(JSON.stringify(goalsMod.listGoals(cfgDir), null, 2) + '\n');
|
|
2806
|
+
return true;
|
|
2807
|
+
}
|
|
2808
|
+
if (sub === 'show') {
|
|
2809
|
+
const name = rest[0];
|
|
2810
|
+
if (!name) { process.stdout.write('usage: /goal show <name>\n'); return true; }
|
|
2811
|
+
const g = goalsMod.getGoal(name, cfgDir);
|
|
2812
|
+
if (!g) { process.stdout.write(`no goal "${name}"\n`); return true; }
|
|
2813
|
+
process.stdout.write(JSON.stringify(g, null, 2) + '\n');
|
|
2814
|
+
return true;
|
|
2815
|
+
}
|
|
2816
|
+
if (sub === 'close') {
|
|
2817
|
+
const name = rest[0];
|
|
2818
|
+
const outcome = rest[1] || 'done';
|
|
2819
|
+
if (!name) { process.stdout.write('usage: /goal close <name> [done|abandoned]\n'); return true; }
|
|
2820
|
+
try {
|
|
2821
|
+
const g = goalsMod.closeGoal(name, outcome, cfgDir);
|
|
2822
|
+
try { await _detachGoalCron(name); }
|
|
2823
|
+
catch (e) { process.stdout.write(`goal warning: cron detach failed (${e?.message || e})\n`); }
|
|
2824
|
+
process.stdout.write(`✓ goal ${g.name} closed (status: ${g.status})\n`);
|
|
2825
|
+
} catch (e) { process.stdout.write(`goal error: ${e?.message || e}\n`); }
|
|
2826
|
+
return true;
|
|
2827
|
+
}
|
|
2828
|
+
// Single-arg branch: switch context to goal:<name>.
|
|
2829
|
+
const goalName = sub;
|
|
2830
|
+
const g = goalsMod.getGoal(goalName, cfgDir);
|
|
2831
|
+
if (!g) {
|
|
2832
|
+
process.stdout.write(`no goal "${goalName}" — try: /goal add ${goalName} --desc "..."\n`);
|
|
2833
|
+
return true;
|
|
2834
|
+
}
|
|
2835
|
+
if (g.status !== 'active') {
|
|
2836
|
+
process.stdout.write(`goal "${goalName}" is ${g.status}; cannot switch\n`);
|
|
2837
|
+
return true;
|
|
2838
|
+
}
|
|
2839
|
+
// Switch: replace the chat's active session id and reload turns
|
|
2840
|
+
// from the goal's session. The provider, model, workspace, and
|
|
2841
|
+
// skill state stay put — only the conversation surface changes.
|
|
2842
|
+
sessionId = g.sessionId;
|
|
2843
|
+
activeGoalName = g.name;
|
|
2844
|
+
const prior = sessionsMod.loadTurns(sessionId, cfgDir);
|
|
2845
|
+
messages = prior.map(t => ({ role: t.role, content: t.content }));
|
|
2846
|
+
// Prepend a one-line goal note to the system message so the
|
|
2847
|
+
// model sees the current objective without us having to mutate
|
|
2848
|
+
// any persistent record on every switch.
|
|
2849
|
+
const sysIdx = messages.findIndex(m => m.role === 'system');
|
|
2850
|
+
const goalNote = `## Goal: ${g.description || g.name}`;
|
|
2851
|
+
if (sysIdx >= 0) {
|
|
2852
|
+
messages[sysIdx] = { role: 'system', content: `${goalNote}\n\n${messages[sysIdx].content}` };
|
|
2853
|
+
} else {
|
|
2854
|
+
messages.unshift({ role: 'system', content: goalNote });
|
|
2855
|
+
}
|
|
2856
|
+
process.stdout.write(`✓ switched to goal: ${g.name} (session: ${sessionId}, ${prior.length} prior turn(s))\n`);
|
|
2857
|
+
return true;
|
|
2858
|
+
}
|
|
2859
|
+
case '/memory': {
|
|
2860
|
+
const arg = line.slice('/memory'.length).trim();
|
|
2861
|
+
const memMod = await import('./memory.mjs');
|
|
2862
|
+
const tokens = arg.split(/\s+/).filter(Boolean);
|
|
2863
|
+
const which = tokens[0] || 'core';
|
|
2864
|
+
if (which === 'core') {
|
|
2865
|
+
const body = memMod.loadCore(cfgDir);
|
|
2866
|
+
process.stdout.write(body || '(empty core memory)\n');
|
|
2867
|
+
return true;
|
|
2868
|
+
}
|
|
2869
|
+
if (which === 'recent') {
|
|
2870
|
+
const items = memMod.loadRecent(20, cfgDir);
|
|
2871
|
+
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
|
|
2872
|
+
return true;
|
|
2873
|
+
}
|
|
2874
|
+
if (which === 'episodic') {
|
|
2875
|
+
const topic = tokens[1];
|
|
2876
|
+
if (topic) {
|
|
2877
|
+
const body = memMod.loadEpisodic(topic, cfgDir);
|
|
2878
|
+
process.stdout.write(body || `(no episodic file "${topic}")\n`);
|
|
2879
|
+
} else {
|
|
2880
|
+
process.stdout.write(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2) + '\n');
|
|
2881
|
+
}
|
|
2882
|
+
return true;
|
|
2883
|
+
}
|
|
2884
|
+
process.stdout.write('usage: /memory [core|recent|episodic [topic]]\n');
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
case '/dream': {
|
|
2888
|
+
const memMod = await import('./memory.mjs');
|
|
2889
|
+
process.stdout.write(' ↯ dreaming…\n');
|
|
2890
|
+
try {
|
|
2891
|
+
const r = await memMod.dream(sessionId, {
|
|
2892
|
+
provider: prov,
|
|
2893
|
+
model: activeModel,
|
|
2894
|
+
apiKey: _resolveAuthKey(cfg, activeProvName),
|
|
2895
|
+
}, cfgDir);
|
|
2896
|
+
process.stdout.write(`✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}\n`);
|
|
2897
|
+
} catch (e) { process.stdout.write(`dream error: ${e?.message || e}\n`); }
|
|
2898
|
+
return true;
|
|
2899
|
+
}
|
|
2900
|
+
case '/agent': {
|
|
2901
|
+
const rawArg = line.slice('/agent'.length).trim();
|
|
2902
|
+
const agentsMod = await import('./agents.mjs');
|
|
2903
|
+
const loopMod = await import('./loop-engine.mjs');
|
|
2904
|
+
let tokens;
|
|
2905
|
+
try { tokens = loopMod.splitArgs(rawArg); }
|
|
2906
|
+
catch (e) { process.stdout.write(`/agent error: ${e?.message || e}\n`); return true; }
|
|
2907
|
+
const sub = tokens[0];
|
|
2908
|
+
const rest = tokens.slice(1);
|
|
2909
|
+
const aname = rest[0];
|
|
2910
|
+
try {
|
|
2911
|
+
if (!sub || sub === 'list') {
|
|
2912
|
+
const agents = agentsMod.listAgents(cfgDir);
|
|
2913
|
+
if (agents.length === 0) process.stdout.write('no agents registered. /agent add <name> [...] to create.\n');
|
|
2914
|
+
else for (const a of agents) {
|
|
2915
|
+
const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
|
|
2916
|
+
process.stdout.write(`• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]\n`);
|
|
2917
|
+
}
|
|
2918
|
+
} else if (sub === 'show') {
|
|
2919
|
+
if (!aname) { process.stdout.write('usage: /agent show <name>\n'); return true; }
|
|
2920
|
+
const a = agentsMod.getAgent(aname, cfgDir);
|
|
2921
|
+
if (!a) process.stdout.write(`no agent "${aname}"\n`);
|
|
2922
|
+
else process.stdout.write(JSON.stringify(a, null, 2) + '\n');
|
|
2923
|
+
} else if (sub === 'add') {
|
|
2924
|
+
if (!aname) { process.stdout.write('usage: /agent add <name> [role text…]\n'); return true; }
|
|
2925
|
+
const roleText = rest.slice(1).join(' ').trim();
|
|
2926
|
+
const a = agentsMod.registerAgent({ name: aname, role: roleText }, cfgDir);
|
|
2927
|
+
process.stdout.write(`✓ added agent ${a.name} (tools=${a.tools.join(',')})\n`);
|
|
2928
|
+
} else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
2929
|
+
if (!aname) { process.stdout.write('usage: /agent remove <name>\n'); return true; }
|
|
2930
|
+
agentsMod.removeAgent(aname, cfgDir);
|
|
2931
|
+
process.stdout.write(`✓ removed agent ${aname}\n`);
|
|
2932
|
+
} else {
|
|
2933
|
+
process.stdout.write(`/agent: unknown sub "${sub}" — list|show|add|remove\n`);
|
|
2934
|
+
}
|
|
2935
|
+
} catch (e) {
|
|
2936
|
+
process.stdout.write(`/agent error: ${e?.message || e}\n`);
|
|
2937
|
+
}
|
|
2938
|
+
return true;
|
|
2939
|
+
}
|
|
2940
|
+
case '/team': {
|
|
2941
|
+
const rawArg = line.slice('/team'.length).trim();
|
|
2942
|
+
const teamsMod = await import('./teams.mjs');
|
|
2943
|
+
const loopMod = await import('./loop-engine.mjs');
|
|
2944
|
+
let tokens;
|
|
2945
|
+
try { tokens = loopMod.splitArgs(rawArg); }
|
|
2946
|
+
catch (e) { process.stdout.write(`/team error: ${e?.message || e}\n`); return true; }
|
|
2947
|
+
const sub = tokens[0];
|
|
2948
|
+
const rest = tokens.slice(1);
|
|
2949
|
+
const tname = rest[0];
|
|
2950
|
+
try {
|
|
2951
|
+
if (!sub || sub === 'list') {
|
|
2952
|
+
const teams = teamsMod.listTeams(cfgDir);
|
|
2953
|
+
if (teams.length === 0) process.stdout.write('no teams registered. /team add <name> --agents a,b --lead a [--channel #x]\n');
|
|
2954
|
+
else for (const t of teams) {
|
|
2955
|
+
const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
|
|
2956
|
+
process.stdout.write(`• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}\n`);
|
|
2957
|
+
}
|
|
2958
|
+
} else if (sub === 'show') {
|
|
2959
|
+
if (!tname) { process.stdout.write('usage: /team show <name>\n'); return true; }
|
|
2960
|
+
const t = teamsMod.getTeam(tname, cfgDir);
|
|
2961
|
+
if (!t) process.stdout.write(`no team "${tname}"\n`);
|
|
2962
|
+
else process.stdout.write(JSON.stringify(t, null, 2) + '\n');
|
|
2963
|
+
} else if (sub === 'add') {
|
|
2964
|
+
// /team add <name> --agents a,b,c [--lead a] [--channel #x]
|
|
2965
|
+
if (!tname) { process.stdout.write('usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]\n'); return true; }
|
|
2966
|
+
let agentsCsv = null, lead = null, channel = '';
|
|
2967
|
+
for (let i = 1; i < rest.length; i++) {
|
|
2968
|
+
const t = rest[i];
|
|
2969
|
+
if (t === '--agents') agentsCsv = rest[++i] || '';
|
|
2970
|
+
else if (t === '--lead') lead = rest[++i] || null;
|
|
2971
|
+
else if (t === '--channel') channel = rest[++i] || '';
|
|
2972
|
+
else { process.stdout.write(`/team error: unknown token "${t}"\n`); return true; }
|
|
2973
|
+
}
|
|
2974
|
+
if (!agentsCsv) { process.stdout.write('/team add: --agents is required\n'); return true; }
|
|
2975
|
+
const agents = teamsMod.parseListFlag(agentsCsv);
|
|
2976
|
+
const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
|
|
2977
|
+
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
2978
|
+
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
2979
|
+
logger: () => {},
|
|
2980
|
+
}) : '';
|
|
2981
|
+
const team = teamsMod.registerTeam({ name: tname, agents, lead, slackChannel: ch }, cfgDir);
|
|
2982
|
+
process.stdout.write(`✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})\n`);
|
|
2983
|
+
} else if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
|
|
2984
|
+
if (!tname) { process.stdout.write('usage: /team remove <name>\n'); return true; }
|
|
2985
|
+
teamsMod.removeTeam(tname, cfgDir);
|
|
2986
|
+
process.stdout.write(`✓ removed team ${tname}\n`);
|
|
2987
|
+
} else {
|
|
2988
|
+
process.stdout.write(`/team: unknown sub "${sub}" — list|show|add|remove\n`);
|
|
2989
|
+
}
|
|
2990
|
+
} catch (e) {
|
|
2991
|
+
process.stdout.write(`/team error: ${e?.message || e}\n`);
|
|
2992
|
+
}
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2600
2995
|
case '/exit': {
|
|
2601
2996
|
return 'EXIT';
|
|
2602
2997
|
}
|
|
@@ -3482,6 +3877,1113 @@ async function cmdCron(sub, positional, flags = {}) {
|
|
|
3482
3877
|
}
|
|
3483
3878
|
}
|
|
3484
3879
|
|
|
3880
|
+
// `lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID]
|
|
3881
|
+
// [--detach] [--provider NAME] [--model NAME]`
|
|
3882
|
+
//
|
|
3883
|
+
// Without --detach: runs the loop in the foreground using the engine
|
|
3884
|
+
// from loop-engine.mjs and streams chunks to stdout (mirrors the REPL
|
|
3885
|
+
// /loop UX but with no surrounding chat REPL).
|
|
3886
|
+
//
|
|
3887
|
+
// With --detach: forks scripts/loop-worker.mjs in its own process group
|
|
3888
|
+
// (`detached: true`), prints `{loopId, pid, statePath}` and returns
|
|
3889
|
+
// immediately. The worker persists state under `<configDir>/loops/<id>/`.
|
|
3890
|
+
async function cmdLoop(prompt, flags = {}) {
|
|
3891
|
+
await ensureRegistry();
|
|
3892
|
+
const cfg = readConfig();
|
|
3893
|
+
const cfgDir = path.dirname(configPath());
|
|
3894
|
+
const loopEng = await import('./loop-engine.mjs');
|
|
3895
|
+
const loopsMod = await import('./loops.mjs');
|
|
3896
|
+
|
|
3897
|
+
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
3898
|
+
console.error('Usage: lazyclaw loop <prompt> [--max N] [--until "<regex>"] [--session ID] [--detach]');
|
|
3899
|
+
process.exit(2);
|
|
3900
|
+
}
|
|
3901
|
+
const max = flags.max !== undefined ? Number(flags.max) : loopEng.LOOP_MAX_DEFAULT;
|
|
3902
|
+
if (!Number.isInteger(max) || max <= 0) {
|
|
3903
|
+
console.error(`loop: --max must be a positive integer, got "${flags.max}"`);
|
|
3904
|
+
process.exit(2);
|
|
3905
|
+
}
|
|
3906
|
+
if (max > loopEng.LOOP_MAX_CEILING) {
|
|
3907
|
+
console.error(`loop: --max ${max} exceeds ceiling ${loopEng.LOOP_MAX_CEILING} (runaway guard)`);
|
|
3908
|
+
process.exit(2);
|
|
3909
|
+
}
|
|
3910
|
+
let untilRe = null;
|
|
3911
|
+
try { untilRe = loopEng.compileUntil(flags.until); }
|
|
3912
|
+
catch (e) { console.error(`loop: ${e?.message || e}`); process.exit(2); }
|
|
3913
|
+
|
|
3914
|
+
const provName = flags.provider || cfg.provider || 'mock';
|
|
3915
|
+
const prov = _registryMod.PROVIDERS[provName];
|
|
3916
|
+
if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
|
|
3917
|
+
const model = flags.model || cfg.model;
|
|
3918
|
+
|
|
3919
|
+
const loopId = loopsMod.newLoopId();
|
|
3920
|
+
const requestedSession = flags.session ? String(flags.session) : null;
|
|
3921
|
+
const sessionId = requestedSession || `loop:${loopId}`;
|
|
3922
|
+
const statePath = loopsMod.loopDir(loopId, cfgDir);
|
|
3923
|
+
|
|
3924
|
+
// Seed meta before forking so `loops list` can see the job even if the
|
|
3925
|
+
// worker hasn't reached its first iteration yet.
|
|
3926
|
+
loopsMod.writeMeta(loopId, {
|
|
3927
|
+
prompt,
|
|
3928
|
+
max,
|
|
3929
|
+
until: flags.until || null,
|
|
3930
|
+
sessionId,
|
|
3931
|
+
sessionMode: requestedSession ? 'shared' : 'fresh',
|
|
3932
|
+
provider: provName,
|
|
3933
|
+
model: model || null,
|
|
3934
|
+
status: 'pending',
|
|
3935
|
+
startedAt: new Date().toISOString(),
|
|
3936
|
+
pid: null,
|
|
3937
|
+
}, cfgDir);
|
|
3938
|
+
|
|
3939
|
+
if (flags.detach) {
|
|
3940
|
+
const { spawn } = await import('node:child_process');
|
|
3941
|
+
const here = path.dirname(new URL(import.meta.url).pathname);
|
|
3942
|
+
const worker = path.join(here, 'scripts', 'loop-worker.mjs');
|
|
3943
|
+
const argv = [worker, '--loop-id', loopId, '--prompt', prompt,
|
|
3944
|
+
'--max', String(max), '--provider', provName, '--cfg-dir', cfgDir];
|
|
3945
|
+
if (flags.until) { argv.push('--until', String(flags.until)); }
|
|
3946
|
+
if (requestedSession) { argv.push('--session-existing', requestedSession); }
|
|
3947
|
+
if (model) { argv.push('--model', String(model)); }
|
|
3948
|
+
const child = spawn(process.execPath, argv, {
|
|
3949
|
+
detached: true,
|
|
3950
|
+
stdio: 'ignore',
|
|
3951
|
+
env: { ...process.env, LAZYCLAW_CONFIG_DIR: cfgDir },
|
|
3952
|
+
});
|
|
3953
|
+
child.unref();
|
|
3954
|
+
loopsMod.patchMeta(loopId, { pid: child.pid, pgid: child.pid, status: 'running' }, cfgDir);
|
|
3955
|
+
process.stdout.write(JSON.stringify({ loopId, pid: child.pid, statePath }) + '\n');
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// Foreground path — same engine, streaming chunks live to stdout.
|
|
3960
|
+
const sessionsMod = await import('./sessions.mjs');
|
|
3961
|
+
const messages = requestedSession
|
|
3962
|
+
? sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }))
|
|
3963
|
+
: [];
|
|
3964
|
+
loopsMod.patchMeta(loopId, { pid: process.pid, status: 'running' }, cfgDir);
|
|
3965
|
+
|
|
3966
|
+
const ac = new AbortController();
|
|
3967
|
+
const onSig = () => ac.abort();
|
|
3968
|
+
process.on('SIGINT', onSig);
|
|
3969
|
+
process.on('SIGTERM', onSig);
|
|
3970
|
+
|
|
3971
|
+
const sendOnce = async (msgs, signal) => {
|
|
3972
|
+
let acc = '';
|
|
3973
|
+
for await (const chunk of prov.sendMessage(msgs, {
|
|
3974
|
+
apiKey: _resolveAuthKey(cfg, provName),
|
|
3975
|
+
model,
|
|
3976
|
+
signal,
|
|
3977
|
+
})) {
|
|
3978
|
+
process.stdout.write(chunk);
|
|
3979
|
+
acc += chunk;
|
|
3980
|
+
}
|
|
3981
|
+
process.stdout.write('\n');
|
|
3982
|
+
return acc;
|
|
3983
|
+
};
|
|
3984
|
+
const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
|
|
3985
|
+
const onIteration = ({ i, max: m, reply }) => {
|
|
3986
|
+
process.stderr.write(` ↻ loop iteration ${i}/${m}\n`);
|
|
3987
|
+
loopsMod.appendIteration(loopId, { iteration: i, of: m, bytes: reply.length, preview: reply.slice(0, 200) }, cfgDir);
|
|
3988
|
+
};
|
|
3989
|
+
|
|
3990
|
+
// Detached/foreground both honor --use-memory and --recall by
|
|
3991
|
+
// rebuilding the system message before each iteration. The
|
|
3992
|
+
// computation lives in memory.mjs so the same logic powers
|
|
3993
|
+
// `/loop --use-memory` in the REPL.
|
|
3994
|
+
const memMod = (flags['use-memory'] || flags.recall) ? await import('./memory.mjs') : null;
|
|
3995
|
+
const buildSystem = memMod ? (() => {
|
|
3996
|
+
const parts = [];
|
|
3997
|
+
if (flags['use-memory']) {
|
|
3998
|
+
const core = memMod.loadCore(cfgDir);
|
|
3999
|
+
if (core && core.trim()) parts.push(core);
|
|
4000
|
+
}
|
|
4001
|
+
if (flags.recall) {
|
|
4002
|
+
const text = memMod.recall(String(flags.recall), { topN: 3 }, cfgDir);
|
|
4003
|
+
if (text && text.trim()) parts.push(text);
|
|
4004
|
+
}
|
|
4005
|
+
return parts.join('\n\n---\n\n');
|
|
4006
|
+
}) : null;
|
|
4007
|
+
|
|
4008
|
+
try {
|
|
4009
|
+
const result = await loopEng.runLoop({ prompt, max, until: untilRe, messages, sendOnce, persist, onIteration, signal: ac.signal, buildSystem });
|
|
4010
|
+
const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
|
|
4011
|
+
loopsMod.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
|
|
4012
|
+
loopsMod.writeResult(loopId, result, cfgDir);
|
|
4013
|
+
process.stdout.write(JSON.stringify({ loopId, ...result }) + '\n');
|
|
4014
|
+
} catch (err) {
|
|
4015
|
+
loopsMod.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
|
|
4016
|
+
loopsMod.writeResult(loopId, { error: err?.message || String(err) }, cfgDir);
|
|
4017
|
+
console.error(`loop error: ${err?.message || err}`);
|
|
4018
|
+
process.exit(1);
|
|
4019
|
+
} finally {
|
|
4020
|
+
process.off('SIGINT', onSig);
|
|
4021
|
+
process.off('SIGTERM', onSig);
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
// Kill registry — `lazyclaw loops kill <id>` SIGTERMs once and SIGKILLs
|
|
4026
|
+
// on a second invocation within KILL_ESCALATE_MS. Module-scoped so two
|
|
4027
|
+
// rapid invocations of `cmd loops kill <id>` from the same process see
|
|
4028
|
+
// each other; for separate processes the worker also handles SIGKILL by
|
|
4029
|
+
// the OS, so the escalation is a UX nicety rather than a correctness gate.
|
|
4030
|
+
const _killLog = new Map();
|
|
4031
|
+
const KILL_ESCALATE_MS = 5000;
|
|
4032
|
+
|
|
4033
|
+
async function cmdLoops(sub, positional, flags = {}) {
|
|
4034
|
+
const loopsMod = await import('./loops.mjs');
|
|
4035
|
+
const cfgDir = path.dirname(configPath());
|
|
4036
|
+
switch (sub) {
|
|
4037
|
+
case undefined:
|
|
4038
|
+
case 'list': {
|
|
4039
|
+
const items = loopsMod.listLoops(cfgDir).map(loopsMod.reconcileStatus);
|
|
4040
|
+
console.log(JSON.stringify(items, null, 2));
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
case 'show': {
|
|
4044
|
+
const id = positional[0];
|
|
4045
|
+
if (!id) { console.error('Usage: lazyclaw loops show <id>'); process.exit(2); }
|
|
4046
|
+
const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
|
|
4047
|
+
if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
|
|
4048
|
+
const iterations = loopsMod.readIterations(id, cfgDir);
|
|
4049
|
+
const result = loopsMod.readResult(id, cfgDir);
|
|
4050
|
+
console.log(JSON.stringify({ id, meta, iterations, result }, null, 2));
|
|
4051
|
+
return;
|
|
4052
|
+
}
|
|
4053
|
+
case 'kill': {
|
|
4054
|
+
const id = positional[0];
|
|
4055
|
+
if (!id) { console.error('Usage: lazyclaw loops kill <id>'); process.exit(2); }
|
|
4056
|
+
const meta = loopsMod.readMeta(id, cfgDir);
|
|
4057
|
+
if (!meta) { console.error(`no loop "${id}"`); process.exit(1); }
|
|
4058
|
+
if (!meta.pid) { console.error(`loop "${id}" has no pid`); process.exit(1); }
|
|
4059
|
+
const last = _killLog.get(id) || 0;
|
|
4060
|
+
const now = Date.now();
|
|
4061
|
+
const escalate = (now - last) < KILL_ESCALATE_MS && last > 0;
|
|
4062
|
+
const sig = escalate ? 'SIGKILL' : 'SIGTERM';
|
|
4063
|
+
try { process.kill(meta.pid, sig); }
|
|
4064
|
+
catch (e) {
|
|
4065
|
+
if (e?.code !== 'ESRCH') throw e;
|
|
4066
|
+
// Already gone — reconcile and report.
|
|
4067
|
+
loopsMod.patchMeta(id, { status: 'killed', finishedAt: new Date().toISOString() }, cfgDir);
|
|
4068
|
+
console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, status: 'already_gone' }));
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
_killLog.set(id, now);
|
|
4072
|
+
console.log(JSON.stringify({ id, pid: meta.pid, signal: sig, escalated: escalate }));
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
case 'tail': {
|
|
4076
|
+
const id = positional[0];
|
|
4077
|
+
if (!id) { console.error('Usage: lazyclaw loops tail <id>'); process.exit(2); }
|
|
4078
|
+
const dir = loopsMod.loopDir(id, cfgDir);
|
|
4079
|
+
const logPath = path.join(dir, 'iterations.log');
|
|
4080
|
+
const fs = await import('node:fs');
|
|
4081
|
+
if (!fs.existsSync(dir)) { console.error(`no loop "${id}"`); process.exit(1); }
|
|
4082
|
+
// Print everything already on disk first, then poll for new lines
|
|
4083
|
+
// until the worker exits / status is no longer "running".
|
|
4084
|
+
let offset = 0;
|
|
4085
|
+
if (fs.existsSync(logPath)) {
|
|
4086
|
+
const buf = fs.readFileSync(logPath, 'utf8');
|
|
4087
|
+
process.stdout.write(buf);
|
|
4088
|
+
offset = buf.length;
|
|
4089
|
+
}
|
|
4090
|
+
const pollMs = Number(flags['poll-ms']) || 250;
|
|
4091
|
+
const maxMs = Number(flags['max-wait-ms']) || 0; // 0 = wait indefinitely
|
|
4092
|
+
const startedAt = Date.now();
|
|
4093
|
+
while (true) {
|
|
4094
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
4095
|
+
let cur = '';
|
|
4096
|
+
try { cur = fs.readFileSync(logPath, 'utf8'); } catch { /* file may briefly not exist */ }
|
|
4097
|
+
if (cur.length > offset) {
|
|
4098
|
+
process.stdout.write(cur.slice(offset));
|
|
4099
|
+
offset = cur.length;
|
|
4100
|
+
}
|
|
4101
|
+
const meta = loopsMod.reconcileStatus(loopsMod.readMeta(id, cfgDir));
|
|
4102
|
+
if (!meta || meta.status !== 'running') break;
|
|
4103
|
+
if (maxMs > 0 && Date.now() - startedAt > maxMs) break;
|
|
4104
|
+
}
|
|
4105
|
+
return;
|
|
4106
|
+
}
|
|
4107
|
+
default:
|
|
4108
|
+
console.error('Usage: lazyclaw loops <list|show|kill|tail> ...');
|
|
4109
|
+
process.exit(2);
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
// Install (or refresh) the system scheduler entry that fires
|
|
4114
|
+
// `lazyclaw goal tick <name>` on a schedule. Writes to cfg.cron and to
|
|
4115
|
+
// the OS backend (launchd / crontab). Tests set
|
|
4116
|
+
// LAZYCLAW_SKIP_CRON_INSTALL=1 to skip the OS-side mutation but keep
|
|
4117
|
+
// the config-side wiring so `cron list` still reflects the entry.
|
|
4118
|
+
async function _attachGoalCron(name, schedule) {
|
|
4119
|
+
const cron = await import('./cron.mjs');
|
|
4120
|
+
cron.parseCronSpec(schedule); // validate before we touch state
|
|
4121
|
+
const cfg = readConfig();
|
|
4122
|
+
const jobName = `goal-${name}`;
|
|
4123
|
+
const cmd = ['lazyclaw', 'goal', 'tick', name];
|
|
4124
|
+
cron.upsertJob(cfg, jobName, schedule, cmd);
|
|
4125
|
+
writeConfig(cfg);
|
|
4126
|
+
if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
|
|
4127
|
+
const backend = cron.pickBackend();
|
|
4128
|
+
if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
|
|
4129
|
+
else cron.installCrontabJob(jobName, schedule, cmd);
|
|
4130
|
+
return { jobName, skipped: false };
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
// Mirror of _attachGoalCron's removal path. Returns true when an entry
|
|
4134
|
+
// was actually present; false when the goal had no cron attached
|
|
4135
|
+
// (already-clean state, safe to call unconditionally during `close`).
|
|
4136
|
+
async function _detachGoalCron(name) {
|
|
4137
|
+
const cron = await import('./cron.mjs');
|
|
4138
|
+
const cfg = readConfig();
|
|
4139
|
+
const jobName = `goal-${name}`;
|
|
4140
|
+
if (!cfg.cron || !cfg.cron[jobName]) return false;
|
|
4141
|
+
cron.removeJob(cfg, jobName);
|
|
4142
|
+
writeConfig(cfg);
|
|
4143
|
+
if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
|
|
4144
|
+
const backend = cron.pickBackend();
|
|
4145
|
+
try {
|
|
4146
|
+
if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
|
|
4147
|
+
else cron.uninstallCrontabJob(jobName);
|
|
4148
|
+
} catch { /* best-effort — cron sync recovers */ }
|
|
4149
|
+
return true;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
// Builds the user-side prompt the scheduler sends on every tick. Memory
|
|
4153
|
+
// (core + episodic matches) lands in the system slot via Phase 6's
|
|
4154
|
+
// buildSystem path, not here — that way a parallel writer touching
|
|
4155
|
+
// core.md mid-loop is reflected on the next iteration without us
|
|
4156
|
+
// having to rebuild this string.
|
|
4157
|
+
function _composeTickPrompt(goal) {
|
|
4158
|
+
const parts = [];
|
|
4159
|
+
parts.push(`Goal: ${goal.description || goal.name}`);
|
|
4160
|
+
const recent = (goal.checkIns || []).slice(-3);
|
|
4161
|
+
if (recent.length) {
|
|
4162
|
+
parts.push('Recent check-ins:');
|
|
4163
|
+
for (const c of recent) parts.push(`- ${c.at}: ${c.summary}`);
|
|
4164
|
+
}
|
|
4165
|
+
parts.push("What's the next concrete step?");
|
|
4166
|
+
return parts.join('\n\n');
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
// `lazyclaw goal <add|list|show|close|switch|tick|channel> ...`
|
|
4170
|
+
//
|
|
4171
|
+
// Pure registration in Phase 3 (no cron install, no channel delivery —
|
|
4172
|
+
// those land in Phase 4 / Phase 8). `switch` is a no-op for the
|
|
4173
|
+
// detached CLI surface; it exists for symmetry with the REPL command
|
|
4174
|
+
// (where it changes the chat's working session) and writes nothing
|
|
4175
|
+
// special when invoked here — the user gets a hint pointing at /goal.
|
|
4176
|
+
async function cmdGoal(sub, positional, flags = {}) {
|
|
4177
|
+
const goalsMod = await import('./goals.mjs');
|
|
4178
|
+
const cfgDir = path.dirname(configPath());
|
|
4179
|
+
switch (sub) {
|
|
4180
|
+
case 'add': {
|
|
4181
|
+
const name = positional[0];
|
|
4182
|
+
if (!name) { console.error('Usage: lazyclaw goal add <name> [--desc "..."] [--cron "<spec>"] [--channel slack:<target>]'); process.exit(2); }
|
|
4183
|
+
let g;
|
|
4184
|
+
const channels = flags.channel ? [String(flags.channel)] : [];
|
|
4185
|
+
try {
|
|
4186
|
+
g = goalsMod.registerGoal({
|
|
4187
|
+
name,
|
|
4188
|
+
description: flags.desc || '',
|
|
4189
|
+
schedule: flags.cron || null,
|
|
4190
|
+
channels,
|
|
4191
|
+
}, cfgDir);
|
|
4192
|
+
} catch (e) { console.error(e?.message || e); process.exit(2); }
|
|
4193
|
+
if (flags.cron) {
|
|
4194
|
+
try { await _attachGoalCron(name, String(flags.cron)); }
|
|
4195
|
+
catch (e) { console.error(`error attaching cron: ${e?.message || e}`); process.exit(1); }
|
|
4196
|
+
}
|
|
4197
|
+
console.log(JSON.stringify(g, null, 2));
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
case undefined:
|
|
4201
|
+
case 'list': {
|
|
4202
|
+
const items = goalsMod.listGoals(cfgDir);
|
|
4203
|
+
console.log(JSON.stringify(items, null, 2));
|
|
4204
|
+
return;
|
|
4205
|
+
}
|
|
4206
|
+
case 'show': {
|
|
4207
|
+
const name = positional[0];
|
|
4208
|
+
if (!name) { console.error('Usage: lazyclaw goal show <name>'); process.exit(2); }
|
|
4209
|
+
const g = goalsMod.getGoal(name, cfgDir);
|
|
4210
|
+
if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
|
|
4211
|
+
console.log(JSON.stringify(g, null, 2));
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
case 'close': {
|
|
4215
|
+
const name = positional[0];
|
|
4216
|
+
const outcome = positional[1] || 'done';
|
|
4217
|
+
if (!name) { console.error('Usage: lazyclaw goal close <name> [done|abandoned]'); process.exit(2); }
|
|
4218
|
+
let g;
|
|
4219
|
+
try { g = goalsMod.closeGoal(name, outcome, cfgDir); }
|
|
4220
|
+
catch (e) { console.error(e?.message || e); process.exit(1); }
|
|
4221
|
+
// Best-effort cron detach. If the goal had no cron attached this
|
|
4222
|
+
// is a no-op; if it did, both cfg.cron and the OS scheduler are
|
|
4223
|
+
// cleaned in tandem so a follow-up `cron list` is empty.
|
|
4224
|
+
try { await _detachGoalCron(name); }
|
|
4225
|
+
catch (e) { console.error(`warn: cron detach failed: ${e?.message || e}`); }
|
|
4226
|
+
console.log(JSON.stringify(g, null, 2));
|
|
4227
|
+
return;
|
|
4228
|
+
}
|
|
4229
|
+
case 'tick': {
|
|
4230
|
+
// Internal subcommand fired by the cron scheduler (or manually
|
|
4231
|
+
// with --force). Exits 0 silently when the goal is not active so
|
|
4232
|
+
// a stale cron entry doesn't crash the scheduler.
|
|
4233
|
+
const name = positional[0];
|
|
4234
|
+
if (!name) { console.error('Usage: lazyclaw goal tick <name> [--force]'); process.exit(2); }
|
|
4235
|
+
const g = goalsMod.getGoal(name, cfgDir);
|
|
4236
|
+
if (!g) {
|
|
4237
|
+
// No goal file at all — exit 0 silently. The scheduler may be
|
|
4238
|
+
// chasing a deleted goal; we don't want to noisy-log the cron
|
|
4239
|
+
// path. Setting LAZYCLAW_DEBUG=1 surfaces it.
|
|
4240
|
+
if (process.env.LAZYCLAW_DEBUG) console.error(`tick: no goal "${name}"`);
|
|
4241
|
+
return;
|
|
4242
|
+
}
|
|
4243
|
+
if (g.status !== 'active') {
|
|
4244
|
+
if (process.env.LAZYCLAW_DEBUG) console.error(`tick: goal "${name}" is ${g.status}, skipping`);
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
await ensureRegistry();
|
|
4248
|
+
const cfg = readConfig();
|
|
4249
|
+
const provName = flags.provider || cfg.provider || 'mock';
|
|
4250
|
+
const prov = _registryMod.PROVIDERS[provName];
|
|
4251
|
+
if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
|
|
4252
|
+
const model = flags.model || cfg.model;
|
|
4253
|
+
|
|
4254
|
+
const memoryMod = await import('./memory.mjs');
|
|
4255
|
+
const tickPrompt = _composeTickPrompt(g);
|
|
4256
|
+
// Memory flows into the system slot. Per-iter rebuild is a no-op
|
|
4257
|
+
// here (max=1) but matches Phase 6's contract so a future tick
|
|
4258
|
+
// with max>1 behaves the same as `/loop --use-memory`.
|
|
4259
|
+
const tickBuildSystem = () => memoryMod.getMemoryForGoal(g.name, g.description || '', cfgDir);
|
|
4260
|
+
const loopEng = await import('./loop-engine.mjs');
|
|
4261
|
+
const sessionsMod = await import('./sessions.mjs');
|
|
4262
|
+
const sessionId = g.sessionId;
|
|
4263
|
+
// Rehydrate prior turns so the model has full context. Tick
|
|
4264
|
+
// appends the user prompt and assistant reply to this session
|
|
4265
|
+
// just like `/loop --max 1` would.
|
|
4266
|
+
const messages = sessionsMod.loadTurns(sessionId, cfgDir).map(t => ({ role: t.role, content: t.content }));
|
|
4267
|
+
|
|
4268
|
+
const sendOnce = async (msgs, signal) => {
|
|
4269
|
+
let acc = '';
|
|
4270
|
+
for await (const chunk of prov.sendMessage(msgs, {
|
|
4271
|
+
apiKey: _resolveAuthKey(cfg, provName),
|
|
4272
|
+
model,
|
|
4273
|
+
signal,
|
|
4274
|
+
})) {
|
|
4275
|
+
acc += chunk;
|
|
4276
|
+
}
|
|
4277
|
+
return acc;
|
|
4278
|
+
};
|
|
4279
|
+
const persist = (role, content) => sessionsMod.appendTurn(sessionId, role, content, cfgDir);
|
|
4280
|
+
|
|
4281
|
+
let result;
|
|
4282
|
+
try {
|
|
4283
|
+
result = await loopEng.runLoop({
|
|
4284
|
+
prompt: tickPrompt,
|
|
4285
|
+
max: 1,
|
|
4286
|
+
until: null,
|
|
4287
|
+
messages,
|
|
4288
|
+
sendOnce,
|
|
4289
|
+
persist,
|
|
4290
|
+
onIteration: undefined,
|
|
4291
|
+
signal: undefined,
|
|
4292
|
+
buildSystem: tickBuildSystem,
|
|
4293
|
+
});
|
|
4294
|
+
} catch (e) {
|
|
4295
|
+
console.error(`tick error: ${e?.message || e}`);
|
|
4296
|
+
process.exit(1);
|
|
4297
|
+
}
|
|
4298
|
+
goalsMod.appendCheckIn(name, result.lastReply, cfgDir);
|
|
4299
|
+
// Fan-out the check-in to every registered channel. We re-read
|
|
4300
|
+
// the goal to capture the freshly-appended checkIn count so the
|
|
4301
|
+
// fan-out body has the canonical timestamp.
|
|
4302
|
+
const refreshed = goalsMod.getGoal(name, cfgDir);
|
|
4303
|
+
const channels = Array.isArray(refreshed?.channels) ? refreshed.channels : [];
|
|
4304
|
+
const slackTargets = channels.filter(c => typeof c === 'string' && c.startsWith('slack:'));
|
|
4305
|
+
const fanoutResults = [];
|
|
4306
|
+
if (slackTargets.length > 0) {
|
|
4307
|
+
// Lazy import so plain non-slack tick paths don't pay the cost.
|
|
4308
|
+
const slackMod = await import('./channels/slack.mjs');
|
|
4309
|
+
let slack;
|
|
4310
|
+
try {
|
|
4311
|
+
slack = new slackMod.SlackChannel({ requireInbound: false });
|
|
4312
|
+
// Validate env BEFORE start() so a missing-secrets environment
|
|
4313
|
+
// does not silently skip — instead the operator sees a clear
|
|
4314
|
+
// warning and tick still succeeds (the check-in is on disk).
|
|
4315
|
+
await slack.start(async () => '', { gate: null });
|
|
4316
|
+
} catch (e) {
|
|
4317
|
+
console.error(`warn: skipping Slack fan-out: ${e?.message || e}`);
|
|
4318
|
+
slack = null;
|
|
4319
|
+
}
|
|
4320
|
+
if (slack) {
|
|
4321
|
+
for (const target of slackTargets) {
|
|
4322
|
+
const channel = target.slice('slack:'.length);
|
|
4323
|
+
try {
|
|
4324
|
+
await slack.send(channel, result.lastReply);
|
|
4325
|
+
fanoutResults.push({ channel: target, ok: true });
|
|
4326
|
+
} catch (e) {
|
|
4327
|
+
fanoutResults.push({ channel: target, ok: false, error: e?.message || String(e) });
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
try { await slack.stop(); } catch { /* best-effort */ }
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
console.log(JSON.stringify({ ok: true, name, iterations: result.iterations, reply: result.lastReply, fanout: fanoutResults }));
|
|
4334
|
+
return;
|
|
4335
|
+
}
|
|
4336
|
+
case 'switch': {
|
|
4337
|
+
const name = positional[0];
|
|
4338
|
+
if (!name) { console.error('Usage: lazyclaw goal switch <name>'); process.exit(2); }
|
|
4339
|
+
const g = goalsMod.getGoal(name, cfgDir);
|
|
4340
|
+
if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
|
|
4341
|
+
// Non-interactive surface: print the session id so a caller can
|
|
4342
|
+
// pipe it into `lazyclaw chat --session <id>`. The REPL slash form
|
|
4343
|
+
// is what mutates state in a live chat.
|
|
4344
|
+
console.log(JSON.stringify({ name: g.name, sessionId: g.sessionId, status: g.status }));
|
|
4345
|
+
return;
|
|
4346
|
+
}
|
|
4347
|
+
case 'channel': {
|
|
4348
|
+
const op = positional[0];
|
|
4349
|
+
const name = positional[1];
|
|
4350
|
+
const target = positional[2];
|
|
4351
|
+
if (!op || !name) { console.error('Usage: lazyclaw goal channel <add|remove> <name> [target]'); process.exit(2); }
|
|
4352
|
+
const g = goalsMod.getGoal(name, cfgDir);
|
|
4353
|
+
if (!g) { console.error(`no goal "${name}"`); process.exit(1); }
|
|
4354
|
+
const cur = Array.isArray(g.channels) ? g.channels : [];
|
|
4355
|
+
let next;
|
|
4356
|
+
if (op === 'add') {
|
|
4357
|
+
if (!target) { console.error('Usage: lazyclaw goal channel add <name> <target>'); process.exit(2); }
|
|
4358
|
+
next = Array.from(new Set([...cur, target]));
|
|
4359
|
+
} else if (op === 'remove') {
|
|
4360
|
+
if (!target) { console.error('Usage: lazyclaw goal channel remove <name> <target>'); process.exit(2); }
|
|
4361
|
+
next = cur.filter(t => t !== target);
|
|
4362
|
+
} else {
|
|
4363
|
+
console.error('Usage: lazyclaw goal channel <add|remove> <name> <target>'); process.exit(2);
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4366
|
+
const updated = goalsMod.patchGoal(name, { channels: next }, cfgDir);
|
|
4367
|
+
console.log(JSON.stringify({ name: updated.name, channels: updated.channels }));
|
|
4368
|
+
return;
|
|
4369
|
+
}
|
|
4370
|
+
default:
|
|
4371
|
+
console.error('Usage: lazyclaw goal <add|list|show|close|switch> ...');
|
|
4372
|
+
process.exit(2);
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
// `lazyclaw memory <show|dream|edit> [args]`
|
|
4377
|
+
//
|
|
4378
|
+
// show core|recent|episodic [topic] print contents to stdout
|
|
4379
|
+
// dream consolidate recent into episodic
|
|
4380
|
+
// edit core open $EDITOR on core.md
|
|
4381
|
+
async function cmdMemory(sub, positional, flags = {}) {
|
|
4382
|
+
const memMod = await import('./memory.mjs');
|
|
4383
|
+
const cfgDir = path.dirname(configPath());
|
|
4384
|
+
switch (sub) {
|
|
4385
|
+
case undefined:
|
|
4386
|
+
case 'show': {
|
|
4387
|
+
const which = positional[0] || 'core';
|
|
4388
|
+
if (which === 'core') {
|
|
4389
|
+
process.stdout.write(memMod.loadCore(cfgDir));
|
|
4390
|
+
return;
|
|
4391
|
+
}
|
|
4392
|
+
if (which === 'recent') {
|
|
4393
|
+
const n = flags.n !== undefined ? Number(flags.n) : 20;
|
|
4394
|
+
console.log(JSON.stringify(memMod.loadRecent(n, cfgDir), null, 2));
|
|
4395
|
+
return;
|
|
4396
|
+
}
|
|
4397
|
+
if (which === 'episodic') {
|
|
4398
|
+
const topic = positional[1];
|
|
4399
|
+
if (topic) { process.stdout.write(memMod.loadEpisodic(topic, cfgDir)); return; }
|
|
4400
|
+
console.log(JSON.stringify(memMod.listEpisodic(cfgDir), null, 2));
|
|
4401
|
+
return;
|
|
4402
|
+
}
|
|
4403
|
+
console.error(`unknown memory.show target: ${which} (expected: core, recent, episodic)`);
|
|
4404
|
+
process.exit(2);
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
case 'dream': {
|
|
4408
|
+
await ensureRegistry();
|
|
4409
|
+
const cfg = readConfig();
|
|
4410
|
+
const provName = flags.provider || cfg.provider || 'mock';
|
|
4411
|
+
const prov = _registryMod.PROVIDERS[provName];
|
|
4412
|
+
if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
|
|
4413
|
+
const sid = positional[0] || flags.session || null;
|
|
4414
|
+
try {
|
|
4415
|
+
const result = await memMod.dream(sid, {
|
|
4416
|
+
provider: prov,
|
|
4417
|
+
model: flags.model || cfg.model,
|
|
4418
|
+
apiKey: _resolveAuthKey(cfg, provName),
|
|
4419
|
+
}, cfgDir);
|
|
4420
|
+
console.log(JSON.stringify({ ok: true, ...result }, null, 2));
|
|
4421
|
+
} catch (e) { console.error(`dream error: ${e?.message || e}`); process.exit(1); }
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
case 'edit': {
|
|
4425
|
+
const which = positional[0] || 'core';
|
|
4426
|
+
if (which !== 'core') {
|
|
4427
|
+
console.error('Only core.md is editable right now (episodic is LLM-curated, recent is append-only)');
|
|
4428
|
+
process.exit(2);
|
|
4429
|
+
}
|
|
4430
|
+
const p = memMod.corePath(cfgDir);
|
|
4431
|
+
const fs_ = await import('node:fs');
|
|
4432
|
+
fs_.mkdirSync(memMod.memoryDir(cfgDir), { recursive: true });
|
|
4433
|
+
if (!fs_.existsSync(p)) fs_.writeFileSync(p, '');
|
|
4434
|
+
const editor = process.env.EDITOR || 'vi';
|
|
4435
|
+
const { spawnSync } = await import('node:child_process');
|
|
4436
|
+
// EDITOR=cat is the test-only escape hatch: spawnSync with stdio
|
|
4437
|
+
// inherit makes the file's contents land on stdout and the
|
|
4438
|
+
// command exits 0 without blocking.
|
|
4439
|
+
const r = spawnSync(editor, [p], { stdio: 'inherit' });
|
|
4440
|
+
if (r.status !== 0 && r.status !== null) {
|
|
4441
|
+
console.error(`editor exited ${r.status}`);
|
|
4442
|
+
process.exit(r.status);
|
|
4443
|
+
}
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
4446
|
+
default:
|
|
4447
|
+
console.error('Usage: lazyclaw memory <show|dream|edit> ...');
|
|
4448
|
+
process.exit(2);
|
|
4449
|
+
}
|
|
4450
|
+
}
|
|
4451
|
+
|
|
4452
|
+
const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect']);
|
|
4453
|
+
const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
|
|
4454
|
+
const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
|
|
4455
|
+
|
|
4456
|
+
async function cmdTask(sub, positional, flags = {}) {
|
|
4457
|
+
const tasksMod = await import('./tasks.mjs');
|
|
4458
|
+
const teamsMod = await import('./teams.mjs');
|
|
4459
|
+
const agentsMod = await import('./agents.mjs');
|
|
4460
|
+
const cfgDir = path.dirname(configPath());
|
|
4461
|
+
const idOrFirst = positional[0];
|
|
4462
|
+
|
|
4463
|
+
const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
4464
|
+
|
|
4465
|
+
// Open a thread root in Slack and return its ts (or '' if we deliberately
|
|
4466
|
+
// skipped posting). Caller decides what to do with the ts.
|
|
4467
|
+
const postKickoff = async ({ task, team, leadAgent }) => {
|
|
4468
|
+
if (!task.slackChannel) {
|
|
4469
|
+
process.stderr.write('[task] team has no slackChannel — skipping Slack post\n');
|
|
4470
|
+
return '';
|
|
4471
|
+
}
|
|
4472
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
4473
|
+
const { SlackChannel } = await import('./channels/slack.mjs');
|
|
4474
|
+
const slack = new SlackChannel({ requireInbound: false });
|
|
4475
|
+
try {
|
|
4476
|
+
await slack.start(async () => '', {});
|
|
4477
|
+
} catch (err) {
|
|
4478
|
+
if (err?.code === 'SLACK_MISSING_ENV') {
|
|
4479
|
+
throw new Error(`SLACK_BOT_TOKEN missing — set it in ${path.join(cfgDir, '.env')} or unset team.slackChannel`);
|
|
4480
|
+
}
|
|
4481
|
+
throw err;
|
|
4482
|
+
}
|
|
4483
|
+
try {
|
|
4484
|
+
const text = tasksMod.buildKickoffMessage({
|
|
4485
|
+
id: task.id,
|
|
4486
|
+
title: task.title,
|
|
4487
|
+
description: task.description,
|
|
4488
|
+
leadDisplayName: leadAgent?.displayName || task.lead,
|
|
4489
|
+
teamDisplayName: team.displayName || team.name,
|
|
4490
|
+
});
|
|
4491
|
+
const res = await slack.send(task.slackChannel, text);
|
|
4492
|
+
return res?.ts || '';
|
|
4493
|
+
} finally {
|
|
4494
|
+
await slack.stop().catch(() => {});
|
|
4495
|
+
}
|
|
4496
|
+
};
|
|
4497
|
+
|
|
4498
|
+
switch (sub) {
|
|
4499
|
+
case undefined:
|
|
4500
|
+
case 'list': {
|
|
4501
|
+
emitJson(tasksMod.listTasks(cfgDir));
|
|
4502
|
+
return;
|
|
4503
|
+
}
|
|
4504
|
+
case 'start': {
|
|
4505
|
+
const teamName = flags.team;
|
|
4506
|
+
const title = flags.title;
|
|
4507
|
+
if (!teamName || !title) {
|
|
4508
|
+
console.error('Usage: lazyclaw task start --team <team> --title "..." [--description "..."] [--lead <agent>]');
|
|
4509
|
+
process.exit(2);
|
|
4510
|
+
}
|
|
4511
|
+
try {
|
|
4512
|
+
const team = teamsMod.getTeam(teamName, cfgDir);
|
|
4513
|
+
if (!team) { console.error(`task start: no team "${teamName}"`); process.exit(2); }
|
|
4514
|
+
const leadName = flags.lead || team.lead;
|
|
4515
|
+
const leadAgent = agentsMod.getAgent(leadName, cfgDir);
|
|
4516
|
+
// Create the task record first (status=pending) so we can roll its
|
|
4517
|
+
// id into the Slack message; then post and patch in the ts.
|
|
4518
|
+
const seeded = tasksMod.registerTask({
|
|
4519
|
+
title,
|
|
4520
|
+
description: flags.description || '',
|
|
4521
|
+
team: teamName,
|
|
4522
|
+
lead: leadName,
|
|
4523
|
+
slackChannel: team.slackChannel,
|
|
4524
|
+
status: 'pending',
|
|
4525
|
+
}, cfgDir);
|
|
4526
|
+
let ts = '';
|
|
4527
|
+
try {
|
|
4528
|
+
ts = await postKickoff({ task: seeded, team, leadAgent });
|
|
4529
|
+
} catch (err) {
|
|
4530
|
+
// Rollback so we don't leave orphan task records when the post fails.
|
|
4531
|
+
try { tasksMod.removeTask(seeded.id, cfgDir); } catch { /* best-effort */ }
|
|
4532
|
+
console.error(`task start: ${err?.message || err}`);
|
|
4533
|
+
process.exit(2);
|
|
4534
|
+
}
|
|
4535
|
+
const turns = ts ? [{ agent: 'system', text: `Task opened by user. Lead: ${leadName}.`, ts }] : [];
|
|
4536
|
+
const finalTask = tasksMod.patchTask(seeded.id, {
|
|
4537
|
+
slackThreadTs: ts,
|
|
4538
|
+
status: ts ? 'running' : 'pending',
|
|
4539
|
+
turns,
|
|
4540
|
+
}, cfgDir);
|
|
4541
|
+
emitJson(finalTask);
|
|
4542
|
+
} catch (err) {
|
|
4543
|
+
console.error(`task start: ${err?.message || err}`);
|
|
4544
|
+
process.exit(2);
|
|
4545
|
+
}
|
|
4546
|
+
return;
|
|
4547
|
+
}
|
|
4548
|
+
case 'show': {
|
|
4549
|
+
if (!idOrFirst) { console.error('Usage: lazyclaw task show <id>'); process.exit(2); }
|
|
4550
|
+
const t = tasksMod.getTask(idOrFirst, cfgDir);
|
|
4551
|
+
if (!t) { console.error(`task show: no task "${idOrFirst}"`); process.exit(2); }
|
|
4552
|
+
emitJson(t);
|
|
4553
|
+
return;
|
|
4554
|
+
}
|
|
4555
|
+
case 'tick': {
|
|
4556
|
+
const id = idOrFirst;
|
|
4557
|
+
const userMsg = positional.slice(1).join(' ').trim() || flags.message || '';
|
|
4558
|
+
if (!id) { console.error('Usage: lazyclaw task tick <id> [<user message>]'); process.exit(2); }
|
|
4559
|
+
const task = tasksMod.getTask(id, cfgDir);
|
|
4560
|
+
if (!task) { console.error(`task tick: no task "${id}"`); process.exit(2); }
|
|
4561
|
+
const team = teamsMod.getTeam(task.team, cfgDir);
|
|
4562
|
+
if (!team) { console.error(`task tick: team "${task.team}" disappeared`); process.exit(2); }
|
|
4563
|
+
// Load all team agents in one shot — the router needs to dispatch
|
|
4564
|
+
// tool-use turns through each speaker's record.
|
|
4565
|
+
const agentsById = {};
|
|
4566
|
+
for (const name of team.agents) {
|
|
4567
|
+
const rec = agentsMod.getAgent(name, cfgDir);
|
|
4568
|
+
if (!rec) { console.error(`task tick: agent "${name}" disappeared`); process.exit(2); }
|
|
4569
|
+
agentsById[name] = rec;
|
|
4570
|
+
}
|
|
4571
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
4572
|
+
const router = await import('./mas/mention_router.mjs');
|
|
4573
|
+
// The runner needs a real api key for the agent's provider. We
|
|
4574
|
+
// resolve the LEAD's key here on the assumption that all team
|
|
4575
|
+
// members share a provider (Phase 13 simplification); future
|
|
4576
|
+
// phases will resolve per-agent.
|
|
4577
|
+
const cfg = readConfig();
|
|
4578
|
+
const leadAgent = agentsById[team.lead];
|
|
4579
|
+
const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
|
|
4580
|
+
// Per-provider base-url override. Mostly useful for tests that
|
|
4581
|
+
// point the adapter at a local mock; production users get the
|
|
4582
|
+
// built-in default by leaving these unset.
|
|
4583
|
+
const baseUrl = {
|
|
4584
|
+
anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
|
|
4585
|
+
openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
|
|
4586
|
+
gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
|
|
4587
|
+
}[leadAgent.provider] || undefined;
|
|
4588
|
+
try {
|
|
4589
|
+
const result = await router.runTaskTurn({
|
|
4590
|
+
task, team, agentsById,
|
|
4591
|
+
userMessage: userMsg || undefined,
|
|
4592
|
+
configDir: cfgDir,
|
|
4593
|
+
apiKey,
|
|
4594
|
+
baseUrl,
|
|
4595
|
+
logger: (line) => process.stderr.write(line),
|
|
4596
|
+
maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
|
|
4597
|
+
});
|
|
4598
|
+
emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
|
|
4599
|
+
} catch (err) {
|
|
4600
|
+
console.error(`task tick: ${err?.message || err}`);
|
|
4601
|
+
process.exit(2);
|
|
4602
|
+
}
|
|
4603
|
+
return;
|
|
4604
|
+
}
|
|
4605
|
+
case 'abandon':
|
|
4606
|
+
case 'done': {
|
|
4607
|
+
if (!idOrFirst) { console.error(`Usage: lazyclaw task ${sub} <id>`); process.exit(2); }
|
|
4608
|
+
const target = sub === 'done' ? 'done' : 'abandoned';
|
|
4609
|
+
try {
|
|
4610
|
+
const next = tasksMod.patchTask(idOrFirst, { status: target }, cfgDir);
|
|
4611
|
+
// Best-effort closing post in the original thread so anyone in
|
|
4612
|
+
// the channel sees the resolution. Errors are surfaced via stderr
|
|
4613
|
+
// but do NOT roll back the status change.
|
|
4614
|
+
if (next.slackChannel && next.slackThreadTs) {
|
|
4615
|
+
try {
|
|
4616
|
+
_loadDotenvIfAny(cfgDir);
|
|
4617
|
+
const { SlackChannel } = await import('./channels/slack.mjs');
|
|
4618
|
+
const slack = new SlackChannel({ requireInbound: false });
|
|
4619
|
+
await slack.start(async () => '', {});
|
|
4620
|
+
const threadId = `${next.slackChannel}:${next.slackThreadTs}`;
|
|
4621
|
+
const msg = target === 'done'
|
|
4622
|
+
? `:white_check_mark: Task *${next.title}* marked done.`
|
|
4623
|
+
: `:no_entry: Task *${next.title}* abandoned.`;
|
|
4624
|
+
await slack.send(threadId, msg);
|
|
4625
|
+
await slack.stop().catch(() => {});
|
|
4626
|
+
} catch (err) {
|
|
4627
|
+
process.stderr.write(`[task] closing post failed: ${err?.message || err}\n`);
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
emitJson(next);
|
|
4631
|
+
} catch (err) {
|
|
4632
|
+
console.error(`task ${sub}: ${err?.message || err}`);
|
|
4633
|
+
process.exit(2);
|
|
4634
|
+
}
|
|
4635
|
+
return;
|
|
4636
|
+
}
|
|
4637
|
+
case 'transcript': {
|
|
4638
|
+
if (!idOrFirst) { console.error('Usage: lazyclaw task transcript <id> [--format text|md|json]'); process.exit(2); }
|
|
4639
|
+
const t = tasksMod.getTask(idOrFirst, cfgDir);
|
|
4640
|
+
if (!t) { console.error(`task transcript: no task "${idOrFirst}"`); process.exit(2); }
|
|
4641
|
+
const fmt = String(flags.format || 'text');
|
|
4642
|
+
if (fmt === 'json') { emitJson(t); return; }
|
|
4643
|
+
process.stdout.write(tasksMod.formatTranscript(t, fmt));
|
|
4644
|
+
return;
|
|
4645
|
+
}
|
|
4646
|
+
case 'remove':
|
|
4647
|
+
case 'rm':
|
|
4648
|
+
case 'delete': {
|
|
4649
|
+
if (!idOrFirst) { console.error('Usage: lazyclaw task remove <id>'); process.exit(2); }
|
|
4650
|
+
try { emitJson(tasksMod.removeTask(idOrFirst, cfgDir)); }
|
|
4651
|
+
catch (err) { console.error(`task remove: ${err?.message || err}`); process.exit(2); }
|
|
4652
|
+
return;
|
|
4653
|
+
}
|
|
4654
|
+
default:
|
|
4655
|
+
console.error('Usage: lazyclaw task <start|tick|list|show|transcript|abandon|done|remove> ...');
|
|
4656
|
+
process.exit(2);
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
|
|
4661
|
+
|
|
4662
|
+
async function cmdTeam(sub, positional, flags = {}) {
|
|
4663
|
+
const teamsMod = await import('./teams.mjs');
|
|
4664
|
+
const cfgDir = path.dirname(configPath());
|
|
4665
|
+
const name = positional[0];
|
|
4666
|
+
|
|
4667
|
+
const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
4668
|
+
const resolveChannel = async (raw) => {
|
|
4669
|
+
if (!raw) return '';
|
|
4670
|
+
// .env may have a SLACK_BOT_TOKEN we can use; otherwise pass through.
|
|
4671
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
4672
|
+
return await teamsMod.resolveSlackChannel(raw, {
|
|
4673
|
+
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
4674
|
+
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
4675
|
+
logger: (line) => process.stderr.write(line),
|
|
4676
|
+
});
|
|
4677
|
+
};
|
|
4678
|
+
|
|
4679
|
+
switch (sub) {
|
|
4680
|
+
case undefined:
|
|
4681
|
+
case 'list': {
|
|
4682
|
+
emitJson(teamsMod.listTeams(cfgDir));
|
|
4683
|
+
return;
|
|
4684
|
+
}
|
|
4685
|
+
case 'add': {
|
|
4686
|
+
if (!name) { console.error('Usage: lazyclaw team add <name> --agents a,b,c [--lead X] [--channel #shop|Cxxx] [--display "..."]'); process.exit(2); }
|
|
4687
|
+
const agents = teamsMod.parseListFlag(flags.agents) || [];
|
|
4688
|
+
try {
|
|
4689
|
+
const channel = await resolveChannel(flags.channel || '');
|
|
4690
|
+
const t = teamsMod.registerTeam({
|
|
4691
|
+
name,
|
|
4692
|
+
displayName: flags.display || flags['display-name'],
|
|
4693
|
+
agents,
|
|
4694
|
+
lead: flags.lead || null,
|
|
4695
|
+
slackChannel: channel,
|
|
4696
|
+
}, cfgDir);
|
|
4697
|
+
emitJson(t);
|
|
4698
|
+
} catch (err) {
|
|
4699
|
+
console.error(`team add: ${err?.message || err}`);
|
|
4700
|
+
process.exit(2);
|
|
4701
|
+
}
|
|
4702
|
+
return;
|
|
4703
|
+
}
|
|
4704
|
+
case 'show': {
|
|
4705
|
+
if (!name) { console.error('Usage: lazyclaw team show <name>'); process.exit(2); }
|
|
4706
|
+
const t = teamsMod.getTeam(name, cfgDir);
|
|
4707
|
+
if (!t) { console.error(`team show: no team "${name}"`); process.exit(2); }
|
|
4708
|
+
emitJson(t);
|
|
4709
|
+
return;
|
|
4710
|
+
}
|
|
4711
|
+
case 'edit': {
|
|
4712
|
+
if (!name) { console.error('Usage: lazyclaw team edit <name> [--agents a,b,c] [--lead X] [--channel ...] [--display "..."]'); process.exit(2); }
|
|
4713
|
+
const patch = {};
|
|
4714
|
+
if (flags.display !== undefined) patch.displayName = String(flags.display);
|
|
4715
|
+
if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
|
|
4716
|
+
if (flags.agents !== undefined) patch.agents = teamsMod.parseListFlag(flags.agents);
|
|
4717
|
+
if (flags.lead !== undefined) patch.lead = String(flags.lead);
|
|
4718
|
+
if (flags.channel !== undefined) patch.slackChannel = await resolveChannel(flags.channel);
|
|
4719
|
+
if (Object.keys(patch).length === 0) {
|
|
4720
|
+
console.error('team edit: no fields to update');
|
|
4721
|
+
process.exit(2);
|
|
4722
|
+
}
|
|
4723
|
+
try { emitJson(teamsMod.patchTeam(name, patch, cfgDir)); }
|
|
4724
|
+
catch (err) { console.error(`team edit: ${err?.message || err}`); process.exit(2); }
|
|
4725
|
+
return;
|
|
4726
|
+
}
|
|
4727
|
+
case 'remove':
|
|
4728
|
+
case 'rm':
|
|
4729
|
+
case 'delete': {
|
|
4730
|
+
if (!name) { console.error('Usage: lazyclaw team remove <name>'); process.exit(2); }
|
|
4731
|
+
try { emitJson(teamsMod.removeTeam(name, cfgDir)); }
|
|
4732
|
+
catch (err) { console.error(`team remove: ${err?.message || err}`); process.exit(2); }
|
|
4733
|
+
return;
|
|
4734
|
+
}
|
|
4735
|
+
default:
|
|
4736
|
+
console.error('Usage: lazyclaw team <add|list|show|edit|remove> ...');
|
|
4737
|
+
process.exit(2);
|
|
4738
|
+
}
|
|
4739
|
+
}
|
|
4740
|
+
|
|
4741
|
+
async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
4742
|
+
const agentsMod = await import('./agents.mjs');
|
|
4743
|
+
const cfgDir = path.dirname(configPath());
|
|
4744
|
+
const name = positional[0];
|
|
4745
|
+
|
|
4746
|
+
const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
4747
|
+
|
|
4748
|
+
switch (sub) {
|
|
4749
|
+
case undefined:
|
|
4750
|
+
case 'list': {
|
|
4751
|
+
emitJson(agentsMod.listAgents(cfgDir));
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
case 'add': {
|
|
4755
|
+
if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep] [--tags a,b]'); process.exit(2); }
|
|
4756
|
+
const tools = agentsMod.parseToolsFlag(flags.tools);
|
|
4757
|
+
try {
|
|
4758
|
+
const a = agentsMod.registerAgent({
|
|
4759
|
+
name,
|
|
4760
|
+
displayName: flags.display || flags['display-name'],
|
|
4761
|
+
role: flags.role || '',
|
|
4762
|
+
provider: flags.provider || 'claude-cli',
|
|
4763
|
+
model: flags.model || '',
|
|
4764
|
+
tools: tools === null ? undefined : tools,
|
|
4765
|
+
tags: agentsMod.parseToolsFlag(flags.tags) || [],
|
|
4766
|
+
}, cfgDir);
|
|
4767
|
+
emitJson(a);
|
|
4768
|
+
} catch (err) {
|
|
4769
|
+
console.error(`agent add: ${err?.message || err}`);
|
|
4770
|
+
process.exit(2);
|
|
4771
|
+
}
|
|
4772
|
+
return;
|
|
4773
|
+
}
|
|
4774
|
+
case 'show': {
|
|
4775
|
+
if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
|
|
4776
|
+
const a = agentsMod.getAgent(name, cfgDir);
|
|
4777
|
+
if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
|
|
4778
|
+
emitJson(a);
|
|
4779
|
+
return;
|
|
4780
|
+
}
|
|
4781
|
+
case 'edit': {
|
|
4782
|
+
if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...]'); process.exit(2); }
|
|
4783
|
+
const patch = {};
|
|
4784
|
+
if (flags.role !== undefined) patch.role = String(flags.role);
|
|
4785
|
+
if (flags.provider !== undefined) patch.provider = String(flags.provider);
|
|
4786
|
+
if (flags.model !== undefined) patch.model = String(flags.model);
|
|
4787
|
+
if (flags.display !== undefined) patch.displayName = String(flags.display);
|
|
4788
|
+
if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
|
|
4789
|
+
if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
|
|
4790
|
+
if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
|
|
4791
|
+
if (Object.keys(patch).length === 0) {
|
|
4792
|
+
console.error('agent edit: no fields to update');
|
|
4793
|
+
process.exit(2);
|
|
4794
|
+
}
|
|
4795
|
+
try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
|
|
4796
|
+
catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
|
|
4797
|
+
return;
|
|
4798
|
+
}
|
|
4799
|
+
case 'remove':
|
|
4800
|
+
case 'rm':
|
|
4801
|
+
case 'delete': {
|
|
4802
|
+
if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
|
|
4803
|
+
try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
|
|
4804
|
+
catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
|
|
4805
|
+
return;
|
|
4806
|
+
}
|
|
4807
|
+
case 'memory': {
|
|
4808
|
+
// memory <show|edit|clear> <name>
|
|
4809
|
+
const op = positional[0];
|
|
4810
|
+
const memName = positional[1];
|
|
4811
|
+
if (!op || !memName) {
|
|
4812
|
+
console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
|
|
4813
|
+
process.exit(2);
|
|
4814
|
+
}
|
|
4815
|
+
const memMod = await import('./mas/agent_memory.mjs');
|
|
4816
|
+
try {
|
|
4817
|
+
if (op === 'show') {
|
|
4818
|
+
const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
|
|
4819
|
+
const text = memMod.readMemory(memName, cfgDir, max);
|
|
4820
|
+
if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
|
|
4821
|
+
else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
|
|
4822
|
+
} else if (op === 'edit') {
|
|
4823
|
+
const p = memMod.memoryPath(memName, cfgDir);
|
|
4824
|
+
// Ensure file exists so $EDITOR doesn't start with a missing
|
|
4825
|
+
// file warning.
|
|
4826
|
+
if (!fs.existsSync(p)) {
|
|
4827
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
4828
|
+
fs.writeFileSync(p, `# ${memName} — memory\n\n`);
|
|
4829
|
+
}
|
|
4830
|
+
const editor = process.env.EDITOR || 'vi';
|
|
4831
|
+
const { spawn } = await import('node:child_process');
|
|
4832
|
+
await new Promise((resolve) => {
|
|
4833
|
+
const ch = spawn(editor, [p], { stdio: 'inherit' });
|
|
4834
|
+
ch.on('close', () => resolve());
|
|
4835
|
+
});
|
|
4836
|
+
process.stdout.write(`edited ${p}\n`);
|
|
4837
|
+
} else if (op === 'clear') {
|
|
4838
|
+
const removed = memMod.clear(memName, cfgDir);
|
|
4839
|
+
process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
|
|
4840
|
+
} else {
|
|
4841
|
+
console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
|
|
4842
|
+
process.exit(2);
|
|
4843
|
+
}
|
|
4844
|
+
} catch (err) {
|
|
4845
|
+
console.error(`agent memory ${op}: ${err?.message || err}`);
|
|
4846
|
+
process.exit(2);
|
|
4847
|
+
}
|
|
4848
|
+
return;
|
|
4849
|
+
}
|
|
4850
|
+
case 'reflect': {
|
|
4851
|
+
const aname = positional[0];
|
|
4852
|
+
const taskId = flags.task || positional[1];
|
|
4853
|
+
if (!aname || !taskId) {
|
|
4854
|
+
console.error('Usage: lazyclaw agent reflect <name> --task <id>');
|
|
4855
|
+
process.exit(2);
|
|
4856
|
+
}
|
|
4857
|
+
const tasksMod = await import('./tasks.mjs');
|
|
4858
|
+
const memMod = await import('./mas/agent_memory.mjs');
|
|
4859
|
+
const a = agentsMod.getAgent(aname, cfgDir);
|
|
4860
|
+
if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
|
|
4861
|
+
const task = tasksMod.getTask(taskId, cfgDir);
|
|
4862
|
+
if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
|
|
4863
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
4864
|
+
const cfg = readConfig();
|
|
4865
|
+
const apiKey = _resolveAuthKey(cfg, a.provider);
|
|
4866
|
+
const baseUrl = {
|
|
4867
|
+
anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
|
|
4868
|
+
openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
|
|
4869
|
+
gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
|
|
4870
|
+
}[a.provider] || undefined;
|
|
4871
|
+
try {
|
|
4872
|
+
const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
|
|
4873
|
+
if (!body || !body.trim()) {
|
|
4874
|
+
process.stderr.write('reflection returned empty body — nothing to write\n');
|
|
4875
|
+
return;
|
|
4876
|
+
}
|
|
4877
|
+
if (!flags['dry-run']) {
|
|
4878
|
+
memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
|
|
4879
|
+
}
|
|
4880
|
+
process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
|
|
4881
|
+
} catch (err) {
|
|
4882
|
+
console.error(`agent reflect: ${err?.message || err}`);
|
|
4883
|
+
process.exit(2);
|
|
4884
|
+
}
|
|
4885
|
+
return;
|
|
4886
|
+
}
|
|
4887
|
+
default:
|
|
4888
|
+
console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect> ...');
|
|
4889
|
+
process.exit(2);
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
// Best-effort .env loader for ~/.lazyclaw/.env. Only sets keys that are
|
|
4894
|
+
// not already present in process.env (so a shell-level export wins).
|
|
4895
|
+
// Lines starting with '#' are comments; values are taken verbatim and
|
|
4896
|
+
// stripped of surrounding double-quotes if present.
|
|
4897
|
+
function _loadDotenvIfAny(cfgDir) {
|
|
4898
|
+
const p = path.join(cfgDir, '.env');
|
|
4899
|
+
if (!fs.existsSync(p)) return { path: p, loaded: 0 };
|
|
4900
|
+
let loaded = 0;
|
|
4901
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
4902
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
4903
|
+
if (!line || line.trimStart().startsWith('#')) continue;
|
|
4904
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
|
|
4905
|
+
if (!m) continue;
|
|
4906
|
+
let val = m[2].trim();
|
|
4907
|
+
if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
|
|
4908
|
+
if (process.env[m[1]] === undefined) { process.env[m[1]] = val; loaded++; }
|
|
4909
|
+
}
|
|
4910
|
+
return { path: p, loaded };
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
async function cmdSlack(sub, positional, flags = {}) {
|
|
4914
|
+
if (sub !== 'listen') {
|
|
4915
|
+
console.error('Usage: lazyclaw slack listen [--provider X] [--model Y]');
|
|
4916
|
+
process.exit(2);
|
|
4917
|
+
}
|
|
4918
|
+
await ensureRegistry();
|
|
4919
|
+
const cfg = readConfig();
|
|
4920
|
+
const cfgDir = path.dirname(configPath());
|
|
4921
|
+
|
|
4922
|
+
const envInfo = _loadDotenvIfAny(cfgDir);
|
|
4923
|
+
process.stderr.write(`[slack] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
|
|
4924
|
+
|
|
4925
|
+
const provName = flags.provider || cfg.provider || 'mock';
|
|
4926
|
+
const prov = _registryMod.PROVIDERS[provName];
|
|
4927
|
+
if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
|
|
4928
|
+
const model = flags.model || cfg.model;
|
|
4929
|
+
|
|
4930
|
+
// Per-thread rolling chat history so multi-turn coherence works
|
|
4931
|
+
// without committing to on-disk sessions. Capped at MAX_TURNS to
|
|
4932
|
+
// bound the prompt size.
|
|
4933
|
+
const threadMsgs = new Map();
|
|
4934
|
+
const MAX_TURNS = 20;
|
|
4935
|
+
|
|
4936
|
+
const handler = async ({ threadId, text }) => {
|
|
4937
|
+
const cleaned = String(text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
|
|
4938
|
+
if (!cleaned) return '(empty message)';
|
|
4939
|
+
const msgs = threadMsgs.get(threadId) || [];
|
|
4940
|
+
msgs.push({ role: 'user', content: cleaned });
|
|
4941
|
+
let acc = '';
|
|
4942
|
+
try {
|
|
4943
|
+
for await (const chunk of prov.sendMessage(msgs, {
|
|
4944
|
+
apiKey: _resolveAuthKey(cfg, provName),
|
|
4945
|
+
model,
|
|
4946
|
+
})) acc += chunk;
|
|
4947
|
+
} catch (err) {
|
|
4948
|
+
msgs.pop();
|
|
4949
|
+
const why = err?.message || String(err);
|
|
4950
|
+
process.stderr.write(`[slack] provider error: ${why}\n`);
|
|
4951
|
+
return `(provider error: ${why})`;
|
|
4952
|
+
}
|
|
4953
|
+
msgs.push({ role: 'assistant', content: acc });
|
|
4954
|
+
if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
|
|
4955
|
+
threadMsgs.set(threadId, msgs);
|
|
4956
|
+
return acc || '(empty reply)';
|
|
4957
|
+
};
|
|
4958
|
+
|
|
4959
|
+
const { SlackChannel } = await import('./channels/slack.mjs');
|
|
4960
|
+
const ch = new SlackChannel();
|
|
4961
|
+
process.stderr.write(`[slack] provider=${provName} model=${model || '(default)'}\n`);
|
|
4962
|
+
try {
|
|
4963
|
+
await ch.start(handler);
|
|
4964
|
+
await ch._connectSocketMode({ logger: (line) => process.stderr.write(line) });
|
|
4965
|
+
} catch (err) {
|
|
4966
|
+
if (err?.code === 'SLACK_MISSING_ENV') {
|
|
4967
|
+
console.error(`slack: missing env vars: ${(err.missing || []).join(', ')}`);
|
|
4968
|
+
console.error(`hint: set them in ${path.join(cfgDir, '.env')} (uncomment SLACK_APP_TOKEN / SLACK_SIGNING_SECRET)`);
|
|
4969
|
+
} else {
|
|
4970
|
+
console.error(`slack: ${err?.message || err}`);
|
|
4971
|
+
}
|
|
4972
|
+
process.exit(2);
|
|
4973
|
+
}
|
|
4974
|
+
process.stderr.write(`[slack] listening. Ctrl-C to stop.\n`);
|
|
4975
|
+
|
|
4976
|
+
await new Promise((resolve) => {
|
|
4977
|
+
const onSig = async () => {
|
|
4978
|
+
process.stderr.write(`\n[slack] shutting down…\n`);
|
|
4979
|
+
try { await ch.stop(); } catch { /* best-effort */ }
|
|
4980
|
+
resolve();
|
|
4981
|
+
};
|
|
4982
|
+
process.once('SIGINT', onSig);
|
|
4983
|
+
process.once('SIGTERM', onSig);
|
|
4984
|
+
});
|
|
4985
|
+
}
|
|
4986
|
+
|
|
3485
4987
|
async function cmdSkills(sub, positional, flags = {}) {
|
|
3486
4988
|
const skillsMod = await import('./skills.mjs');
|
|
3487
4989
|
const cfgDir = path.dirname(configPath());
|
|
@@ -4275,6 +5777,9 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
4275
5777
|
'with-turn-count', // sessions list: include turn count per session
|
|
4276
5778
|
'no-probe', // providers add: skip the /v1/models reachability probe
|
|
4277
5779
|
'pick', // onboard / chat: force the interactive picker even when provider already set
|
|
5780
|
+
'detach', // loop: fork worker and return immediately
|
|
5781
|
+
'use-memory', // loop: prepend core memory to each iteration
|
|
5782
|
+
'force', // goal tick --force: bypass schedule when invoked manually
|
|
4278
5783
|
]);
|
|
4279
5784
|
|
|
4280
5785
|
function parseArgs(argv) {
|
|
@@ -4530,7 +6035,10 @@ async function _dispatchMenuChoice(argv) {
|
|
|
4530
6035
|
try {
|
|
4531
6036
|
switch (sub) {
|
|
4532
6037
|
case 'chat': return await cmdChat({});
|
|
4533
|
-
case 'agent':
|
|
6038
|
+
case 'agent': {
|
|
6039
|
+
if (AGENT_REG_SUBS.has(rest[0])) return await cmdAgentRegistry(rest[0], rest.slice(1), {});
|
|
6040
|
+
return await cmdAgent(rest[0] || '-', {});
|
|
6041
|
+
}
|
|
4534
6042
|
case 'onboard': return await cmdOnboard({});
|
|
4535
6043
|
case 'setup': return await cmdSetup(undefined, rest, {});
|
|
4536
6044
|
case 'workspace': return await cmdWorkspace(rest[0], rest.slice(1), {});
|
|
@@ -4539,6 +6047,13 @@ async function _dispatchMenuChoice(argv) {
|
|
|
4539
6047
|
case 'sessions': return await cmdSessions(rest[0], rest.slice(1), {});
|
|
4540
6048
|
case 'providers': return await cmdProviders(rest[0], rest.slice(1), {});
|
|
4541
6049
|
case 'cron': return await cmdCron(rest[0], rest.slice(1), {});
|
|
6050
|
+
case 'loop': return await cmdLoop(rest[0] || '', {});
|
|
6051
|
+
case 'loops': return await cmdLoops(rest[0], rest.slice(1), {});
|
|
6052
|
+
case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
|
|
6053
|
+
case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
|
|
6054
|
+
case 'slack': return await cmdSlack(rest[0], rest.slice(1), {});
|
|
6055
|
+
case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
|
|
6056
|
+
case 'task': return await cmdTask(rest[0], rest.slice(1), {});
|
|
4542
6057
|
case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
|
|
4543
6058
|
case 'pairing': return await cmdPairing(rest[0], rest.slice(1), {});
|
|
4544
6059
|
case 'nodes': return await cmdNodes(rest[0], rest.slice(1), {});
|
|
@@ -4803,8 +6318,13 @@ async function cmdLauncher() {
|
|
|
4803
6318
|
});
|
|
4804
6319
|
|
|
4805
6320
|
if (!picked || picked.id === 'quit' || !picked.argv) {
|
|
4806
|
-
//
|
|
4807
|
-
|
|
6321
|
+
// v3.99.28 — break out of the while loop, fall through the
|
|
6322
|
+
// finally (stdin cleanup), then hit the explicit process.exit(0)
|
|
6323
|
+
// at the function tail. Previously this was `return`, which
|
|
6324
|
+
// jumped over the explicit exit and left dangling timers /
|
|
6325
|
+
// sockets (ollama probe, registry retry, etc.) keeping the
|
|
6326
|
+
// event loop alive — visible to the user as "Quit didn't quit."
|
|
6327
|
+
break;
|
|
4808
6328
|
}
|
|
4809
6329
|
|
|
4810
6330
|
// Two menu items need a follow-up question before they can run:
|
|
@@ -5058,6 +6578,41 @@ async function main() {
|
|
|
5058
6578
|
await cmdCron(sub, rest.positional.slice(1), rest.flags);
|
|
5059
6579
|
break;
|
|
5060
6580
|
}
|
|
6581
|
+
case 'loop': {
|
|
6582
|
+
const prompt = rest.positional[0];
|
|
6583
|
+
await cmdLoop(prompt, rest.flags);
|
|
6584
|
+
break;
|
|
6585
|
+
}
|
|
6586
|
+
case 'loops': {
|
|
6587
|
+
const sub = rest.positional[0];
|
|
6588
|
+
await cmdLoops(sub, rest.positional.slice(1), rest.flags);
|
|
6589
|
+
break;
|
|
6590
|
+
}
|
|
6591
|
+
case 'goal': {
|
|
6592
|
+
const sub = rest.positional[0];
|
|
6593
|
+
await cmdGoal(sub, rest.positional.slice(1), rest.flags);
|
|
6594
|
+
break;
|
|
6595
|
+
}
|
|
6596
|
+
case 'memory': {
|
|
6597
|
+
const sub = rest.positional[0];
|
|
6598
|
+
await cmdMemory(sub, rest.positional.slice(1), rest.flags);
|
|
6599
|
+
break;
|
|
6600
|
+
}
|
|
6601
|
+
case 'slack': {
|
|
6602
|
+
const sub = rest.positional[0];
|
|
6603
|
+
await cmdSlack(sub, rest.positional.slice(1), rest.flags);
|
|
6604
|
+
break;
|
|
6605
|
+
}
|
|
6606
|
+
case 'team': {
|
|
6607
|
+
const sub = rest.positional[0];
|
|
6608
|
+
await cmdTeam(sub, rest.positional.slice(1), rest.flags);
|
|
6609
|
+
break;
|
|
6610
|
+
}
|
|
6611
|
+
case 'task': {
|
|
6612
|
+
const sub = rest.positional[0];
|
|
6613
|
+
await cmdTask(sub, rest.positional.slice(1), rest.flags);
|
|
6614
|
+
break;
|
|
6615
|
+
}
|
|
5061
6616
|
case 'setup': {
|
|
5062
6617
|
await cmdSetup(undefined, rest.positional, rest.flags);
|
|
5063
6618
|
break;
|
|
@@ -5071,8 +6626,12 @@ async function main() {
|
|
|
5071
6626
|
break;
|
|
5072
6627
|
}
|
|
5073
6628
|
case 'agent': {
|
|
5074
|
-
const
|
|
5075
|
-
|
|
6629
|
+
const first = rest.positional[0];
|
|
6630
|
+
if (AGENT_REG_SUBS.has(first)) {
|
|
6631
|
+
await cmdAgentRegistry(first, rest.positional.slice(1), rest.flags);
|
|
6632
|
+
} else {
|
|
6633
|
+
await cmdAgent(first, rest.flags);
|
|
6634
|
+
}
|
|
5076
6635
|
break;
|
|
5077
6636
|
}
|
|
5078
6637
|
case 'doctor': {
|