lazyclaw 4.2.1 → 4.3.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/cli.mjs CHANGED
@@ -56,6 +56,17 @@ function _resolveAuthKey(cfg, provider) {
56
56
  return cfg['api-key'] || '';
57
57
  }
58
58
 
59
+ // Per-provider base-URL override (used by tests + private gateways).
60
+ // Single source of truth — the reflect / skill-synth / task-tick paths
61
+ // all resolve through here so a new provider's env var lands in one spot.
62
+ function _resolveBaseUrl(provider) {
63
+ return {
64
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
65
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
66
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
67
+ }[provider] || undefined;
68
+ }
69
+
59
70
  async function importWorkflow(file) {
60
71
  const abs = path.resolve(file);
61
72
  const url = pathToFileURL(abs).href;
@@ -924,6 +935,10 @@ const SUBCOMMANDS = [
924
935
  'loop', 'loops', 'goal', 'memory',
925
936
  // v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
926
937
  'slack',
938
+ // v4.3.0 — Telegram long-poll listener (zero-install mobile control)
939
+ 'telegram',
940
+ // v4.3.0 — Matrix /sync long-poll listener
941
+ 'matrix',
927
942
  // v4.1.0 — multi-agent slack system (Phase 9+)
928
943
  'agent', 'team', 'task',
929
944
  ];
@@ -931,13 +946,13 @@ const SUBCOMMANDS = [
931
946
  const SUBCOMMAND_SUBS = {
932
947
  config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
933
948
  sessions: ['list', 'show', 'clear', 'export', 'search'],
934
- skills: ['list', 'show', 'install', 'remove', 'search'],
949
+ skills: ['list', 'show', 'install', 'remove', 'search', 'curate', 'classify'],
935
950
  providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
936
951
  rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
937
952
  completion: ['bash', 'zsh'],
938
953
  auth: ['list', 'add', 'remove', 'use', 'rotate'],
939
954
  pairing: ['list', 'add', 'remove'],
940
- nodes: ['list', 'register', 'remove'],
955
+ nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'devices'],
941
956
  message: ['list', 'add', 'remove', 'send'],
942
957
  workspace: ['list', 'init', 'show', 'remove', 'path'],
943
958
  cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
@@ -946,6 +961,8 @@ const SUBCOMMAND_SUBS = {
946
961
  goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
947
962
  memory: ['show', 'dream', 'edit'],
948
963
  slack: ['listen'],
964
+ telegram: ['listen'],
965
+ matrix: ['listen'],
949
966
  agent: ['add', 'list', 'show', 'edit', 'remove'],
950
967
  team: ['add', 'list', 'show', 'edit', 'remove'],
951
968
  task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
@@ -1484,28 +1501,28 @@ function _attachGhostAutocomplete(rl) {
1484
1501
  // Width-management rule: every inner line is forced through
1485
1502
  // `.padEnd(W)` so a stray width miscount can't punch the right
1486
1503
  // border off the box (which is exactly the bug v3.99.5 shipped:
1487
- // v4.2.2 — restore the original v3.99.11-era banner: a figlet
1488
- // "lazy" wordmark (small font, 4 rows) inside a 30-col rounded box
1489
- // with a "LazyClaw |__/ v<ver>" caption sharing the last inner row
1490
- // with the descender of the `y`. This is the banner that shipped
1491
- // before the helmet-mascot / 8-bit-crab / emoji detours, and the one
1492
- // the user kept in muscle memory.
1504
+ // v4.2.2 — boxed figlet "lazy" wordmark, single-colour orange. The
1505
+ // previous mixed-colour banner (helmet-red letter art + ink-beige
1506
+ // caption) read as "two banners glued together" because the colour
1507
+ // changed mid-box. We use one warm orange (#F08246) for everything
1508
+ // border, letter art, caption so the eye reads it as one badge.
1493
1509
  //
1494
- // Layout invariant: every inner row is exactly 30 visible cells (no
1495
- // double-width glyphs, all chars are 1 cell in any monospace font),
1496
- // so the right edge `│` always lands in the same column.
1510
+ // Letter art is figlet "standard" (6 rows) rather than the v3.99.11
1511
+ // "small" (4 rows), because small renders as a pixel mush in most
1512
+ // terminal fonts. Standard's strokes are wide enough that the
1513
+ // letters read as `l a z y` even at small terminal sizes.
1497
1514
  //
1498
- // _renderMascot / _renderMascotTiny are kept as stubs because the
1499
- // _renderBanner caller still passes a row array around; they no
1500
- // longer drive separate art for working/done/error since the box-
1501
- // banner has no good place to flash those states. The router and
1502
- // loop runners that used to ask for a state-coloured tiny mascot can
1503
- // keep importing the helper; it just returns the wordmark.
1515
+ // Layout invariant: every inner row is exactly INNER_W visible cells
1516
+ // (no double-width glyphs, all chars are 1 cell in any monospace
1517
+ // font), so the right edge `│` always lands in the same column.
1518
+ //
1519
+ // _renderMascot / _renderMascotTiny are kept as stubs so any leftover
1520
+ // caller doesn't crash; no state-coloured art is produced any more.
1521
+
1522
+ const _ORANGE_RGB = '241;130;70'; // #F18246
1523
+ function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
1504
1524
 
1505
1525
  function _renderMascot() {
1506
- // Banner builds its own art; this stub exists so any leftover
1507
- // caller doesn't crash. One element so .map() and length checks
1508
- // behave like the legacy shape.
1509
1526
  return ['lazyclaw'];
1510
1527
  }
1511
1528
 
@@ -1513,39 +1530,49 @@ function _renderMascotTiny() {
1513
1530
  return 'lazyclaw';
1514
1531
  }
1515
1532
 
1533
+ // figlet "standard" "lazy", trimmed of leading blank line. Each row
1534
+ // is left-padded by two spaces inside the box, and every row is then
1535
+ // padded to INNER_W cells.
1536
+ const _LAZY_STANDARD = [
1537
+ ' _ ',
1538
+ '| | __ _ _____ _ ',
1539
+ '| |/ _` |_ / | | | ',
1540
+ '| | (_| |/ /| |_| | ',
1541
+ '|_|\\__,_/___|\\__, | ',
1542
+ ' |___/ ',
1543
+ ];
1544
+
1545
+ const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
1546
+
1516
1547
  function _renderBanner(version) {
1517
- const helmet = (s) => `\x1b[38;2;195;61;42m${s}\x1b[0m`;
1518
- const ink = (s) => `\x1b[38;2;241;234;217m${s}\x1b[0m`;
1519
1548
  const v = String(version || '?.?.?');
1520
- // Caption shares the final inner row with the `|__/` descender of
1521
- // the `y` from figlet-small, exactly like the v3.99.11 screenshot.
1522
- const cap = ` LazyClaw |__/ v${v}`.padEnd(30, ' ').slice(0, 30);
1523
- const top = '╭' + '─'.repeat(30) + '╮';
1524
- const bot = '╰' + '─'.repeat(30) + '╯';
1525
- const wrap = (inner) => helmet('│') + inner + helmet('│');
1549
+ const cap = ` LazyClaw v${v}`;
1550
+ const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
1551
+ const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
1552
+ const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
1553
+ const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
1526
1554
  return [
1527
- helmet(top),
1528
- wrap(helmet(' _ ')),
1529
- wrap(helmet(' | |__ _ _____ _ _ ')),
1530
- wrap(helmet(` | / _\` |_ / || | '_| `)),
1531
- wrap(helmet(` |_\\__,_/__\\_, |_| `)),
1532
- wrap(ink(cap)),
1533
- helmet(bot),
1555
+ top,
1556
+ ..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
1557
+ wrap(padInner(cap)),
1558
+ bot,
1534
1559
  ];
1535
1560
  }
1536
1561
 
1537
1562
  function _printChatBanner(activeProvName, activeModel, version) {
1538
1563
  if (!process.stdout.isTTY) return;
1539
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1540
- const ok = (s) => `\x1b[32m${s}\x1b[0m`;
1564
+ // Single-hue header: labels dim-orange, values/emphasis full-orange, so the
1565
+ // four caption rows below the box read as part of the same warm badge.
1566
+ const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
1567
+ const orange = _orange;
1541
1568
  const lines = [
1542
1569
  '',
1543
1570
  ..._renderBanner(version),
1544
1571
  '',
1545
- ` ${dim('provider ·')} ${ok(activeProvName)}`,
1546
- ` ${dim('model ·')} ${ok(activeModel || '(default)')}`,
1547
- ` ${dim('slash ·')} /help · /model · /provider · /exit`,
1548
- ` ${dim('hint ·')} → ${dim('to accept the suggested command,')} Tab ${dim('to cycle')}`,
1572
+ ` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
1573
+ ` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
1574
+ ` ${dimOrange('slash ·')} ${orange('/help · /model · /provider · /exit')}`,
1575
+ ` ${dimOrange('hint ·')} ${orange('')} ${dimOrange('to accept the suggested command,')} ${orange('Tab')} ${dimOrange('to cycle')}`,
1549
1576
  '',
1550
1577
  ];
1551
1578
  process.stdout.write(lines.join('\n') + '\n');
@@ -3567,8 +3594,51 @@ async function cmdNodes(sub, positional, flags = {}) {
3567
3594
  console.log(JSON.stringify({ ok: true, removed: id }));
3568
3595
  return;
3569
3596
  }
3597
+ // Device-gateway pairing (Phase 27) — distinct from the config-based
3598
+ // `nodes register` table above. These drive the Ed25519 PairingStore
3599
+ // a companion node authenticates against via `lazyclaw daemon`.
3600
+ case 'pending': {
3601
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3602
+ const store = new PairingStore(path.dirname(configPath()));
3603
+ console.log(JSON.stringify(store.pending(), null, 2));
3604
+ return;
3605
+ }
3606
+ case 'devices': {
3607
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3608
+ const store = new PairingStore(path.dirname(configPath()));
3609
+ console.log(JSON.stringify(store.devicesList(), null, 2));
3610
+ return;
3611
+ }
3612
+ case 'approve': {
3613
+ const requestId = positional[0];
3614
+ if (!requestId) {
3615
+ console.error('Usage: lazyclaw nodes approve <requestId> (see `lazyclaw nodes pending`)');
3616
+ process.exit(2);
3617
+ }
3618
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3619
+ const store = new PairingStore(path.dirname(configPath()));
3620
+ try {
3621
+ const { deviceId } = store.approve(requestId);
3622
+ // The token is intentionally NOT printed — the device receives its
3623
+ // rotated token on its next /gateway/connect, so it never has to
3624
+ // pass through a terminal / shell history.
3625
+ console.log(JSON.stringify({ ok: true, approved: requestId, deviceId, note: 'device receives its token on next /gateway/connect' }));
3626
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
3627
+ return;
3628
+ }
3629
+ case 'revoke': {
3630
+ const deviceId = positional[0];
3631
+ if (!deviceId) {
3632
+ console.error('Usage: lazyclaw nodes revoke <deviceId>');
3633
+ process.exit(2);
3634
+ }
3635
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3636
+ const store = new PairingStore(path.dirname(configPath()));
3637
+ console.log(JSON.stringify(store.revoke(deviceId)));
3638
+ return;
3639
+ }
3570
3640
  default:
3571
- console.error('Usage: lazyclaw nodes <list|register|remove> ...');
3641
+ console.error('Usage: lazyclaw nodes <list|register|remove|pending|approve <requestId>|revoke <deviceId>|devices> ...');
3572
3642
  process.exit(2);
3573
3643
  }
3574
3644
  }
@@ -4372,7 +4442,7 @@ async function cmdMemory(sub, positional, flags = {}) {
4372
4442
  }
4373
4443
  }
4374
4444
 
4375
- const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect']);
4445
+ const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
4376
4446
  const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
4377
4447
  const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
4378
4448
 
@@ -4500,14 +4570,36 @@ async function cmdTask(sub, positional, flags = {}) {
4500
4570
  const cfg = readConfig();
4501
4571
  const leadAgent = agentsById[team.lead];
4502
4572
  const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
4503
- // Per-provider base-url override. Mostly useful for tests that
4504
- // point the adapter at a local mock; production users get the
4505
- // built-in default by leaving these unset.
4506
- const baseUrl = {
4507
- anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4508
- openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4509
- gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4510
- }[leadAgent.provider] || undefined;
4573
+ // Per-provider base-url override (tests point this at a local mock;
4574
+ // production leaves it unset for the built-in default).
4575
+ const baseUrl = _resolveBaseUrl(leadAgent.provider);
4576
+ // --approve-url turns on remote human-in-the-loop approval for the
4577
+ // sensitive tools (bash/write): each such call long-polls a running
4578
+ // daemon's `POST /exec/request`, which broadcasts to paired devices
4579
+ // over the gateway SSE and resolves when one approves. Fail-closed —
4580
+ // any endpoint error denies the call. Omit the flag → ungated (the
4581
+ // historical behavior).
4582
+ let approve;
4583
+ if (flags['approve-url']) {
4584
+ const approveUrl = String(flags['approve-url']).replace(/\/$/, '');
4585
+ const approveToken = flags['approve-token'] ? String(flags['approve-token']) : '';
4586
+ const approveTimeoutMs = flags['approve-timeout'] ? parseInt(flags['approve-timeout'], 10) : 120000;
4587
+ approve = async ({ tool, args, agent }) => {
4588
+ const summary = `${tool}: ${typeof args === 'object' ? JSON.stringify(args) : String(args)}`.slice(0, 400);
4589
+ try {
4590
+ const r = await fetch(`${approveUrl}/exec/request`, {
4591
+ method: 'POST',
4592
+ headers: { 'content-type': 'application/json', ...(approveToken ? { authorization: `Bearer ${approveToken}` } : {}) },
4593
+ body: JSON.stringify({ tool, agentId: agent, summary, timeoutMs: approveTimeoutMs }),
4594
+ });
4595
+ if (!r.ok) return { approved: false, reason: `approval endpoint HTTP ${r.status}` };
4596
+ const j = await r.json();
4597
+ return { approved: !!j.approved, reason: j.reason || (j.approved ? 'approved' : 'denied') };
4598
+ } catch (err) {
4599
+ return { approved: false, reason: `approval request failed: ${err?.message || err}` };
4600
+ }
4601
+ };
4602
+ }
4511
4603
  try {
4512
4604
  const result = await router.runTaskTurn({
4513
4605
  task, team, agentsById,
@@ -4517,6 +4609,7 @@ async function cmdTask(sub, positional, flags = {}) {
4517
4609
  baseUrl,
4518
4610
  logger: (line) => process.stderr.write(line),
4519
4611
  maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
4612
+ approve,
4520
4613
  });
4521
4614
  emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
4522
4615
  } catch (err) {
@@ -4675,7 +4768,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4675
4768
  return;
4676
4769
  }
4677
4770
  case 'add': {
4678
- 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); }
4771
+ if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep,skill_view] [--tags a,b] [--skill-write auto|manual|off]'); process.exit(2); }
4679
4772
  const tools = agentsMod.parseToolsFlag(flags.tools);
4680
4773
  try {
4681
4774
  const a = agentsMod.registerAgent({
@@ -4686,6 +4779,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4686
4779
  model: flags.model || '',
4687
4780
  tools: tools === null ? undefined : tools,
4688
4781
  tags: agentsMod.parseToolsFlag(flags.tags) || [],
4782
+ skillWrite: flags['skill-write'],
4689
4783
  }, cfgDir);
4690
4784
  emitJson(a);
4691
4785
  } catch (err) {
@@ -4702,7 +4796,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4702
4796
  return;
4703
4797
  }
4704
4798
  case 'edit': {
4705
- if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...]'); process.exit(2); }
4799
+ if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...] [--skill-write auto|manual|off] [--memory-write auto|manual|off]'); process.exit(2); }
4706
4800
  const patch = {};
4707
4801
  if (flags.role !== undefined) patch.role = String(flags.role);
4708
4802
  if (flags.provider !== undefined) patch.provider = String(flags.provider);
@@ -4711,6 +4805,8 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4711
4805
  if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4712
4806
  if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
4713
4807
  if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
4808
+ if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
4809
+ if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
4714
4810
  if (Object.keys(patch).length === 0) {
4715
4811
  console.error('agent edit: no fields to update');
4716
4812
  process.exit(2);
@@ -4786,11 +4882,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4786
4882
  try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4787
4883
  const cfg = readConfig();
4788
4884
  const apiKey = _resolveAuthKey(cfg, a.provider);
4789
- const baseUrl = {
4790
- anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4791
- openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4792
- gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4793
- }[a.provider] || undefined;
4885
+ const baseUrl = _resolveBaseUrl(a.provider);
4794
4886
  try {
4795
4887
  const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
4796
4888
  if (!body || !body.trim()) {
@@ -4807,8 +4899,50 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4807
4899
  }
4808
4900
  return;
4809
4901
  }
4902
+ case 'skill-synth': {
4903
+ const aname = positional[0];
4904
+ const taskId = flags.task || positional[1];
4905
+ if (!aname || !taskId) {
4906
+ console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
4907
+ process.exit(2);
4908
+ }
4909
+ const tasksMod = await import('./tasks.mjs');
4910
+ const synthMod = await import('./mas/skill_synth.mjs');
4911
+ const skillsMod = await import('./skills.mjs');
4912
+ const a = agentsMod.getAgent(aname, cfgDir);
4913
+ if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
4914
+ const task = tasksMod.getTask(taskId, cfgDir);
4915
+ if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
4916
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4917
+ const cfg = readConfig();
4918
+ const apiKey = _resolveAuthKey(cfg, a.provider);
4919
+ const baseUrl = _resolveBaseUrl(a.provider);
4920
+ try {
4921
+ const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
4922
+ if (!result) {
4923
+ process.stderr.write('skill synthesis produced nothing worth saving\n');
4924
+ return;
4925
+ }
4926
+ if (!flags['dry-run']) {
4927
+ // installSynthesized reserves a collision-free name (never
4928
+ // clobbers a human-authored skill) and version-bumps when it
4929
+ // improves its own prior skill.
4930
+ const installed = synthMod.installSynthesized(
4931
+ { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
4932
+ cfgDir,
4933
+ );
4934
+ emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
4935
+ } else {
4936
+ process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
4937
+ }
4938
+ } catch (err) {
4939
+ console.error(`agent skill-synth: ${err?.message || err}`);
4940
+ process.exit(2);
4941
+ }
4942
+ return;
4943
+ }
4810
4944
  default:
4811
- console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect> ...');
4945
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
4812
4946
  process.exit(2);
4813
4947
  }
4814
4948
  }
@@ -4919,6 +5053,165 @@ async function cmdSlack(sub, positional, flags = {}) {
4919
5053
  });
4920
5054
  }
