lazyclaw 4.2.0 → 4.2.2
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/channels/slack.mjs +94 -15
- package/cli.mjs +70 -125
- package/mas/agent_memory.mjs +4 -3
- package/mas/agent_turn.mjs +9 -3
- package/package.json +1 -1
- package/providers/tool_use/claude_cli.mjs +215 -0
package/channels/slack.mjs
CHANGED
|
@@ -31,6 +31,32 @@ export class SlackError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Decide whether a Socket Mode event should drive a handler call.
|
|
35
|
+
// Pulled out of dispatchEvent so we can unit-test the filter without
|
|
36
|
+
// standing up a WebSocket. Returns false (= skip) for:
|
|
37
|
+
// - non-objects / null
|
|
38
|
+
// - the bot's own message in any of the four wire shapes Slack uses
|
|
39
|
+
// (legacy bot_id, legacy bot_message subtype, modern bot_profile.id,
|
|
40
|
+
// or the message.user field matching our cached auth.test user_id —
|
|
41
|
+
// this last one is the chat:write.customize trap that caused the
|
|
42
|
+
// v4.2.0 listener-loop)
|
|
43
|
+
// - non-message-shaped events (we only handle `message` and
|
|
44
|
+
// `app_mention`)
|
|
45
|
+
// - empty / whitespace-only text bodies (defense in depth so a
|
|
46
|
+
// stray blocks-only post can't loop us back into "(empty message)"
|
|
47
|
+
// fallback)
|
|
48
|
+
export function shouldDispatchEvent(event, { selfUserId = null, selfBotId = null } = {}) {
|
|
49
|
+
if (!event || typeof event !== 'object') return false;
|
|
50
|
+
if (event.bot_id || event.subtype === 'bot_message') return false;
|
|
51
|
+
if (selfUserId && event.user === selfUserId) return false;
|
|
52
|
+
if (selfBotId && event.bot_id === selfBotId) return false;
|
|
53
|
+
if (selfBotId && event.bot_profile && event.bot_profile.id === selfBotId) return false;
|
|
54
|
+
if (event.type !== 'app_mention' && event.type !== 'message') return false;
|
|
55
|
+
const text = typeof event.text === 'string' ? event.text : '';
|
|
56
|
+
if (text.trim() === '') return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
export function readSlackEnv(env = process.env) {
|
|
35
61
|
const out = {
|
|
36
62
|
botToken: env.SLACK_BOT_TOKEN || null,
|
|
@@ -81,7 +107,11 @@ export class SlackChannel extends Channel {
|
|
|
81
107
|
|
|
82
108
|
// Called by Socket Mode wiring (or tests) for every inbound message
|
|
83
109
|
// routed to this app. The handler returns the bot's reply; the
|
|
84
|
-
// adapter posts it back to Slack in the same thread.
|
|
110
|
+
// adapter posts it back to Slack in the same thread. A null /
|
|
111
|
+
// empty-string reply skips the send entirely (Phase 19.2) so a
|
|
112
|
+
// handler that decided to stay silent — e.g. the listener dropping
|
|
113
|
+
// an empty-after-mention-strip inbound — doesn't leak a "(empty
|
|
114
|
+
// reply)" placeholder into the channel.
|
|
85
115
|
async _simulateInbound(text, threadId) {
|
|
86
116
|
let reply;
|
|
87
117
|
try {
|
|
@@ -94,6 +124,7 @@ export class SlackChannel extends Channel {
|
|
|
94
124
|
await this.send(threadId, `(error: ${err?.message || err})`);
|
|
95
125
|
return;
|
|
96
126
|
}
|
|
127
|
+
if (reply == null || (typeof reply === 'string' && reply.trim() === '')) return;
|
|
97
128
|
await this.send(threadId, reply);
|
|
98
129
|
}
|
|
99
130
|
|
|
@@ -187,6 +218,37 @@ export class SlackChannel extends Channel {
|
|
|
187
218
|
return json.url;
|
|
188
219
|
};
|
|
189
220
|
|
|
221
|
+
// Resolve our own identity so dispatchEvent can refuse loops where
|
|
222
|
+
// the router posted a message with chat:write.customize (Slack
|
|
223
|
+
// strips bot_id / subtype from those events, so the original
|
|
224
|
+
// filter missed them and we replied to ourselves). Best-effort —
|
|
225
|
+
// a failed auth.test leaves the filter relying on the legacy
|
|
226
|
+
// bot_id / subtype check; better than refusing to start.
|
|
227
|
+
let selfUserId = null;
|
|
228
|
+
let selfBotId = null;
|
|
229
|
+
try {
|
|
230
|
+
const authUrl = `${apiBase}/auth.test`;
|
|
231
|
+
const r = await fetch(authUrl, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: {
|
|
234
|
+
'Authorization': `Bearer ${this._env.botToken}`,
|
|
235
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
if (r.ok) {
|
|
239
|
+
const j = await r.json().catch(() => ({}));
|
|
240
|
+
if (j && j.ok) {
|
|
241
|
+
selfUserId = j.user_id || null;
|
|
242
|
+
selfBotId = j.bot_id || null;
|
|
243
|
+
logger(`[slack] auth.test OK — self user=${selfUserId} bot=${selfBotId}\n`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
logger(`[slack] auth.test failed: ${err?.message || err}\n`);
|
|
248
|
+
}
|
|
249
|
+
this._selfUserId = selfUserId;
|
|
250
|
+
this._selfBotId = selfBotId;
|
|
251
|
+
|
|
190
252
|
// (channel, ts) dedupe — a single user message can fire both
|
|
191
253
|
// `message` and `app_mention` events in the same channel. Both
|
|
192
254
|
// arrive as separate Socket Mode envelopes (different envelope_id),
|
|
@@ -209,10 +271,15 @@ export class SlackChannel extends Channel {
|
|
|
209
271
|
};
|
|
210
272
|
|
|
211
273
|
const dispatchEvent = async (event) => {
|
|
212
|
-
if (!event
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
274
|
+
if (!shouldDispatchEvent(event, { selfUserId: this._selfUserId, selfBotId: this._selfBotId })) {
|
|
275
|
+
// shouldDispatchEvent already encodes the "skip the bot's own
|
|
276
|
+
// chat:write.customize message" trap that caused the v4.2.0
|
|
277
|
+
// listener loop. Bail before the reaction / handler call.
|
|
278
|
+
if (event && (event.type === 'message' || event.type === 'app_mention')) {
|
|
279
|
+
logger(`[slack] skipping ${event.type} from ${event.channel || '?'}:${event.ts || '?'}\n`);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
216
283
|
// For DMs (`im`) channel_type is 'im'; for channel mentions we only
|
|
217
284
|
// get app_mention events. Either way we have channel + ts.
|
|
218
285
|
const text = typeof event.text === 'string' ? event.text : '';
|
|
@@ -227,16 +294,10 @@ export class SlackChannel extends Channel {
|
|
|
227
294
|
const threadId = `${channel}:${replyTs}`;
|
|
228
295
|
logger(`[slack] inbound ${event.type} from ${channel} (${text.length} chars)\n`);
|
|
229
296
|
|
|
230
|
-
// Immediate acknowledgement
|
|
231
|
-
//
|
|
232
|
-
// spam)
|
|
233
|
-
|
|
234
|
-
const eyesOk = await this._reaction('add', channel, sourceTs, 'eyes');
|
|
235
|
-
if (!eyesOk) {
|
|
236
|
-
logger(`[slack] reactions:write missing — falling back to text ack\n`);
|
|
237
|
-
try { await this.send(threadId, '_확인해보겠습니다…_'); }
|
|
238
|
-
catch (err) { logger(`[slack] text ack failed: ${err?.message || err}\n`); }
|
|
239
|
-
}
|
|
297
|
+
// Immediate acknowledgement. _ackInbound is silent when
|
|
298
|
+
// reactions:write is missing (Phase 19.2 — no more text-ack
|
|
299
|
+
// spam).
|
|
300
|
+
const eyesOk = await this._ackInbound(channel, sourceTs, logger);
|
|
240
301
|
|
|
241
302
|
try {
|
|
242
303
|
await this._simulateInbound(text, threadId);
|
|
@@ -329,6 +390,24 @@ export class SlackChannel extends Channel {
|
|
|
329
390
|
return this._socketHandle;
|
|
330
391
|
}
|
|
331
392
|
|
|
393
|
+
// Mark an inbound message as "being worked on" without spamming the
|
|
394
|
+
// channel. Tries the :eyes: reaction first (silent UX). When
|
|
395
|
+
// reactions:write is missing we used to fall back to a text post
|
|
396
|
+
// ("확인해보겠습니다…") which doubled the noise per turn; Phase 19.2
|
|
397
|
+
// dropped that fallback so the channel stays clean when the scope
|
|
398
|
+
// is unavailable. The operator can flip reactions:write on at any
|
|
399
|
+
// time to bring the visible signal back.
|
|
400
|
+
//
|
|
401
|
+
// Exposed as a method (not closure-private in dispatchEvent) so the
|
|
402
|
+
// listener-noise unit tests can drive it directly.
|
|
403
|
+
async _ackInbound(channel, sourceTs, logger = () => {}) {
|
|
404
|
+
const eyesOk = await this._reaction('add', channel, sourceTs, 'eyes');
|
|
405
|
+
if (!eyesOk) {
|
|
406
|
+
logger('[slack] reactions:write missing — silent ack only (no text fallback)\n');
|
|
407
|
+
}
|
|
408
|
+
return eyesOk;
|
|
409
|
+
}
|
|
410
|
+
|
|
332
411
|
// Best-effort chat.delete — used by typing-indicator workflows where
|
|
333
412
|
// we post a placeholder and want to clean it up. Returns true on
|
|
334
413
|
// success, silent false otherwise.
|
package/cli.mjs
CHANGED
|
@@ -1484,145 +1484,78 @@ function _attachGhostAutocomplete(rl) {
|
|
|
1484
1484
|
// Width-management rule: every inner line is forced through
|
|
1485
1485
|
// `.padEnd(W)` so a stray width miscount can't punch the right
|
|
1486
1486
|
// border off the box (which is exactly the bug v3.99.5 shipped:
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
//
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
' │ │ ',
|
|
1505
|
-
' │ │ ',
|
|
1506
|
-
' ╰───────────╯ ',
|
|
1507
|
-
' ╭───────╮ ',
|
|
1508
|
-
' │ ● ● │ ',
|
|
1509
|
-
' │ ─── │ ',
|
|
1510
|
-
' ╰───────╯ ',
|
|
1511
|
-
' ┃ ┃ ',
|
|
1512
|
-
' ┗┛ ┗┛ ',
|
|
1513
|
-
],
|
|
1514
|
-
working: [
|
|
1515
|
-
' ° ╷ ╷ ° ',
|
|
1516
|
-
' ● ● ',
|
|
1517
|
-
' ╭───────────╮ ',
|
|
1518
|
-
' ╱╲│ │╱╲ ',
|
|
1519
|
-
' │ │ ',
|
|
1520
|
-
' ╰───────────╯ ',
|
|
1521
|
-
' ╭───────╮ ',
|
|
1522
|
-
' │ ─ ─ │ ',
|
|
1523
|
-
' │ ═══ │ ',
|
|
1524
|
-
' ╰───────╯ ',
|
|
1525
|
-
' ┃ ┃ ',
|
|
1526
|
-
' ┗┛ ┗┛ ',
|
|
1527
|
-
],
|
|
1528
|
-
done: [
|
|
1529
|
-
' ✦ ╷ ╷ ✦ ',
|
|
1530
|
-
' ● ● ',
|
|
1531
|
-
' ╭───────────╮ ',
|
|
1532
|
-
' │ │ ',
|
|
1533
|
-
' │ │ ',
|
|
1534
|
-
' ╰───────────╯ ',
|
|
1535
|
-
' ╭───────╮ ',
|
|
1536
|
-
' │ ^ ^ │ ',
|
|
1537
|
-
' │ ‿‿‿ │ ',
|
|
1538
|
-
' ╰───────╯ ',
|
|
1539
|
-
' ┃ ┃ ',
|
|
1540
|
-
' ┗┛ ┗┛ ',
|
|
1541
|
-
],
|
|
1542
|
-
error: [
|
|
1543
|
-
' \\ ╷ ╷ / ',
|
|
1544
|
-
' ! ● ● ! ',
|
|
1545
|
-
' ╭───────────╮ ',
|
|
1546
|
-
' │ │ ',
|
|
1547
|
-
' │ │ ',
|
|
1548
|
-
' ╰───────────╯ ',
|
|
1549
|
-
' ╭───────╮ ',
|
|
1550
|
-
' │ O O │ ',
|
|
1551
|
-
' │ ╭─╮ │ ',
|
|
1552
|
-
' ╰───────╯ ',
|
|
1553
|
-
' ┃ ┃ ',
|
|
1554
|
-
' ┗┛ ┗┛ ',
|
|
1555
|
-
],
|
|
1556
|
-
};
|
|
1557
|
-
const _MASCOT_TINY = {
|
|
1558
|
-
idle: ' ● ● \n(│ ─ │)\n ┃ ┃ ',
|
|
1559
|
-
working: ' ● ● \n(│ ═ │)°\n ┃ ┃ ',
|
|
1560
|
-
done: ' ^ ^ \n(│ ‿ │)✦\n ┃ ┃ ',
|
|
1561
|
-
error: ' O O \n(│╭╮│)!\n ┃ ┃ ',
|
|
1562
|
-
};
|
|
1487
|
+
// v4.2.2 — boxed figlet "lazy" wordmark, single-colour orange. The
|
|
1488
|
+
// previous mixed-colour banner (helmet-red letter art + ink-beige
|
|
1489
|
+
// caption) read as "two banners glued together" because the colour
|
|
1490
|
+
// changed mid-box. We use one warm orange (#F08246) for everything —
|
|
1491
|
+
// border, letter art, caption — so the eye reads it as one badge.
|
|
1492
|
+
//
|
|
1493
|
+
// Letter art is figlet "standard" (6 rows) rather than the v3.99.11
|
|
1494
|
+
// "small" (4 rows), because small renders as a pixel mush in most
|
|
1495
|
+
// terminal fonts. Standard's strokes are wide enough that the
|
|
1496
|
+
// letters read as `l a z y` even at small terminal sizes.
|
|
1497
|
+
//
|
|
1498
|
+
// Layout invariant: every inner row is exactly INNER_W visible cells
|
|
1499
|
+
// (no double-width glyphs, all chars are 1 cell in any monospace
|
|
1500
|
+
// font), so the right edge `│` always lands in the same column.
|
|
1501
|
+
//
|
|
1502
|
+
// _renderMascot / _renderMascotTiny are kept as stubs so any leftover
|
|
1503
|
+
// caller doesn't crash; no state-coloured art is produced any more.
|
|
1563
1504
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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.
|
|
1575
|
-
function _renderMascot(state) {
|
|
1576
|
-
const rows = _MASCOT_BIG[state] || _MASCOT_BIG.idle;
|
|
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
|
-
});
|
|
1505
|
+
const _ORANGE_RGB = '241;130;70'; // #F18246
|
|
1506
|
+
function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
|
|
1507
|
+
|
|
1508
|
+
function _renderMascot() {
|
|
1509
|
+
return ['lazyclaw'];
|
|
1582
1510
|
}
|
|
1583
1511
|
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
// Returns a string; callers add their own newline. Inked crab-red so
|
|
1587
|
-
// it stays on-brand wherever it's spliced in.
|
|
1588
|
-
function _renderMascotTiny(state) {
|
|
1589
|
-
return _MC_RED(_MASCOT_TINY[state] || _MASCOT_TINY.idle);
|
|
1512
|
+
function _renderMascotTiny() {
|
|
1513
|
+
return 'lazyclaw';
|
|
1590
1514
|
}
|
|
1591
1515
|
|
|
1516
|
+
// figlet "standard" "lazy", trimmed of leading blank line. Each row
|
|
1517
|
+
// is left-padded by two spaces inside the box, and every row is then
|
|
1518
|
+
// padded to INNER_W cells.
|
|
1519
|
+
const _LAZY_STANDARD = [
|
|
1520
|
+
' _ ',
|
|
1521
|
+
'| | __ _ _____ _ ',
|
|
1522
|
+
'| |/ _` |_ / | | | ',
|
|
1523
|
+
'| | (_| |/ /| |_| | ',
|
|
1524
|
+
'|_|\\__,_/___|\\__, | ',
|
|
1525
|
+
' |___/ ',
|
|
1526
|
+
];
|
|
1527
|
+
|
|
1528
|
+
const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
|
|
1529
|
+
|
|
1592
1530
|
function _renderBanner(version) {
|
|
1593
|
-
const ink = (s) => `\x1b[38;2;241;234;217m${s}\x1b[0m`;
|
|
1594
|
-
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
1595
1531
|
const v = String(version || '?.?.?');
|
|
1596
|
-
const
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
'',
|
|
1607
|
-
'',
|
|
1608
|
-
'',
|
|
1609
|
-
'',
|
|
1532
|
+
const cap = ` LazyClaw v${v}`;
|
|
1533
|
+
const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
|
|
1534
|
+
const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
|
|
1535
|
+
const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
|
|
1536
|
+
const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
|
|
1537
|
+
return [
|
|
1538
|
+
top,
|
|
1539
|
+
..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
|
|
1540
|
+
wrap(padInner(cap)),
|
|
1541
|
+
bot,
|
|
1610
1542
|
];
|
|
1611
|
-
return left.map((l, i) => ' ' + l + (right[i] || ''));
|
|
1612
1543
|
}
|
|
1613
1544
|
|
|
1614
1545
|
function _printChatBanner(activeProvName, activeModel, version) {
|
|
1615
1546
|
if (!process.stdout.isTTY) return;
|
|
1616
|
-
|
|
1617
|
-
|
|
1547
|
+
// Single-hue header: labels dim-orange, values/emphasis full-orange, so the
|
|
1548
|
+
// four caption rows below the box read as part of the same warm badge.
|
|
1549
|
+
const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
|
|
1550
|
+
const orange = _orange;
|
|
1618
1551
|
const lines = [
|
|
1619
1552
|
'',
|
|
1620
1553
|
..._renderBanner(version),
|
|
1621
1554
|
'',
|
|
1622
|
-
` ${
|
|
1623
|
-
` ${
|
|
1624
|
-
` ${
|
|
1625
|
-
` ${
|
|
1555
|
+
` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
|
|
1556
|
+
` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
|
|
1557
|
+
` ${dimOrange('slash ·')} ${orange('/help · /model · /provider · /exit')}`,
|
|
1558
|
+
` ${dimOrange('hint ·')} ${orange('→')} ${dimOrange('to accept the suggested command,')} ${orange('Tab')} ${dimOrange('to cycle')}`,
|
|
1626
1559
|
'',
|
|
1627
1560
|
];
|
|
1628
1561
|
process.stdout.write(lines.join('\n') + '\n');
|
|
@@ -4935,7 +4868,15 @@ async function cmdSlack(sub, positional, flags = {}) {
|
|
|
4935
4868
|
|
|
4936
4869
|
const handler = async ({ threadId, text }) => {
|
|
4937
4870
|
const cleaned = String(text || '').replace(/<@[A-Z0-9]+>/g, '').trim();
|
|
4938
|
-
|
|
4871
|
+
// Phase 19.2: never post a placeholder ("(empty message)" / "(empty
|
|
4872
|
+
// reply)") into the thread — those leaked through as visible noise
|
|
4873
|
+
// when listener self-message echoes happened. Return null and let
|
|
4874
|
+
// _simulateInbound's guard drop the send. Real provider errors
|
|
4875
|
+
// still surface so the operator knows something went wrong.
|
|
4876
|
+
if (!cleaned) {
|
|
4877
|
+
process.stderr.write('[slack] dropping empty inbound (after mention strip)\n');
|
|
4878
|
+
return null;
|
|
4879
|
+
}
|
|
4939
4880
|
const msgs = threadMsgs.get(threadId) || [];
|
|
4940
4881
|
msgs.push({ role: 'user', content: cleaned });
|
|
4941
4882
|
let acc = '';
|
|
@@ -4953,7 +4894,11 @@ async function cmdSlack(sub, positional, flags = {}) {
|
|
|
4953
4894
|
msgs.push({ role: 'assistant', content: acc });
|
|
4954
4895
|
if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
|
|
4955
4896
|
threadMsgs.set(threadId, msgs);
|
|
4956
|
-
|
|
4897
|
+
if (!acc.trim()) {
|
|
4898
|
+
process.stderr.write('[slack] provider returned empty text — not posting\n');
|
|
4899
|
+
return null;
|
|
4900
|
+
}
|
|
4901
|
+
return acc;
|
|
4957
4902
|
};
|
|
4958
4903
|
|
|
4959
4904
|
const { SlackChannel } = await import('./channels/slack.mjs');
|
package/mas/agent_memory.mjs
CHANGED
|
@@ -179,9 +179,10 @@ export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, max
|
|
|
179
179
|
|
|
180
180
|
async function pickAdapter(provider) {
|
|
181
181
|
switch (provider) {
|
|
182
|
-
case 'anthropic':
|
|
183
|
-
case 'openai':
|
|
184
|
-
case 'gemini':
|
|
182
|
+
case 'anthropic': return await import('../providers/tool_use/anthropic.mjs');
|
|
183
|
+
case 'openai': return await import('../providers/tool_use/openai.mjs');
|
|
184
|
+
case 'gemini': return await import('../providers/tool_use/gemini.mjs');
|
|
185
|
+
case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
|
|
185
186
|
default:
|
|
186
187
|
throw new AgentMemoryError(`provider "${provider}" does not support reflection`, 'AGENT_MEMORY_NO_PROVIDER');
|
|
187
188
|
}
|
package/mas/agent_turn.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { listToolSchemas, runTool, ToolError } from './tool_runner.mjs';
|
|
|
24
24
|
import * as anthropic from '../providers/tool_use/anthropic.mjs';
|
|
25
25
|
import * as openai from '../providers/tool_use/openai.mjs';
|
|
26
26
|
import * as gemini from '../providers/tool_use/gemini.mjs';
|
|
27
|
+
import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
|
|
27
28
|
|
|
28
29
|
export class AgentTurnError extends Error {
|
|
29
30
|
constructor(message, code) {
|
|
@@ -37,9 +38,14 @@ const DEFAULT_MAX_ITERATIONS = 10;
|
|
|
37
38
|
|
|
38
39
|
function adapterFor(provider) {
|
|
39
40
|
switch (provider) {
|
|
40
|
-
case 'anthropic':
|
|
41
|
-
case 'openai':
|
|
42
|
-
case 'gemini':
|
|
41
|
+
case 'anthropic': return { ...anthropic, toolSchemas: anthropic.toAnthropicTools };
|
|
42
|
+
case 'openai': return { ...openai, toolSchemas: openai.toOpenAITools };
|
|
43
|
+
case 'gemini': return { ...gemini, toolSchemas: gemini.toGeminiTools };
|
|
44
|
+
// claude-cli runs the tool-use loop INSIDE the binary. Our adapter
|
|
45
|
+
// resolves every call to kind:'final' so the mention router still
|
|
46
|
+
// gets a normalised reply, even though no tool_calls envelope is
|
|
47
|
+
// ever observed.
|
|
48
|
+
case 'claude-cli': return { ...claudeCli, toolSchemas: (s) => s };
|
|
43
49
|
default:
|
|
44
50
|
throw new AgentTurnError(`provider "${provider}" does not support tool-use yet`, 'PROVIDER_UNSUPPORTED');
|
|
45
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.2",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// claude-cli tool-use adapter (Phase 19).
|
|
2
|
+
//
|
|
3
|
+
// Unlike the API-based adapters, this one wraps the official `claude`
|
|
4
|
+
// CLI (Claude Code). The CLI runs the *entire* tool-use loop inside
|
|
5
|
+
// itself — bash, edit, read, write, grep, etc. — and emits a single
|
|
6
|
+
// final text answer. From lazyclaw's mention-router perspective every
|
|
7
|
+
// call resolves to `{ kind: 'final', text }` after one iteration, so
|
|
8
|
+
// the multi-agent handoff still works (we just lose lazyclaw's audit
|
|
9
|
+
// log for tools claude ran on its own; the CLI keeps its own log).
|
|
10
|
+
//
|
|
11
|
+
// Wiring choices:
|
|
12
|
+
// - --output-format stream-json + --verbose so we can accumulate
|
|
13
|
+
// text deltas exactly like providers/claude_cli.mjs does (proven
|
|
14
|
+
// parser, no second JSON shape to maintain).
|
|
15
|
+
// - --permission-mode bypassPermissions because spec §10 #6 ships
|
|
16
|
+
// destructive-pattern confirmation OFF by default. Audit log
|
|
17
|
+
// still captures every tool the CLI runs (via the CLI's own
|
|
18
|
+
// telemetry — we don't double-write here).
|
|
19
|
+
// - --tools maps the lazyclaw whitelist into claude's built-in
|
|
20
|
+
// names (bash → Bash, etc.). When the whitelist is empty we pass
|
|
21
|
+
// `""` so tools are fully disabled.
|
|
22
|
+
// - --system-prompt carries the agent role + memory + team metadata
|
|
23
|
+
// the mention router builds.
|
|
24
|
+
// - LAZYCLAW_CLAUDE_BIN overrides the binary path so tests can
|
|
25
|
+
// point at a deterministic shim script.
|
|
26
|
+
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BIN = 'claude';
|
|
30
|
+
const LAZYCLAW_TO_CLAUDE_TOOL = {
|
|
31
|
+
bash: 'Bash',
|
|
32
|
+
read: 'Read',
|
|
33
|
+
write: 'Write',
|
|
34
|
+
grep: 'Grep',
|
|
35
|
+
web_search: 'WebSearch',
|
|
36
|
+
web_fetch: 'WebFetch',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export class ClaudeCliToolUseError extends Error {
|
|
40
|
+
constructor(message, code, body) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'ClaudeCliToolUseError';
|
|
43
|
+
this.code = code || 'CLAUDE_CLI_ERR';
|
|
44
|
+
if (body) this.body = body;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The schemas value from listToolSchemas comes in lazyclaw form; claude
|
|
49
|
+
// expects a comma-separated string of its OWN built-in tool names.
|
|
50
|
+
// Returning a string rather than an array lets us pass it as a single
|
|
51
|
+
// CLI argument unchanged. An empty string is meaningful — it disables
|
|
52
|
+
// all tools — so callers should distinguish "no tools whitelisted" from
|
|
53
|
+
// "tools field omitted".
|
|
54
|
+
export function toClaudeTools(schemas) {
|
|
55
|
+
if (!Array.isArray(schemas) || schemas.length === 0) return '';
|
|
56
|
+
const names = schemas
|
|
57
|
+
.map((s) => LAZYCLAW_TO_CLAUDE_TOOL[s?.name])
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
return [...new Set(names)].join(',');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeHistory(turns) {
|
|
63
|
+
return Array.isArray(turns) ? [...turns] : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function initialUserMessage(text) {
|
|
67
|
+
return { role: 'user', content: String(text) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build the single prompt string the CLI sees. Concatenate every
|
|
71
|
+
// non-system message, prefixing prior assistant turns with a "[prior]
|
|
72
|
+
// " marker so the model can tell them apart from the live user turn.
|
|
73
|
+
// Mirrors the established pattern in providers/claude_cli.mjs.
|
|
74
|
+
function buildPrompt(messages) {
|
|
75
|
+
const lastUser = [...messages].reverse().find((m) => m && m.role === 'user');
|
|
76
|
+
if (!lastUser) return '';
|
|
77
|
+
const history = messages
|
|
78
|
+
.filter((m) => m !== lastUser && m && m.role !== 'system')
|
|
79
|
+
.map((m) => {
|
|
80
|
+
const tag = m.role === 'assistant' ? '[prior assistant]' : '[prior user]';
|
|
81
|
+
return `${tag} ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`;
|
|
82
|
+
})
|
|
83
|
+
.join('\n\n');
|
|
84
|
+
return history ? `${history}\n\n${lastUser.content}` : String(lastUser.content);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractTextDelta(obj) {
|
|
88
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
89
|
+
if (obj.type !== 'stream_event') return '';
|
|
90
|
+
const ev = obj.event || {};
|
|
91
|
+
if (ev.type === 'content_block_delta') {
|
|
92
|
+
const d = ev.delta || {};
|
|
93
|
+
if (d.type === 'text_delta' && typeof d.text === 'string') return d.text;
|
|
94
|
+
}
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Walk stream-json output to completion, accumulating text deltas, and
|
|
99
|
+
// return the final concatenated reply. The CLI also emits an
|
|
100
|
+
// 'assistant' record carrying the full message content; we fall back
|
|
101
|
+
// to that when no `stream_event` deltas were observed (some claude
|
|
102
|
+
// versions only emit the consolidated record).
|
|
103
|
+
async function readUntilDone(proc) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
let stdout = '';
|
|
106
|
+
let stderr = '';
|
|
107
|
+
let acc = '';
|
|
108
|
+
let assistantFallback = '';
|
|
109
|
+
let resultText = '';
|
|
110
|
+
proc.stdout.setEncoding('utf8');
|
|
111
|
+
proc.stderr.setEncoding('utf8');
|
|
112
|
+
proc.stdout.on('data', (chunk) => {
|
|
113
|
+
stdout += chunk;
|
|
114
|
+
let nl;
|
|
115
|
+
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
116
|
+
const line = stdout.slice(0, nl).trim();
|
|
117
|
+
stdout = stdout.slice(nl + 1);
|
|
118
|
+
if (!line) continue;
|
|
119
|
+
let obj;
|
|
120
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
121
|
+
const delta = extractTextDelta(obj);
|
|
122
|
+
if (delta) acc += delta;
|
|
123
|
+
// Fallback: consolidated assistant content block (no streaming).
|
|
124
|
+
if (obj?.type === 'assistant' && obj?.message?.content) {
|
|
125
|
+
for (const block of obj.message.content) {
|
|
126
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
127
|
+
assistantFallback += block.text;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (obj?.type === 'result' && typeof obj.result === 'string') {
|
|
132
|
+
resultText = obj.result;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
137
|
+
proc.on('error', reject);
|
|
138
|
+
proc.on('close', (code) => {
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
return reject(new ClaudeCliToolUseError(`claude CLI exit ${code}: ${stderr.slice(0, 300)}`, 'CLAUDE_CLI_EXIT', stderr));
|
|
141
|
+
}
|
|
142
|
+
// Prefer accumulated stream deltas; fall back to the assistant
|
|
143
|
+
// record or the final result text when streaming was disabled.
|
|
144
|
+
const text = acc || assistantFallback || resultText || '';
|
|
145
|
+
resolve(text);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function callOnce({
|
|
151
|
+
messages,
|
|
152
|
+
tools = [],
|
|
153
|
+
model,
|
|
154
|
+
apiKey, // unused — the CLI authenticates itself
|
|
155
|
+
system,
|
|
156
|
+
baseUrl, // unused
|
|
157
|
+
fetchImpl, // unused
|
|
158
|
+
signal,
|
|
159
|
+
bin,
|
|
160
|
+
cwd,
|
|
161
|
+
permissionMode = 'bypassPermissions',
|
|
162
|
+
} = {}) {
|
|
163
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
164
|
+
throw new ClaudeCliToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
165
|
+
}
|
|
166
|
+
const prompt = buildPrompt(messages);
|
|
167
|
+
if (!prompt) {
|
|
168
|
+
throw new ClaudeCliToolUseError('messages produced an empty prompt', 'NO_PROMPT');
|
|
169
|
+
}
|
|
170
|
+
const args = [
|
|
171
|
+
'-p', prompt,
|
|
172
|
+
'--output-format', 'stream-json',
|
|
173
|
+
'--include-partial-messages',
|
|
174
|
+
'--verbose',
|
|
175
|
+
'--permission-mode', permissionMode,
|
|
176
|
+
];
|
|
177
|
+
if (model) args.push('--model', model);
|
|
178
|
+
if (system && String(system).trim()) {
|
|
179
|
+
args.push('--system-prompt', String(system));
|
|
180
|
+
}
|
|
181
|
+
// Phase 19: pass the lazyclaw whitelist through to claude's --tools
|
|
182
|
+
// even when empty (an empty string explicitly disables every tool).
|
|
183
|
+
const toolsArg = toClaudeTools(tools);
|
|
184
|
+
args.push('--tools', toolsArg);
|
|
185
|
+
|
|
186
|
+
const binPath = bin || process.env.LAZYCLAW_CLAUDE_BIN || DEFAULT_BIN;
|
|
187
|
+
let proc;
|
|
188
|
+
try {
|
|
189
|
+
proc = spawn(binPath, args, {
|
|
190
|
+
cwd: cwd || process.cwd(),
|
|
191
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
192
|
+
env: process.env,
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err?.code === 'ENOENT') {
|
|
196
|
+
throw new ClaudeCliToolUseError(`claude CLI binary not found at "${binPath}"`, 'CLAUDE_CLI_NOT_FOUND');
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
const onAbort = () => { try { proc.kill('SIGTERM'); } catch { /* gone */ } };
|
|
201
|
+
if (signal) signal.addEventListener('abort', onAbort);
|
|
202
|
+
try {
|
|
203
|
+
const text = await readUntilDone(proc);
|
|
204
|
+
return { kind: 'final', text, raw: null };
|
|
205
|
+
} finally {
|
|
206
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The CLI handles tools internally — these helpers exist to keep the
|
|
211
|
+
// adapter surface symmetrical with anthropic/openai/gemini. Neither
|
|
212
|
+
// path is actually exercised at runtime because callOnce always
|
|
213
|
+
// returns kind:'final'.
|
|
214
|
+
export function assistantTurnMessages(_resp) { return []; }
|
|
215
|
+
export function toolResultMessages(_results) { return []; }
|