lazyclaw 4.2.2 → 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/README.md +155 -0
- package/agents.mjs +19 -3
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/cli.mjs +332 -21
- package/daemon.mjs +98 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +2 -1
- package/mas/mention_router.mjs +75 -4
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +232 -0
- package/mas/tool_runner.mjs +24 -2
- package/mas/tools/skill_view.mjs +43 -0
- package/package.json +3 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
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'],
|
|
@@ -3577,8 +3594,51 @@ async function cmdNodes(sub, positional, flags = {}) {
|
|
|
3577
3594
|
console.log(JSON.stringify({ ok: true, removed: id }));
|
|
3578
3595
|
return;
|
|
3579
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
|
+
}
|
|
3580
3640
|
default:
|
|
3581
|
-
console.error('Usage: lazyclaw nodes <list|register|remove> ...');
|
|
3641
|
+
console.error('Usage: lazyclaw nodes <list|register|remove|pending|approve <requestId>|revoke <deviceId>|devices> ...');
|
|
3582
3642
|
process.exit(2);
|
|
3583
3643
|
}
|
|
3584
3644
|
}
|
|
@@ -4382,7 +4442,7 @@ async function cmdMemory(sub, positional, flags = {}) {
|
|
|
4382
4442
|
}
|
|
4383
4443
|
}
|
|
4384
4444
|
|
|
4385
|
-
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']);
|
|
4386
4446
|
const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
|
|
4387
4447
|
const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
|
|
4388
4448
|
|
|
@@ -4510,14 +4570,36 @@ async function cmdTask(sub, positional, flags = {}) {
|
|
|
4510
4570
|
const cfg = readConfig();
|
|
4511
4571
|
const leadAgent = agentsById[team.lead];
|
|
4512
4572
|
const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
|
|
4513
|
-
// Per-provider base-url override
|
|
4514
|
-
//
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
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
|
+
}
|
|
4521
4603
|
try {
|
|
4522
4604
|
const result = await router.runTaskTurn({
|
|
4523
4605
|
task, team, agentsById,
|
|
@@ -4527,6 +4609,7 @@ async function cmdTask(sub, positional, flags = {}) {
|
|
|
4527
4609
|
baseUrl,
|
|
4528
4610
|
logger: (line) => process.stderr.write(line),
|
|
4529
4611
|
maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
|
|
4612
|
+
approve,
|
|
4530
4613
|
});
|
|
4531
4614
|
emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
|
|
4532
4615
|
} catch (err) {
|
|
@@ -4685,7 +4768,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
4685
4768
|
return;
|
|
4686
4769
|
}
|
|
4687
4770
|
case 'add': {
|
|
4688
|
-
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); }
|
|
4689
4772
|
const tools = agentsMod.parseToolsFlag(flags.tools);
|
|
4690
4773
|
try {
|
|
4691
4774
|
const a = agentsMod.registerAgent({
|
|
@@ -4696,6 +4779,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
4696
4779
|
model: flags.model || '',
|
|
4697
4780
|
tools: tools === null ? undefined : tools,
|
|
4698
4781
|
tags: agentsMod.parseToolsFlag(flags.tags) || [],
|
|
4782
|
+
skillWrite: flags['skill-write'],
|
|
4699
4783
|
}, cfgDir);
|
|
4700
4784
|
emitJson(a);
|
|
4701
4785
|
} catch (err) {
|
|
@@ -4712,7 +4796,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
4712
4796
|
return;
|
|
4713
4797
|
}
|
|
4714
4798
|
case 'edit': {
|
|
4715
|
-
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); }
|
|
4716
4800
|
const patch = {};
|
|
4717
4801
|
if (flags.role !== undefined) patch.role = String(flags.role);
|
|
4718
4802
|
if (flags.provider !== undefined) patch.provider = String(flags.provider);
|
|
@@ -4721,6 +4805,8 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
4721
4805
|
if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
|
|
4722
4806
|
if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
|
|
4723
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']);
|
|
4724
4810
|
if (Object.keys(patch).length === 0) {
|
|
4725
4811
|
console.error('agent edit: no fields to update');
|
|
4726
4812
|
process.exit(2);
|
|
@@ -4796,11 +4882,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
4796
4882
|
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
4797
4883
|
const cfg = readConfig();
|
|
4798
4884
|
const apiKey = _resolveAuthKey(cfg, a.provider);
|
|
4799
|
-
const baseUrl =
|
|
4800
|
-
anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
|
|
4801
|
-
openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
|
|
4802
|
-
gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
|
|
4803
|
-
}[a.provider] || undefined;
|
|
4885
|
+
const baseUrl = _resolveBaseUrl(a.provider);
|
|
4804
4886
|
try {
|
|
4805
4887
|
const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
|
|
4806
4888
|
if (!body || !body.trim()) {
|
|
@@ -4817,8 +4899,50 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
|
4817
4899
|
}
|
|
4818
4900
|
return;
|
|
4819
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
|
+
}
|
|
4820
4944
|
default:
|
|
4821
|
-
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> ...');
|
|
4822
4946
|
process.exit(2);
|
|
4823
4947
|
}
|
|
4824
4948
|
}
|
|
@@ -4929,6 +5053,165 @@ async function cmdSlack(sub, positional, flags = {}) {
|
|
|
4929
5053
|
});
|
|
4930
5054
|
}
|
|
4931
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
|
+
|
|
4932
5215
|
async function cmdSkills(sub, positional, flags = {}) {
|
|
4933
5216
|
const skillsMod = await import('./skills.mjs');
|
|
4934
5217
|
const cfgDir = path.dirname(configPath());
|
|
@@ -5120,8 +5403,24 @@ async function cmdSkills(sub, positional, flags = {}) {
|
|
|
5120
5403
|
console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
|
|
5121
5404
|
return;
|
|
5122
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
|
+
}
|
|
5123
5422
|
default:
|
|
5124
|
-
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>>');
|
|
5125
5424
|
process.exit(2);
|
|
5126
5425
|
}
|
|
5127
5426
|
}
|
|
@@ -5997,6 +6296,8 @@ async function _dispatchMenuChoice(argv) {
|
|
|
5997
6296
|
case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
|
|
5998
6297
|
case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
|
|
5999
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), {});
|
|
6000
6301
|
case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
|
|
6001
6302
|
case 'task': return await cmdTask(rest[0], rest.slice(1), {});
|
|
6002
6303
|
case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
|
|
@@ -6548,6 +6849,16 @@ async function main() {
|
|
|
6548
6849
|
await cmdSlack(sub, rest.positional.slice(1), rest.flags);
|
|
6549
6850
|
break;
|
|
6550
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
|
+
}
|
|
6551
6862
|
case 'team': {
|
|
6552
6863
|
const sub = rest.positional[0];
|
|
6553
6864
|
await cmdTeam(sub, rest.positional.slice(1), rest.flags);
|
package/daemon.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { withFallback } from './providers/fallback.mjs';
|
|
|
21
21
|
import { withResponseCache } from './providers/cache.mjs';
|
|
22
22
|
import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
|
|
23
23
|
import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill } from './skills.mjs';
|
|
24
|
+
import { ChallengeRegistry } from './gateway/device_auth.mjs';
|
|
25
|
+
import { createGateway } from './gateway/http_gateway.mjs';
|
|
24
26
|
import { TokenBucketLimiter } from './ratelimit.mjs';
|
|
25
27
|
import { createLogger } from './logger.mjs';
|
|
26
28
|
import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
|
|
@@ -321,6 +323,11 @@ export function makeHandler(ctx) {
|
|
|
321
323
|
costsByCurrency: /** @type {Record<string, number>} */({}),
|
|
322
324
|
tokensTotal: { inputTokens: 0, outputTokens: 0 },
|
|
323
325
|
};
|
|
326
|
+
// Device gateway (Phase 27). The ChallengeRegistry is a per-process
|
|
327
|
+
// singleton: a challenge minted by one request is consumed by a later
|
|
328
|
+
// one, so it must outlive a single call.
|
|
329
|
+
const gwConfigDir = typeof ctx.sessionsDirGetter === 'function' ? ctx.sessionsDirGetter() : undefined;
|
|
330
|
+
const gateway = createGateway({ configDir: gwConfigDir, challengeRegistry: new ChallengeRegistry(), heartbeatMs: 25000 });
|
|
324
331
|
return async function handler(req, res) {
|
|
325
332
|
// Capture method+path before any handler logic runs; req.url survives
|
|
326
333
|
// the response but capturing now keeps the log line stable even if a
|
|
@@ -361,6 +368,31 @@ export function makeHandler(ctx) {
|
|
|
361
368
|
if (!isOriginAllowed(req, allowedOrigins, allowLoopback)) {
|
|
362
369
|
return writeJson(res, 403, { error: 'forbidden origin' });
|
|
363
370
|
}
|
|
371
|
+
// Device gateway (Phase 27) — routed BEFORE the shared auth-token
|
|
372
|
+
// gate. Companion-node auth is the gateway's own Ed25519 device-auth
|
|
373
|
+
// (challenge/sign/approve + bearer token); the only unauthenticated
|
|
374
|
+
// route, /gateway/connect/challenge, returns nothing but a random
|
|
375
|
+
// nonce. The bypass decision uses the NORMALIZED pathname (the same
|
|
376
|
+
// one the gateway routes on) so a dot-segment path like
|
|
377
|
+
// `/gateway/../sessions` can't skip the auth-token gate — it
|
|
378
|
+
// normalizes to `/sessions`, fails this prefix test, and falls
|
|
379
|
+
// through to the protected handler. When the limiter is enabled,
|
|
380
|
+
// gateway traffic uses its own key namespace so an unauthenticated
|
|
381
|
+
// flood can't drain the authenticated user's per-IP budget.
|
|
382
|
+
let gwPath = '';
|
|
383
|
+
try { gwPath = new URL(req.url || '/', 'http://localhost').pathname; } catch { gwPath = ''; }
|
|
384
|
+
if (gwPath.startsWith('/gateway/')) {
|
|
385
|
+
if (limiter) {
|
|
386
|
+
const key = 'gw:' + (req.socket?.remoteAddress || 'no-socket');
|
|
387
|
+
const verdict = limiter.consume(key);
|
|
388
|
+
if (!verdict.allowed) {
|
|
389
|
+
metrics.rateLimitDenied += 1;
|
|
390
|
+
const retrySeconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1000));
|
|
391
|
+
return writeJson(res, 429, { error: 'rate limit exceeded', retryAfterMs: verdict.retryAfterMs }, { 'retry-after': String(retrySeconds) });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return await gateway.handle(req, res, { readBody: readTextBody });
|
|
395
|
+
}
|
|
364
396
|
// Authentication gate — when authToken is set, every request must
|
|
365
397
|
// present `Authorization: Bearer <token>`. This is opt-in because
|
|
366
398
|
// the default deployment is loopback-only single-user; the token
|
|
@@ -430,6 +462,26 @@ export function makeHandler(ctx) {
|
|
|
430
462
|
}
|
|
431
463
|
case route === 'GET /version':
|
|
432
464
|
return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
|
|
465
|
+
case route === 'POST /exec/request': {
|
|
466
|
+
// Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
|
|
467
|
+
// (above), so only the trusted local operator/CLI can REQUEST an
|
|
468
|
+
// approval; a paired mobile device RESOLVES it over the gateway
|
|
469
|
+
// (POST /gateway/exec/resolve). The route long-polls: it awaits
|
|
470
|
+
// the device's decision (or the approval's timeout) and returns
|
|
471
|
+
// { approved, by, reason }.
|
|
472
|
+
let body;
|
|
473
|
+
try { body = await readJson(req); }
|
|
474
|
+
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
475
|
+
if (!body || typeof body.tool !== 'string' || !body.tool) {
|
|
476
|
+
return writeJson(res, 400, { error: 'tool is required' });
|
|
477
|
+
}
|
|
478
|
+
const { promise } = gateway.requestApproval(
|
|
479
|
+
{ tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
|
|
480
|
+
{ timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
|
|
481
|
+
);
|
|
482
|
+
const result = await promise;
|
|
483
|
+
return writeJson(res, 200, result);
|
|
484
|
+
}
|
|
433
485
|
case route === 'GET /health':
|
|
434
486
|
// Conventional liveness check — always 200 if the process
|
|
435
487
|
// is alive enough to hit the route. No config inspection
|
|
@@ -1486,6 +1538,52 @@ export function makeHandler(ctx) {
|
|
|
1486
1538
|
}, m.headers || {});
|
|
1487
1539
|
}
|
|
1488
1540
|
}
|
|
1541
|
+
case route === 'POST /inbound': {
|
|
1542
|
+
// Generic inbound bridge — a stable, channel-agnostic relay
|
|
1543
|
+
// target so ANY platform (a Discord/WhatsApp/etc. bot the user
|
|
1544
|
+
// runs elsewhere) can forward a message in and get one reply,
|
|
1545
|
+
// without lazyclaw shipping that platform's SDK. Auth-token
|
|
1546
|
+
// gated like every non-gateway route; additionally pairing-gated
|
|
1547
|
+
// on senderId when a pairing allowlist is configured.
|
|
1548
|
+
const breach = checkCostCap(metrics, costCap);
|
|
1549
|
+
if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
|
|
1550
|
+
let body;
|
|
1551
|
+
try { body = await readJson(req); }
|
|
1552
|
+
catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
|
|
1553
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
1554
|
+
if (!text) return writeJson(res, 400, { error: 'text is required' });
|
|
1555
|
+
const cfg = ctx.readConfig();
|
|
1556
|
+
// Pairing gate: when the operator has paired any senders, the
|
|
1557
|
+
// relay must identify an allowlisted senderId.
|
|
1558
|
+
const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
|
|
1559
|
+
if (allow.length > 0) {
|
|
1560
|
+
const sender = String(body.senderId || '');
|
|
1561
|
+
if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
|
|
1562
|
+
}
|
|
1563
|
+
const provName = body.provider || cfg.provider || 'mock';
|
|
1564
|
+
const resolved = resolveProvider(body, provName, cachedByName, logger);
|
|
1565
|
+
if (resolved.error) return writeJson(res, 400, { error: resolved.error });
|
|
1566
|
+
let acc = '';
|
|
1567
|
+
let inboundUsage = null;
|
|
1568
|
+
try {
|
|
1569
|
+
for await (const chunk of resolved.provider.sendMessage(
|
|
1570
|
+
[{ role: 'user', content: text }],
|
|
1571
|
+
{ apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
|
|
1572
|
+
)) acc += chunk;
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
const m = statusForProviderError(err);
|
|
1575
|
+
return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
|
|
1576
|
+
}
|
|
1577
|
+
// Feed the running spend total so the cost cap can actually trip
|
|
1578
|
+
// on /inbound traffic (mirrors POST /agent / POST /chat).
|
|
1579
|
+
if (inboundUsage && cfg.rates) {
|
|
1580
|
+
try {
|
|
1581
|
+
const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
|
|
1582
|
+
if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
|
|
1583
|
+
} catch { /* cost is best-effort; never block a reply on it */ }
|
|
1584
|
+
}
|
|
1585
|
+
return writeJson(res, 200, { reply: acc, threadId: body.threadId || null });
|
|
1586
|
+
}
|
|
1489
1587
|
case route === 'POST /agent': {
|
|
1490
1588
|
const breach = checkCostCap(metrics, costCap);
|
|
1491
1589
|
if (breach) {
|