4921
5055
 
5056
+ // `lazyclaw telegram listen` — zero-install mobile control surface.
5057
+ // Long-polls the Telegram Bot API (no public URL / webhook needed) and
5058
+ // pipes each inbound message through the active provider, replying in
5059
+ // the same chat. Mirrors `slack listen`. Access is gated by the existing
5060
+ // `pairing` allowlist (Telegram numeric user ids); an empty allowlist
5061
+ // means "reply to anyone who can reach the bot".
5062
+ async function cmdTelegram(sub, positional, flags = {}) {
5063
+ if (sub !== 'listen') {
5064
+ console.error('Usage: lazyclaw telegram listen [--provider X] [--model Y]\n Long-polls the Telegram Bot API. Set TELEGRAM_BOT_TOKEN in ~/.lazyclaw/.env.\n Restrict who can talk to it with `lazyclaw pairing add <telegram-user-id>`.');
5065
+ process.exit(2);
5066
+ }
5067
+ await ensureRegistry();
5068
+ const cfg = readConfig();
5069
+ const cfgDir = path.dirname(configPath());
5070
+
5071
+ const envInfo = _loadDotenvIfAny(cfgDir);
5072
+ process.stderr.write(`[telegram] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
5073
+
5074
+ const provName = flags.provider || cfg.provider || 'mock';
5075
+ const prov = _registryMod.PROVIDERS[provName];
5076
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
5077
+ const model = flags.model || cfg.model;
5078
+
5079
+ const threadMsgs = new Map();
5080
+ const MAX_TURNS = 20;
5081
+
5082
+ const handler = async ({ threadId, text }) => {
5083
+ const cleaned = String(text || '').trim();
5084
+ if (!cleaned) { process.stderr.write('[telegram] dropping empty inbound\n'); return null; }
5085
+ const msgs = threadMsgs.get(threadId) || [];
5086
+ msgs.push({ role: 'user', content: cleaned });
5087
+ let acc = '';
5088
+ try {
5089
+ for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
5090
+ } catch (err) {
5091
+ msgs.pop();
5092
+ const why = err?.message || String(err);
5093
+ process.stderr.write(`[telegram] provider error: ${why}\n`);
5094
+ return `(provider error: ${why})`;
5095
+ }
5096
+ msgs.push({ role: 'assistant', content: acc });
5097
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
5098
+ threadMsgs.set(threadId, msgs);
5099
+ if (!acc.trim()) { process.stderr.write('[telegram] provider returned empty text — not posting\n'); return null; }
5100
+ return acc;
5101
+ };
5102
+
5103
+ // The pairing allowlist doubles as the Telegram sender allowlist.
5104
+ const allowlist = (cfg.pairing || []).map((p) => String(p.id));
5105
+ const { TelegramChannel } = await import('./channels/telegram.mjs');
5106
+ let ch;
5107
+ try {
5108
+ ch = new TelegramChannel({ allowlist: allowlist.length ? allowlist : null });
5109
+ } catch (err) {
5110
+ console.error(`telegram: ${err?.message || err}`);
5111
+ process.exit(2);
5112
+ }
5113
+ process.stderr.write(`[telegram] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
5114
+ try {
5115
+ await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
5116
+ } catch (err) {
5117
+ if (err?.code === 'TELEGRAM_MISSING_TOKEN') {
5118
+ console.error('telegram: TELEGRAM_BOT_TOKEN not set');
5119
+ console.error(`hint: add TELEGRAM_BOT_TOKEN=... to ${path.join(cfgDir, '.env')}`);
5120
+ } else {
5121
+ console.error(`telegram: ${err?.message || err}`);
5122
+ }
5123
+ process.exit(2);
5124
+ }
5125
+ process.stderr.write(`[telegram] listening. Ctrl-C to stop.\n`);
5126
+
5127
+ await new Promise((resolve) => {
5128
+ const onSig = async () => {
5129
+ process.stderr.write(`\n[telegram] shutting down…\n`);
5130
+ try { await ch.stop(); } catch { /* best-effort */ }
5131
+ resolve();
5132
+ };
5133
+ process.once('SIGINT', onSig);
5134
+ process.once('SIGTERM', onSig);
5135
+ });
5136
+ }
5137
+
5138
+ // `lazyclaw matrix listen` — Matrix inbound over the client-server API's
5139
+ // long-poll /sync (no SDK). Mirrors `telegram listen`. Set MATRIX_HOMESERVER
5140
+ // + MATRIX_ACCESS_TOKEN (+ MATRIX_USER_ID for self-filtering) in ~/.lazyclaw/.env.
5141
+ async function cmdMatrix(sub, positional, flags = {}) {
5142
+ if (sub !== 'listen') {
5143
+ console.error('Usage: lazyclaw matrix listen [--provider X] [--model Y]\n Long-polls the Matrix /sync API. Set MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN (+ MATRIX_USER_ID) in ~/.lazyclaw/.env.\n Restrict who can talk to it with `lazyclaw pairing add <@user:server>`.');
5144
+ process.exit(2);
5145
+ }
5146
+ await ensureRegistry();
5147
+ const cfg = readConfig();
5148
+ const cfgDir = path.dirname(configPath());
5149
+
5150
+ const envInfo = _loadDotenvIfAny(cfgDir);
5151
+ process.stderr.write(`[matrix] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
5152
+
5153
+ const provName = flags.provider || cfg.provider || 'mock';
5154
+ const prov = _registryMod.PROVIDERS[provName];
5155
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
5156
+ const model = flags.model || cfg.model;
5157
+
5158
+ const threadMsgs = new Map();
5159
+ const MAX_TURNS = 20;
5160
+ const handler = async ({ threadId, text }) => {
5161
+ const cleaned = String(text || '').trim();
5162
+ if (!cleaned) { process.stderr.write('[matrix] dropping empty inbound\n'); return null; }
5163
+ const msgs = threadMsgs.get(threadId) || [];
5164
+ msgs.push({ role: 'user', content: cleaned });
5165
+ let acc = '';
5166
+ try {
5167
+ for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
5168
+ } catch (err) {
5169
+ msgs.pop();
5170
+ const why = err?.message || String(err);
5171
+ process.stderr.write(`[matrix] provider error: ${why}\n`);
5172
+ return `(provider error: ${why})`;
5173
+ }
5174
+ msgs.push({ role: 'assistant', content: acc });
5175
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
5176
+ threadMsgs.set(threadId, msgs);
5177
+ if (!acc.trim()) { process.stderr.write('[matrix] provider returned empty text — not posting\n'); return null; }
5178
+ return acc;
5179
+ };
5180
+
5181
+ const allowlist = (cfg.pairing || []).map((p) => String(p.id));
5182
+ const { MatrixChannel } = await import('./channels/matrix.mjs');
5183
+ let ch;
5184
+ try {
5185
+ ch = new MatrixChannel({ allowlist: allowlist.length ? allowlist : null });
5186
+ } catch (err) {
5187
+ console.error(`matrix: ${err?.message || err}`);
5188
+ process.exit(2);
5189
+ }
5190
+ process.stderr.write(`[matrix] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
5191
+ try {
5192
+ await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
5193
+ } catch (err) {
5194
+ if (err?.code === 'MATRIX_MISSING_TOKEN' || err?.code === 'MATRIX_MISSING_HOMESERVER') {
5195
+ console.error(`matrix: ${err.message}`);
5196
+ console.error(`hint: set MATRIX_HOMESERVER and MATRIX_ACCESS_TOKEN in ${path.join(cfgDir, '.env')}`);
5197
+ } else {
5198
+ console.error(`matrix: ${err?.message || err}`);
5199
+ }
5200
+ process.exit(2);
5201
+ }
5202
+ process.stderr.write(`[matrix] listening. Ctrl-C to stop.\n`);
5203
+
5204
+ await new Promise((resolve) => {
5205
+ const onSig = async () => {
5206
+ process.stderr.write(`\n[matrix] shutting down…\n`);
5207
+ try { await ch.stop(); } catch { /* best-effort */ }
5208
+ resolve();
5209
+ };
5210
+ process.once('SIGINT', onSig);
5211
+ process.once('SIGTERM', onSig);
5212
+ });
5213
+ }
5214
+
4922
5215
  async function cmdSkills(sub, positional, flags = {}) {
4923
5216
  const skillsMod = await import('./skills.mjs');
4924
5217
  const cfgDir = path.dirname(configPath());
@@ -5110,8 +5403,24 @@ async function cmdSkills(sub, positional, flags = {}) {
5110
5403
  console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
5111
5404
  return;
5112
5405
  }
5406
+ case 'curate': {
5407
+ // Lifecycle sweep: agent-authored skills unused >90d move into
5408
+ // skills/.archive/ (recoverable). Human-authored skills are never
5409
+ // moved. The real clock is injected here; the module stays pure.
5410
+ const curator = await import('./skills_curator.mjs');
5411
+ const r = curator.curate(cfgDir, Date.now());
5412
+ console.log(JSON.stringify(r, null, 2));
5413
+ return;
5414
+ }
5415
+ case 'classify': {
5416
+ const name = positional[0];
5417
+ if (!name) { console.error('Usage: lazyclaw skills classify <name>'); process.exit(2); }
5418
+ const curator = await import('./skills_curator.mjs');
5419
+ console.log(JSON.stringify({ name, state: curator.classify(name, cfgDir, Date.now()), usage: curator.usageOf(name, cfgDir) }, null, 2));
5420
+ return;
5421
+ }
5113
5422
  default:
5114
- console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]>');
5423
+ console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]|curate|classify <name>>');
5115
5424
  process.exit(2);
5116
5425
  }
