lazyclaw 6.0.0 → 6.1.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.
Files changed (73) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/channels-discord/index.mjs +76 -0
  9. package/channels-discord/package.json +14 -0
  10. package/channels-email/index.mjs +109 -0
  11. package/channels-email/package.json +14 -0
  12. package/channels-signal/index.mjs +69 -0
  13. package/channels-signal/package.json +9 -0
  14. package/channels-voice/index.mjs +81 -0
  15. package/channels-voice/package.json +9 -0
  16. package/channels-whatsapp/index.mjs +70 -0
  17. package/channels-whatsapp/package.json +13 -0
  18. package/cli.mjs +10 -21
  19. package/commands/agents.mjs +669 -0
  20. package/commands/auth_nodes.mjs +323 -0
  21. package/commands/automation.mjs +582 -0
  22. package/commands/channels.mjs +254 -0
  23. package/commands/chat.mjs +1253 -0
  24. package/commands/config.mjs +315 -0
  25. package/commands/daemon.mjs +275 -0
  26. package/commands/gateway.mjs +194 -0
  27. package/commands/misc.mjs +128 -0
  28. package/commands/providers.mjs +490 -0
  29. package/commands/service.mjs +113 -0
  30. package/commands/sessions.mjs +343 -0
  31. package/commands/setup.mjs +742 -0
  32. package/commands/setup_channels.mjs +207 -0
  33. package/commands/skills.mjs +218 -0
  34. package/commands/workflow.mjs +661 -0
  35. package/config_features.mjs +106 -0
  36. package/daemon/lib/auth.mjs +58 -0
  37. package/daemon/lib/cost.mjs +30 -0
  38. package/daemon/lib/inbound_dedup.mjs +108 -0
  39. package/daemon/lib/learn_queue.mjs +46 -0
  40. package/daemon/lib/provider.mjs +69 -0
  41. package/daemon/lib/respond.mjs +83 -0
  42. package/daemon/route_table.mjs +96 -0
  43. package/daemon/routes/_deps.mjs +42 -0
  44. package/daemon/routes/config.mjs +99 -0
  45. package/daemon/routes/conversation.mjs +435 -0
  46. package/daemon/routes/meta.mjs +239 -0
  47. package/daemon/routes/ops.mjs +165 -0
  48. package/daemon/routes/providers.mjs +211 -0
  49. package/daemon/routes/rates.mjs +90 -0
  50. package/daemon/routes/registry.mjs +223 -0
  51. package/daemon/routes/sessions.mjs +213 -0
  52. package/daemon/routes/skills.mjs +260 -0
  53. package/daemon/routes/workflows.mjs +224 -0
  54. package/dotenv_min.mjs +51 -0
  55. package/first_run.mjs +24 -0
  56. package/goals_cron.mjs +37 -0
  57. package/lib/args.mjs +216 -0
  58. package/lib/config.mjs +113 -0
  59. package/lib/gateway_guard.mjs +100 -0
  60. package/lib/inbound_client.mjs +108 -0
  61. package/lib/registry_boot.mjs +55 -0
  62. package/lib/service_install.mjs +207 -0
  63. package/package.json +15 -3
  64. package/providers/probe.mjs +28 -0
  65. package/secure_write.mjs +46 -0
  66. package/tui/editor.mjs +18 -2
  67. package/tui/repl.mjs +25 -4
  68. package/tui/slash_commands.mjs +4 -0
  69. package/tui/slash_dispatcher.mjs +118 -0
  70. package/tui/splash_props.mjs +52 -0
  71. package/web/dashboard.css +6 -12
  72. package/web/dashboard.html +2 -34
  73. package/web/dashboard.js +3 -3