5117
5426
  }
@@ -5987,6 +6296,8 @@ async function _dispatchMenuChoice(argv) {
5987
6296
  case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
5988
6297
  case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
5989
6298
  case 'slack': return await cmdSlack(rest[0], rest.slice(1), {});
6299
+ case 'telegram': return await cmdTelegram(rest[0], rest.slice(1), {});
6300
+ case 'matrix': return await cmdMatrix(rest[0], rest.slice(1), {});
5990
6301
  case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
5991
6302
  case 'task': return await cmdTask(rest[0], rest.slice(1), {});
5992
6303
  case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
@@ -6538,6 +6849,16 @@ async function main() {
6538
6849
  await cmdSlack(sub, rest.positional.slice(1), rest.flags);
6539
6850
  break;
6540
6851
  }
6852
+ case 'telegram': {
6853
+ const sub = rest.positional[0];
6854
+ await cmdTelegram(sub, rest.positional.slice(1), rest.flags);
6855
+ break;
6856
+ }
6857
+ case 'matrix': {
6858
+ const sub = rest.positional[0];
6859
+ await cmdMatrix(sub, rest.positional.slice(1), rest.flags);
6860
+ break;
6861
+ }
6541
6862
  case 'team': {
6542
6863
  const sub = rest.positional[0];
6543
6864
  await cmdTeam(sub, rest.positional.slice(1), rest.flags);