@@ -0,0 +1,69 @@
1
+ // @lazyclaw/channel-signal
2
+ //
3
+ // Thin wrapper around `signal-cli` (external binary, must be installed
4
+ // separately and linked to a registered account). Inbound polling uses
5
+ // `signal-cli receive --json`; outbound uses `signal-cli send`.
6
+
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { Channel } from '../channels/base.mjs';
9
+
10
+ export class SignalChannel extends Channel {
11
+ constructor(opts = {}) {
12
+ super('signal');
13
+ this._binary = opts.binary || process.env.SIGNAL_CLI_BIN || 'signal-cli';
14
+ this._account = opts.account || process.env.SIGNAL_ACCOUNT || null;
15
+ this._receiver = null;
16
+ this._pollMs = opts.pollMs || 15000;
17
+ }
18
+
19
+ async start(handler, opts = {}) {
20
+ await super.start(handler, opts);
21
+ if (!this._account) throw new Error('SignalChannel: account (E.164) required');
22
+ this._receiver = setInterval(() => { this._pollOnce().catch(() => {}); }, this._pollMs);
23
+ this._receiver.unref?.();
24
+ }
25
+
26
+ async _pollOnce() {
27
+ const proc = spawn(this._binary, ['-a', this._account, 'receive', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
28
+ let buf = '';
29
+ proc.stdout.on('data', (d) => { buf += d.toString('utf8'); });
30
+ await new Promise((resolve) => proc.on('exit', resolve));
31
+ for (const line of buf.split('\n')) {
32
+ if (!line.trim()) continue;
33
+ try {
34
+ const evt = JSON.parse(line);
35
+ const env = evt.envelope || {};
36
+ const text = env.dataMessage?.message || '';
37
+ const from = env.source || env.sourceNumber;
38
+ if (!from || !text) continue;
39
+ const reply = await this._processInbound({
40
+ threadId: String(from), text, gateInput: { token: from },
41
+ });
42
+ if (reply) await this.send(from, reply);
43
+ } catch { /* skip malformed */ }
44
+ }
45
+ }
46
+
47
+ async send(threadId, text) {
48
+ const res = spawnSync(this._binary, ['-a', this._account, 'send', '-m', String(text), String(threadId)],
49
+ { stdio: ['ignore', 'pipe', 'pipe'] });
50
+ if (res.error && res.error.code === 'ENOENT') {
51
+ const err = new Error(`SIGNAL_CLI_MISSING: ${this._binary}`);
52
+ err.code = 'SIGNAL_CLI_MISSING';
53
+ throw err;
54
+ }
55
+ if (res.status !== 0) {
56
+ throw new Error(`signal-cli send exited ${res.status}: ${res.stderr?.toString('utf8') || ''}`);
57
+ }
58
+ }
59
+
60
+ async stop() {
61
+ if (this._receiver) clearInterval(this._receiver);
62
+ this._receiver = null;
63
+ await super.stop();
64
+ }
65
+ }
66
+
67
+ export function register({ addChannel }) {
68
+ addChannel('signal', (opts) => new SignalChannel(opts));
69
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@lazyclaw/channel-signal",
3
+ "version": "0.1.0",
4
+ "description": "Signal channel plugin for lazyclaw v5 (signal-cli wrapper).",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "files": ["index.mjs"],
8
+ "peerDependencies": { "lazyclaw": ">=5.0.0" }
9
+ }
@@ -0,0 +1,81 @@
1
+ // @lazyclaw/channel-voice
2
+ //
3
+ // v5.0 scope per spec §0.2: TRANSCRIBE-ONLY. No TTS reply (deferred to v5.1).
4
+ //
5
+ // This channel does not own its own transport. It registers itself with
6
+ // the Telegram and Discord plugins (and any other channel that exposes
7
+ // a `onVoiceMemo` hook) and acts as the transcription pipeline.
8
+ // Telegram/Discord call `voice.ingestVoiceMemo({threadId, audio, mime})`
9
+ // when a voice memo arrives; the resulting text is forwarded through
10
+ // this channel's handler and the text reply is sent on whichever channel
11
+ // the user is currently bound to via channels/threads.mjs.
12
+
13
+ import { Channel } from '../channels/base.mjs';
14
+
15
+ export class VoiceChannel extends Channel {
16
+ constructor(opts = {}) {
17
+ super('voice');
18
+ this._transcribe = typeof opts.transcribe === 'function' ? opts.transcribe : null;
19
+ }
20
+
21
+ setTranscriber(fn) {
22
+ if (typeof fn !== 'function') throw new Error('setTranscriber: function required');
23
+ this._transcribe = fn;
24
+ }
25
+
26
+ async ingestVoiceMemo({ threadId, audio, mime }) {
27
+ if (!this._transcribe) {
28
+ const err = new Error('TRANSCRIBE_NOT_CONFIGURED');
29
+ err.code = 'TRANSCRIBE_NOT_CONFIGURED';
30
+ throw err;
31
+ }
32
+ if (!Buffer.isBuffer(audio)) {
33
+ throw new Error('ingestVoiceMemo: audio must be a Buffer');
34
+ }
35
+ const text = await this._transcribe(audio, mime || 'audio/ogg');
36
+ if (!text || typeof text !== 'string') return null;
37
+ return await this._processInbound({
38
+ threadId: String(threadId), text, gateInput: { token: 'voice' },
39
+ });
40
+ }
41
+
42
+ // send() is intentionally a no-op text passthrough — voice channel does
43
+ // not synthesise audio in v5.0. The text reply is delivered by whichever
44
+ // channel the thread is bound to (channels/threads.mjs).
45
+ async send(_threadId, _text) {
46
+ // no-op
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Default transcriber backed by an OpenAI-Whisper-compatible endpoint
52
+ * (e.g. OpenAI /v1/audio/transcriptions or any compatible proxy). Used
53
+ * by the registered factory when the host does not inject one.
54
+ */
55
+ export function makeOpenAITranscriber({ apiKey, model = 'whisper-1', baseUrl = 'https://api.openai.com/v1' }) {
56
+ if (!apiKey) throw new Error('makeOpenAITranscriber: apiKey required');
57
+ return async function transcribe(audio, mime) {
58
+ const fd = new FormData();
59
+ const ext = (mime || '').split('/')[1] || 'ogg';
60
+ fd.append('file', new Blob([audio], { type: mime || 'audio/ogg' }), `memo.${ext}`);
61
+ fd.append('model', model);
62
+ const res = await fetch(`${baseUrl}/audio/transcriptions`, {
63
+ method: 'POST',
64
+ headers: { Authorization: `Bearer ${apiKey}` },
65
+ body: fd,
66
+ });
67
+ if (!res.ok) throw new Error(`transcribe HTTP ${res.status}: ${await res.text()}`);
68
+ const j = await res.json();
69
+ return j.text || '';
70
+ };
71
+ }
72
+
73
+ export function register({ addChannel }) {
74
+ addChannel('voice', (opts) => {
75
+ const ch = new VoiceChannel(opts || {});
76
+ if (!opts?.transcribe && opts?.openai?.apiKey) {
77
+ ch.setTranscriber(makeOpenAITranscriber(opts.openai));
78
+ }
79
+ return ch;
80
+ });
81
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@lazyclaw/channel-voice",
3
+ "version": "0.1.0",
4
+ "description": "Voice memo channel for lazyclaw v5 (transcribe-only; TTS deferred to v5.1).",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "files": ["index.mjs"],
8
+ "peerDependencies": { "lazyclaw": ">=5.0.0" }
9
+ }
@@ -0,0 +1,70 @@
1
+ // @lazyclaw/channel-whatsapp
2
+ //
3
+ // whatsapp-web.js (browser automation). First-run prints a QR via
4
+ // qrcode-terminal; subsequent runs reuse LocalAuth session in
5
+ // <configDir>/whatsapp/. Inbound `message` events route to handler.
6
+
7
+ import { Channel } from '../channels/base.mjs';
8
+
9
+ export class WhatsappChannel extends Channel {
10
+ constructor(opts = {}) {
11
+ super('whatsapp');
12
+ this._opts = opts || {};
13
+ this._client = null;
14
+ this._qrState = 'pending'; // pending | shown | authenticated | failed
15
+ this._lastQr = null;
16
+ }
17
+
18
+ qrState() { return this._qrState; }
19
+ lastQr() { return this._lastQr; }
20
+
21
+ async start(handler, opts = {}) {
22
+ await super.start(handler, opts);
23
+ const wweb = await import('whatsapp-web.js');
24
+ const qrt = await import('qrcode-terminal');
25
+ const { Client, LocalAuth } = wweb;
26
+ this._client = new Client({
27
+ authStrategy: new LocalAuth({ dataPath: this._opts.dataPath || './whatsapp' }),
28
+ puppeteer: { headless: true },
29
+ });
30
+ this._client.on('qr', (qr) => {
31
+ this._lastQr = qr;
32
+ this._qrState = 'shown';
33
+ qrt.default?.generate(qr, { small: true });
34
+ });
35
+ this._client.on('authenticated', () => { this._qrState = 'authenticated'; });
36
+ this._client.on('auth_failure', () => { this._qrState = 'failed'; });
37
+ this._client.on('message', async (msg) => {
38
+ try {
39
+ const reply = await this._processInbound({
40
+ threadId: msg.from, text: msg.body || '', gateInput: { token: msg.from },
41
+ });
42
+ if (reply) await msg.reply(reply);
43
+ } catch (e) {
44
+ if (e.code !== 'CHANNEL_GATED') {
45
+ process.stderr.write(`[whatsapp] inbound error: ${e.message}\n`);
46
+ }
47
+ }
48
+ });
49
+ await this._client.initialize();
50
+ }
51
+
52
+ async send(threadId, text) {
53
+ if (!this._client || this._qrState !== 'authenticated') {
54
+ const err = new Error('NOT_AUTHENTICATED');
55
+ err.code = 'NOT_AUTHENTICATED';
56
+ throw err;
57
+ }
58
+ await this._client.sendMessage(String(threadId), String(text));
59
+ }
60
+
61
+ async stop() {
62
+ if (this._client) { try { await this._client.destroy(); } catch { /* ignore */ } }
63
+ this._client = null;
64
+ await super.stop();
65
+ }
66
+ }
67
+
68
+ export function register({ addChannel }) {
69
+ addChannel('whatsapp', (opts) => new WhatsappChannel(opts));
70
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@lazyclaw/channel-whatsapp",
3
+ "version": "0.1.0",
4
+ "description": "WhatsApp channel plugin for lazyclaw v5 (whatsapp-web.js).",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "files": ["index.mjs"],
8
+ "peerDependencies": { "lazyclaw": ">=5.0.0" },
9
+ "dependencies": {
10
+ "whatsapp-web.js": "^1.23.0",
11
+ "qrcode-terminal": "^0.12.0"
12
+ }
13
+ }
package/cli.mjs CHANGED
@@ -311,27 +311,16 @@ async function main() {
311
311
  }
312
312
  case 'channels': {
313
313
  const sub = (rest.positional[0] || 'list').toLowerCase();
314
- const { createLoader, listInstalled } = await import('./channels/loader.mjs');
315
- const cfgDir = path.dirname(configPath());
316
- const loader = createLoader({ configDir: cfgDir });
317
- if (sub === 'install') {
318
- const name = rest.positional[1];
319
- if (!name) { process.stderr.write('usage: lazyclaw channels install <@lazyclaw/channel-name>\n'); process.exit(2); }
320
- const info = await loader.install(name);
321
- process.stdout.write(`installed ${info.name}@${info.version}\n`);
322
- break;
323
- }
324
- if (sub === 'remove' || sub === 'uninstall') {
325
- const name = rest.positional[1];
326
- if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
327
- await loader.remove(name);
328
- process.stdout.write(`removed ${name}\n`);
329
- break;
330
- }
331
- // list
332
- const rows = listInstalled(cfgDir);
333
- if (!rows.length) { process.stdout.write('no channel plugins installed\n'); break; }
334
- for (const r of rows) process.stdout.write(`${r.name}\t${r.version}\n`);
314
+ await (await import('./commands/channels.mjs')).cmdChannels(sub, rest.positional.slice(1), rest.flags);
315
+ break;
316
+ }
317
+ case 'service': {
318
+ const sub = rest.positional[0];
319
+ await (await import('./commands/service.mjs')).cmdService(sub, rest.positional.slice(1), rest.flags);
320
+ break;
321
+ }
322
+ case 'gateway': {
323
+ await (await import('./commands/gateway.mjs')).cmdGateway(rest.flags);
335
324
  break;
336
325
  }
337
326
  case 'daemon': {