lazyclaw 4.2.2 → 5.0.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.ko.md +44 -0
- package/README.md +172 -353
- package/agents.mjs +19 -3
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +730 -27
- package/daemon.mjs +111 -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 +30 -1
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/mention_router.mjs +75 -4
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +331 -0
- package/mas/tool_runner.mjs +19 -48
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/skill_view.mjs +43 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +22 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
package/agents.mjs
CHANGED
|
@@ -19,8 +19,8 @@ import { ensureValidName as cronEnsureValidName } from './cron.mjs';
|
|
|
19
19
|
|
|
20
20
|
const AGENTS_DIRNAME = 'agents';
|
|
21
21
|
|
|
22
|
-
export const DEFAULT_TOOLS = ['bash', 'read', 'write', 'grep'];
|
|
23
|
-
export const ALL_TOOLS = ['bash', 'read', 'write', 'grep', 'web_search', 'web_fetch', 'slack_post'];
|
|
22
|
+
export const DEFAULT_TOOLS = ['bash', 'read', 'write', 'grep', 'skill_view'];
|
|
23
|
+
export const ALL_TOOLS = ['bash', 'read', 'write', 'grep', 'skill_view', 'web_search', 'web_fetch', 'slack_post'];
|
|
24
24
|
|
|
25
25
|
export class AgentError extends Error {
|
|
26
26
|
constructor(message, code) {
|
|
@@ -80,6 +80,13 @@ function defaultShape(name) {
|
|
|
80
80
|
// for `lazyclaw agent reflect`; 'off' disables writes entirely.
|
|
81
81
|
memoryWrite: 'auto',
|
|
82
82
|
memoryMaxChars: 12 * 1024,
|
|
83
|
+
// Phase 20 — self-improving skill synthesis trigger. 'manual'
|
|
84
|
+
// (default) means a skill is only written when the user runs
|
|
85
|
+
// `lazyclaw agent skill-synth`; 'auto' fires synthesis on terminal
|
|
86
|
+
// `done` alongside reflection; 'off' disables it. Defaults to
|
|
87
|
+
// 'manual' (unlike memoryWrite) because a synthesised SKILL.md
|
|
88
|
+
// feeds every future agent's prompt, so we keep it opt-in.
|
|
89
|
+
skillWrite: 'manual',
|
|
83
90
|
createdAt: new Date().toISOString(),
|
|
84
91
|
updatedAt: new Date().toISOString(),
|
|
85
92
|
};
|
|
@@ -94,8 +101,9 @@ function writeAtomic(filePath, obj) {
|
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
const VALID_MEMORY_WRITE = ['auto', 'manual', 'off'];
|
|
104
|
+
const VALID_SKILL_WRITE = ['auto', 'manual', 'off'];
|
|
97
105
|
|
|
98
|
-
export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars } = {}, configDir = defaultConfigDir()) {
|
|
106
|
+
export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars, skillWrite } = {}, configDir = defaultConfigDir()) {
|
|
99
107
|
ensureValidName(name);
|
|
100
108
|
const p = agentPath(name, configDir);
|
|
101
109
|
if (fs.existsSync(p)) {
|
|
@@ -106,6 +114,10 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
|
|
|
106
114
|
if (!VALID_MEMORY_WRITE.includes(mw)) {
|
|
107
115
|
throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
|
|
108
116
|
}
|
|
117
|
+
const sw = skillWrite ?? 'manual';
|
|
118
|
+
if (!VALID_SKILL_WRITE.includes(sw)) {
|
|
119
|
+
throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
|
|
120
|
+
}
|
|
109
121
|
const data = {
|
|
110
122
|
...defaultShape(name),
|
|
111
123
|
displayName: displayName || titleCase(name),
|
|
@@ -117,6 +129,7 @@ export function registerAgent({ name, displayName, role = '', provider = 'claude
|
|
|
117
129
|
iconEmoji: String(iconEmoji || ''),
|
|
118
130
|
memoryWrite: mw,
|
|
119
131
|
memoryMaxChars: Number.isFinite(+memoryMaxChars) && +memoryMaxChars > 0 ? +memoryMaxChars : 12 * 1024,
|
|
132
|
+
skillWrite: sw,
|
|
120
133
|
};
|
|
121
134
|
writeAtomic(p, data);
|
|
122
135
|
return data;
|
|
@@ -155,6 +168,9 @@ export function patchAgent(name, patch, configDir = defaultConfigDir()) {
|
|
|
155
168
|
if (patch.memoryWrite !== undefined && !VALID_MEMORY_WRITE.includes(patch.memoryWrite)) {
|
|
156
169
|
throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
|
|
157
170
|
}
|
|
171
|
+
if (patch.skillWrite !== undefined && !VALID_SKILL_WRITE.includes(patch.skillWrite)) {
|
|
172
|
+
throw new AgentError(`skillWrite must be one of ${VALID_SKILL_WRITE.join(', ')}`, 'AGENT_BAD_SKILL_WRITE');
|
|
173
|
+
}
|
|
158
174
|
writeAtomic(agentPath(name, configDir), next);
|
|
159
175
|
return next;
|
|
160
176
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// channels/handoff.mjs
|
|
2
|
+
//
|
|
3
|
+
// Migrates an active thread (sessionId) from one channel to another.
|
|
4
|
+
// Pure function over (threads store, live channel map) — the CLI slash
|
|
5
|
+
// and the daemon HTTP route both call this.
|
|
6
|
+
|
|
7
|
+
export async function runHandoff({ threads, channels, threadId, target, externalId, note = '' }) {
|
|
8
|
+
const cur = threads.findByThread(threadId);
|
|
9
|
+
if (!cur) {
|
|
10
|
+
const err = new Error(`THREAD_NOT_FOUND: ${threadId}`);
|
|
11
|
+
err.code = 'THREAD_NOT_FOUND';
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
if (!channels[target] || typeof channels[target].send !== 'function') {
|
|
15
|
+
const err = new Error(`CHANNEL_NOT_AVAILABLE: ${target}`);
|
|
16
|
+
err.code = 'CHANNEL_NOT_AVAILABLE';
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
const srcChannel = cur.channel;
|
|
20
|
+
const srcExternal = cur.externalId;
|
|
21
|
+
|
|
22
|
+
// 1. Persist the migration first so a crash mid-notify leaves us in the new home.
|
|
23
|
+
const next = threads.handoff(threadId, { channel: target, externalId });
|
|
24
|
+
|
|
25
|
+
// 2. Notify source (best-effort) so the human knows where the convo went.
|
|
26
|
+
const tail = note ? ` — ${note}` : '';
|
|
27
|
+
if (channels[srcChannel] && typeof channels[srcChannel].send === 'function') {
|
|
28
|
+
try {
|
|
29
|
+
await channels[srcChannel].send(srcExternal,
|
|
30
|
+
`handoff: this conversation moved to ${target}${tail}`);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
process.stderr.write(`[handoff] source notify failed: ${e.message}\n`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3. Notify target with a resume marker.
|
|
37
|
+
await channels[target].send(externalId,
|
|
38
|
+
`resumed from ${srcChannel} (session ${next.sessionId})${tail}`);
|
|
39
|
+
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// channels/loader.mjs
|
|
2
|
+
//
|
|
3
|
+
// Plugin loader for @lazyclaw/channel-<name> packages. Installs into
|
|
4
|
+
// <configDir>/node_modules via `npm install <spec>` and dynamic-imports
|
|
5
|
+
// the entry, calling the package's exported register({Channel, addChannel}).
|
|
6
|
+
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { Channel } from './base.mjs';
|
|
12
|
+
|
|
13
|
+
const PLUGIN_RE = /^@lazyclaw\/channel-[a-z][a-z0-9-]*$/;
|
|
14
|
+
|
|
15
|
+
export function isPluginName(name) {
|
|
16
|
+
return typeof name === 'string' && PLUGIN_RE.test(name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function listInstalled(configDir) {
|
|
20
|
+
const root = path.join(String(configDir), 'node_modules', '@lazyclaw');
|
|
21
|
+
if (!fs.existsSync(root)) return [];
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const entry of fs.readdirSync(root)) {
|
|
24
|
+
if (!entry.startsWith('channel-')) continue;
|
|
25
|
+
const pj = path.join(root, entry, 'package.json');
|
|
26
|
+
if (!fs.existsSync(pj)) continue;
|
|
27
|
+
try {
|
|
28
|
+
const meta = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
29
|
+
if (!isPluginName(meta.name)) continue;
|
|
30
|
+
out.push({ name: meta.name, version: meta.version || '0.0.0' });
|
|
31
|
+
} catch { /* skip */ }
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createLoader({ configDir, skipInstall = false, npmBin = 'npm' } = {}) {
|
|
37
|
+
if (!configDir) throw new Error('createLoader: configDir required');
|
|
38
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
/** @type {Map<string, (opts:any)=>Channel>} */
|
|
41
|
+
const factories = new Map();
|
|
42
|
+
|
|
43
|
+
function addChannel(kind, factory) {
|
|
44
|
+
if (typeof factory !== 'function') {
|
|
45
|
+
throw new Error(`plugin "${kind}" register() must call addChannel(name, factory)`);
|
|
46
|
+
}
|
|
47
|
+
factories.set(kind, factory);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getFactory(kind) {
|
|
51
|
+
return factories.get(kind) || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listKinds() {
|
|
55
|
+
return Array.from(factories.keys()).sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadFromPath(declaredName, pkgDir) {
|
|
59
|
+
if (!isPluginName(declaredName)) {
|
|
60
|
+
const err = new Error(`INVALID_PLUGIN_NAME: ${declaredName}`);
|
|
61
|
+
err.code = 'INVALID_PLUGIN_NAME';
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
const pj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
65
|
+
const entryRel = pj.main || 'index.mjs';
|
|
66
|
+
const entry = path.join(pkgDir, entryRel);
|
|
67
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
68
|
+
if (typeof mod.register !== 'function') {
|
|
69
|
+
throw new Error(`plugin ${declaredName} missing register() export`);
|
|
70
|
+
}
|
|
71
|
+
await mod.register({ Channel, addChannel });
|
|
72
|
+
return { name: declaredName, version: pj.version || '0.0.0' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function install(name) {
|
|
76
|
+
if (!isPluginName(name)) {
|
|
77
|
+
const err = new Error(`INVALID_PLUGIN_NAME: ${name}`);
|
|
78
|
+
err.code = 'INVALID_PLUGIN_NAME';
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
if (!skipInstall) {
|
|
82
|
+
const res = spawnSync(npmBin, ['install', '--no-audit', '--no-fund', name], {
|
|
83
|
+
cwd: configDir, stdio: 'inherit', env: process.env,
|
|
84
|
+
});
|
|
85
|
+
if (res.status !== 0) {
|
|
86
|
+
throw new Error(`npm install ${name} exited ${res.status}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const pkgDir = path.join(configDir, 'node_modules', name);
|
|
90
|
+
return loadFromPath(name, pkgDir);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function remove(name) {
|
|
94
|
+
if (!isPluginName(name)) {
|
|
95
|
+
const err = new Error(`INVALID_PLUGIN_NAME: ${name}`);
|
|
96
|
+
err.code = 'INVALID_PLUGIN_NAME';
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
const res = spawnSync(npmBin, ['uninstall', name], {
|
|
100
|
+
cwd: configDir, stdio: 'inherit', env: process.env,
|
|
101
|
+
});
|
|
102
|
+
if (res.status !== 0) throw new Error(`npm uninstall ${name} exited ${res.status}`);
|
|
103
|
+
// factories map stays — restart of daemon picks up the change.
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadAllInstalled() {
|
|
107
|
+
const installed = listInstalled(configDir);
|
|
108
|
+
const loaded = [];
|
|
109
|
+
for (const { name } of installed) {
|
|
110
|
+
try {
|
|
111
|
+
const pkgDir = path.join(configDir, 'node_modules', name);
|
|
112
|
+
loaded.push(await loadFromPath(name, pkgDir));
|
|
113
|
+
} catch (e) {
|
|
114
|
+
process.stderr.write(`[channels] failed to load ${name}: ${e.message}\n`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return loaded;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
addChannel, getFactory, listKinds,
|
|
122
|
+
loadFromPath, loadAllInstalled, install, remove,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Matrix channel adapter.
|
|
2
|
+
//
|
|
3
|
+
// Speaks the Matrix client-server HTTP API directly — no SDK dependency,
|
|
4
|
+
// mirroring Telegram's getUpdates long-poll discipline. Two secrets are
|
|
5
|
+
// read from the constructor or the environment ONLY (never from goal
|
|
6
|
+
// files, never logged):
|
|
7
|
+
// MATRIX_HOMESERVER https://matrix.org — the homeserver base URL
|
|
8
|
+
// MATRIX_ACCESS_TOKEN syt_… — bearer token for every call
|
|
9
|
+
//
|
|
10
|
+
// Inbound arrives via long-poll `GET /_matrix/client/v3/sync`: the request
|
|
11
|
+
// is held open up to `timeout` ms; when room timeline events arrive we
|
|
12
|
+
// route every `m.room.message` of `msgtype: m.text` through
|
|
13
|
+
// `_simulateInbound(syncResponse)`, which calls
|
|
14
|
+
// `handler({ channel:'matrix', threadId:'matrix:<roomId>', text, senderId })`
|
|
15
|
+
// and posts the reply with `send()`. The `since` token is advanced from the
|
|
16
|
+
// sync response's `next_batch` ONLY after the batch is processed without
|
|
17
|
+
// throwing, so a failed reply isn't silently dropped (mirrors Telegram's
|
|
18
|
+
// _processBatch offset-after-success discipline).
|
|
19
|
+
//
|
|
20
|
+
// Outbound (`send(threadId, text)`) issues
|
|
21
|
+
// `PUT /_matrix/client/v3/rooms/<roomId>/send/m.room.message/<txnId>` with a
|
|
22
|
+
// unique counter-based txnId so the homeserver doesn't dedupe distinct
|
|
23
|
+
// replies.
|
|
24
|
+
//
|
|
25
|
+
// `start({ poll: false })` validates credentials and registers the handler
|
|
26
|
+
// without bringing up the poll loop, so unit tests can drive
|
|
27
|
+
// `_simulateInbound` / `send` directly. The default poll path is intended to
|
|
28
|
+
// be driven by a `matrix listen` subcommand (mirrors `telegram listen`).
|
|
29
|
+
//
|
|
30
|
+
// LAZYCLAW_MATRIX_API_BASE (or opts.apiBase) overrides the API base URL so
|
|
31
|
+
// the Phase 30 spec can point the adapter at a local mock HTTP server. When
|
|
32
|
+
// unset it defaults to the homeserver.
|
|
33
|
+
|
|
34
|
+
import { Channel, ChannelGated } from './base.mjs';
|
|
35
|
+
|
|
36
|
+
const THREAD_PREFIX = 'matrix';
|
|
37
|
+
// Server-side long-poll window for /sync (milliseconds). The homeserver
|
|
38
|
+
// holds the request open up to this long when no events are pending, so an
|
|
39
|
+
// idle bot makes ~1 request per LONG_POLL_MS instead of spinning.
|
|
40
|
+
const LONG_POLL_MS = 30000;
|
|
41
|
+
|
|
42
|
+
export class MatrixError extends Error {
|
|
43
|
+
constructor(message, code) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'MatrixError';
|
|
46
|
+
this.code = code || 'MATRIX_ERR';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Resolve credentials + base URLs, preferring an explicit override, then
|
|
51
|
+
// the env. Trailing slashes are trimmed so paths join cleanly. apiBase
|
|
52
|
+
// defaults to the homeserver when neither override nor env is set.
|
|
53
|
+
export function readMatrixEnv(env = process.env) {
|
|
54
|
+
return {
|
|
55
|
+
homeserver: env.MATRIX_HOMESERVER || null,
|
|
56
|
+
accessToken: env.MATRIX_ACCESS_TOKEN || null,
|
|
57
|
+
userId: env.MATRIX_USER_ID || null,
|
|
58
|
+
apiBase: env.LAZYCLAW_MATRIX_API_BASE || null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Extract the routable text messages from a parsed /sync response. Walks
|
|
63
|
+
// `rooms.join.<roomId>.timeline.events`, keeps only `m.room.message` events
|
|
64
|
+
// whose `content.msgtype` is `m.text`, and returns one normalized event per
|
|
65
|
+
// message. Returns [] for any shape we don't handle so callers can skip
|
|
66
|
+
// without special-casing. Kept as a pure export so the filter is unit
|
|
67
|
+
// testable without a transport.
|
|
68
|
+
export function extractMessageEvents(syncResponse) {
|
|
69
|
+
if (!syncResponse || typeof syncResponse !== 'object') return [];
|
|
70
|
+
const join = syncResponse.rooms && syncResponse.rooms.join;
|
|
71
|
+
if (!join || typeof join !== 'object') return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const roomId of Object.keys(join)) {
|
|
74
|
+
const room = join[roomId];
|
|
75
|
+
const events = room && room.timeline && Array.isArray(room.timeline.events)
|
|
76
|
+
? room.timeline.events
|
|
77
|
+
: [];
|
|
78
|
+
for (const ev of events) {
|
|
79
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
80
|
+
if (ev.type !== 'm.room.message') continue;
|
|
81
|
+
const content = ev.content;
|
|
82
|
+
if (!content || typeof content !== 'object') continue;
|
|
83
|
+
if (content.msgtype !== 'm.text') continue;
|
|
84
|
+
const text = typeof content.body === 'string' ? content.body : '';
|
|
85
|
+
const senderId = ev.sender != null ? String(ev.sender) : null;
|
|
86
|
+
out.push({
|
|
87
|
+
roomId,
|
|
88
|
+
text,
|
|
89
|
+
senderId,
|
|
90
|
+
eventId: ev.event_id != null ? String(ev.event_id) : null,
|
|
91
|
+
threadId: `${THREAD_PREFIX}:${roomId}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class MatrixChannel extends Channel {
|
|
99
|
+
constructor(opts = {}) {
|
|
100
|
+
super('matrix');
|
|
101
|
+
this._env = { ...readMatrixEnv(), ...opts };
|
|
102
|
+
// apiBase falls back to the homeserver when no override is supplied.
|
|
103
|
+
if (!this._env.apiBase) this._env.apiBase = this._env.homeserver;
|
|
104
|
+
// A pairing allowlist of Matrix user ids (strings). When set, only
|
|
105
|
+
// senders on the list reach the handler; everything else is dropped
|
|
106
|
+
// silently (no handler call, no reply leak to an unpaired room).
|
|
107
|
+
const allow = opts.allowlist || opts.allowedSenders || null;
|
|
108
|
+
this._allowlist = Array.isArray(allow) ? new Set(allow.map((id) => String(id))) : null;
|
|
109
|
+
this._pollHandle = null; // { stop() } once the loop is running
|
|
110
|
+
this._since = opts.since || null; // /sync batch cursor
|
|
111
|
+
this._txnCounter = 0; // monotonic txnId source (deterministic-friendly)
|
|
112
|
+
this._inflight = null; // AbortController for the held-open /sync
|
|
113
|
+
// Per-event dedup: when a mid-batch send fails we leave `since`
|
|
114
|
+
// un-advanced and the homeserver re-delivers the WHOLE batch, so we
|
|
115
|
+
// remember already-handled event ids (bounded, FIFO-evicted) to avoid
|
|
116
|
+
// re-replying to events that already got a reply.
|
|
117
|
+
this._seen = new Set();
|
|
118
|
+
this._seenOrder = [];
|
|
119
|
+
this._seenCap = 2000;
|
|
120
|
+
// Diagnostic sink. Defaults to a no-op until start() wires one up so
|
|
121
|
+
// _simulateInbound can log internal errors without leaking them to the
|
|
122
|
+
// room. Replaced (never appended) on every start().
|
|
123
|
+
this._logger = () => {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Begin accepting messages. With the default `poll: true` this spins up
|
|
127
|
+
// the long-poll loop; tests pass `poll: false` to keep the adapter pure
|
|
128
|
+
// and drive `_simulateInbound` / `send` directly.
|
|
129
|
+
//
|
|
130
|
+
// opts (beyond the base gate):
|
|
131
|
+
// poll?: boolean — start the /sync loop (default true)
|
|
132
|
+
// since?: string — initial sync cursor (default none → full sync)
|
|
133
|
+
// timeoutMs?: number — server-side long-poll window (default 30000)
|
|
134
|
+
// logger?: (line) => void — diagnostic sink (stderr in CLI, no-op in tests)
|
|
135
|
+
async start(handler, opts = {}) {
|
|
136
|
+
if (!this._env.accessToken) {
|
|
137
|
+
throw new MatrixError(
|
|
138
|
+
'cannot start Matrix channel without an access token — set MATRIX_ACCESS_TOKEN or pass { accessToken }',
|
|
139
|
+
'MATRIX_MISSING_TOKEN'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
// The homeserver identifies the bot's domain and is required even when
|
|
143
|
+
// an explicit apiBase override (test mock) supplies the transport host.
|
|
144
|
+
if (!this._env.homeserver) {
|
|
145
|
+
throw new MatrixError(
|
|
146
|
+
'cannot start Matrix channel without a homeserver — set MATRIX_HOMESERVER or pass { homeserver }',
|
|
147
|
+
'MATRIX_MISSING_HOMESERVER'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (!this._env.apiBase) {
|
|
151
|
+
throw new MatrixError(
|
|
152
|
+
'cannot resolve a Matrix API base URL — set MATRIX_HOMESERVER, LAZYCLAW_MATRIX_API_BASE, or pass { apiBase }',
|
|
153
|
+
'MATRIX_MISSING_API_BASE'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
await super.start(handler, opts);
|
|
157
|
+
this._logger = typeof opts.logger === 'function' ? opts.logger : () => {};
|
|
158
|
+
if (typeof opts.since === 'string') this._since = opts.since;
|
|
159
|
+
this._timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : LONG_POLL_MS;
|
|
160
|
+
const poll = opts.poll !== false; // default true
|
|
161
|
+
if (poll) this._startPollLoop({ logger: this._logger });
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Called by the poll loop (or tests) for each parsed /sync response. Walks
|
|
166
|
+
// the room timelines, enforces the self-filter + pairing allowlist, calls
|
|
167
|
+
// the handler per text message, and posts the reply back to the
|
|
168
|
+
// originating room. A null / empty-string reply skips the send entirely so
|
|
169
|
+
// a handler that decides to stay silent doesn't leak a placeholder. Throws
|
|
170
|
+
// if any send() throws so the caller can decline to advance the `since`
|
|
171
|
+
// cursor (mirrors Telegram's _processBatch).
|
|
172
|
+
async _simulateInbound(syncResponse) {
|
|
173
|
+
const events = extractMessageEvents(syncResponse);
|
|
174
|
+
for (const evt of events) {
|
|
175
|
+
// Already handled in a prior (re-delivered) batch → don't re-reply.
|
|
176
|
+
if (evt.eventId && this._seen.has(evt.eventId)) continue;
|
|
177
|
+
// Never reply to ourselves — that's an infinite loop. (Not marked
|
|
178
|
+
// seen: a no-op skip is cheap to re-evaluate, and marking it could
|
|
179
|
+
// mask a genuinely different later event.)
|
|
180
|
+
if (this._env.userId && evt.senderId === String(this._env.userId)) continue;
|
|
181
|
+
// Not paired — drop silently. We deliberately do NOT reply so an
|
|
182
|
+
// unknown room can't be used to probe the bot. (Also not marked seen
|
|
183
|
+
// — re-evaluating is a cheap no-op with no side effect.)
|
|
184
|
+
if (this._allowlist && (!evt.senderId || !this._allowlist.has(evt.senderId))) continue;
|
|
185
|
+
|
|
186
|
+
let reply;
|
|
187
|
+
try {
|
|
188
|
+
reply = await this._processInbound({
|
|
189
|
+
threadId: evt.threadId,
|
|
190
|
+
text: evt.text,
|
|
191
|
+
// base.mjs's bucket gate reads req.token || req.key, so the sender
|
|
192
|
+
// id rides under `key`: an authToken gate compares against it and a
|
|
193
|
+
// rate-limit gate keys per-sender. We keep senderId for downstream
|
|
194
|
+
// handler context too.
|
|
195
|
+
gateInput: { key: evt.senderId, senderId: evt.senderId },
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof ChannelGated || err?.code === 'CHANNEL_GATED') {
|
|
199
|
+
// A gate denial is an expected, user-facing condition; the reason
|
|
200
|
+
// ('rate_limited' / 'unauthorized') is safe to surface.
|
|
201
|
+
await this.send(evt.threadId, `(gated: ${err.message})`);
|
|
202
|
+
this._markSeen(evt.eventId);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// An unexpected handler/transport error may carry internal detail
|
|
206
|
+
// (stack, secrets in messages). Reply a generic notice to the room
|
|
207
|
+
// and log the full error to the diagnostic sink only.
|
|
208
|
+
this._logger(`[matrix] handler error: ${err?.stack || err?.message || err}\n`);
|
|
209
|
+
try {
|
|
210
|
+
await this.send(evt.threadId, '(internal error)');
|
|
211
|
+
} catch (sendErr) {
|
|
212
|
+
this._logger(`[matrix] failed to deliver error notice: ${sendErr?.message || sendErr}\n`);
|
|
213
|
+
}
|
|
214
|
+
this._markSeen(evt.eventId);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (reply == null || (typeof reply === 'string' && reply.trim() === '')) { this._markSeen(evt.eventId); continue; }
|
|
218
|
+
// If this send throws, the event is left UNSEEN so the re-delivered
|
|
219
|
+
// batch retries it (while already-replied events above are skipped).
|
|
220
|
+
await this.send(evt.threadId, reply);
|
|
221
|
+
this._markSeen(evt.eventId);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Remember a handled event id, FIFO-evicting beyond the cap so the set
|
|
226
|
+
// can't grow without bound on a long-lived listener.
|
|
227
|
+
_markSeen(eventId) {
|
|
228
|
+
if (!eventId || this._seen.has(eventId)) return;
|
|
229
|
+
this._seen.add(eventId);
|
|
230
|
+
this._seenOrder.push(eventId);
|
|
231
|
+
if (this._seenOrder.length > this._seenCap) {
|
|
232
|
+
const old = this._seenOrder.shift();
|
|
233
|
+
this._seen.delete(old);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The base _processInbound forwards { channel, threadId, text }; we enrich
|
|
238
|
+
// the event the router sees with senderId so memory / pairing hooks
|
|
239
|
+
// downstream can key on the human. Override stays in lockstep with
|
|
240
|
+
// base.mjs's contract — it only adds fields, never drops them.
|
|
241
|
+
async _processInbound({ threadId, text, gateInput }) {
|
|
242
|
+
if (this._gate) {
|
|
243
|
+
const verdict = this._gate.check(gateInput || {});
|
|
244
|
+
if (!verdict.ok) {
|
|
245
|
+
const err = new Error(verdict.reason || 'denied');
|
|
246
|
+
err.code = 'CHANNEL_GATED';
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!this._handler) throw new Error(`channel "${this.name}" has no handler`);
|
|
251
|
+
return await this._handler({
|
|
252
|
+
channel: this.name,
|
|
253
|
+
threadId,
|
|
254
|
+
text,
|
|
255
|
+
senderId: gateInput && gateInput.senderId != null ? gateInput.senderId : null,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Deliver a reply. threadId encodes the room as `matrix:<roomId>` (the
|
|
260
|
+
// shape extractMessageEvents emits); a bare room id is also accepted so
|
|
261
|
+
// callers can address a room directly.
|
|
262
|
+
async send(threadId, text, _opts = {}) {
|
|
263
|
+
if (!this._env.accessToken) throw new MatrixError('cannot send without a Matrix access token', 'MATRIX_NO_TOKEN');
|
|
264
|
+
const roomId = this._decodeRoomId(threadId);
|
|
265
|
+
if (!roomId) throw new MatrixError(`cannot resolve roomId from threadId "${threadId}"`, 'MATRIX_BAD_THREAD');
|
|
266
|
+
const txnId = this._nextTxnId();
|
|
267
|
+
const base = String(this._env.apiBase).replace(/\/$/, '');
|
|
268
|
+
const url = `${base}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`;
|
|
269
|
+
const body = { msgtype: 'm.text', body: String(text) };
|
|
270
|
+
let res;
|
|
271
|
+
try {
|
|
272
|
+
res = await fetch(url, {
|
|
273
|
+
method: 'PUT',
|
|
274
|
+
headers: {
|
|
275
|
+
'Authorization': `Bearer ${this._env.accessToken}`,
|
|
276
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify(body),
|
|
279
|
+
});
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new MatrixError(`matrix send transport error: ${err?.message || err}`, 'MATRIX_TRANSPORT');
|
|
282
|
+
}
|
|
283
|
+
if (!res.ok) {
|
|
284
|
+
throw new MatrixError(`matrix send failed: HTTP ${res.status}`, 'MATRIX_HTTP_FAIL');
|
|
285
|
+
}
|
|
286
|
+
const json = await res.json().catch(() => ({}));
|
|
287
|
+
if (json && json.errcode) {
|
|
288
|
+
throw new MatrixError(`matrix send failed: ${json.error || json.errcode}`, 'MATRIX_API_FAIL');
|
|
289
|
+
}
|
|
290
|
+
return json;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Translate a `matrix:<roomId>` threadId (or a bare room id) into a room
|
|
294
|
+
// id string. Room ids contain a ':' (e.g. !abc:example), so we only strip
|
|
295
|
+
// the leading `matrix:` prefix — never split on the first ':'.
|
|
296
|
+
_decodeRoomId(threadId) {
|
|
297
|
+
if (threadId == null) return null;
|
|
298
|
+
const s = String(threadId);
|
|
299
|
+
if (s.startsWith(`${THREAD_PREFIX}:`)) {
|
|
300
|
+
const rest = s.slice(THREAD_PREFIX.length + 1);
|
|
301
|
+
return rest || null;
|
|
302
|
+
}
|
|
303
|
+
return s || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Monotonic, deterministic-friendly transaction id. The homeserver dedupes
|
|
307
|
+
// PUT /send retries by (room, txnId), so each distinct reply needs its own
|
|
308
|
+
// id. We seed with the process start time so a restarted daemon doesn't
|
|
309
|
+
// collide with a previous run's low counters.
|
|
310
|
+
_nextTxnId() {
|
|
311
|
+
this._txnCounter += 1;
|
|
312
|
+
return `lazyclaw-${this._txnCounter}-${Date.now()}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Spin up the long-poll loop. Each iteration issues a single held-open
|
|
316
|
+
// /sync; the held-open request is what paces the idle loop (no per-turn
|
|
317
|
+
// sleep). The batch is handed to _simulateInbound which throws if a reply
|
|
318
|
+
// fails to deliver, in which case we leave `since` un-advanced so the
|
|
319
|
+
// homeserver re-delivers on the next poll. Errors are logged and the loop
|
|
320
|
+
// backs off rather than crashing the daemon. The in-flight AbortController
|
|
321
|
+
// is held so stop() can abort the ~30s held-open request for prompt
|
|
322
|
+
// shutdown.
|
|
323
|
+
_startPollLoop({ logger }) {
|
|
324
|
+
let stopped = false;
|
|
325
|
+
const loop = async () => {
|
|
326
|
+
while (!stopped) {
|
|
327
|
+
try {
|
|
328
|
+
const sync = await this._fetchSync(() => stopped);
|
|
329
|
+
if (stopped) break;
|
|
330
|
+
if (sync) {
|
|
331
|
+
await this._simulateInbound(sync);
|
|
332
|
+
// Advance the cursor ONLY after the batch processed without
|
|
333
|
+
// throwing, so a failed send isn't silently acked away.
|
|
334
|
+
if (sync.next_batch != null) this._since = sync.next_batch;
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (stopped) break;
|
|
338
|
+
if (err?.name === 'AbortError' || err?.code === 'MATRIX_ABORTED') break;
|
|
339
|
+
// A dead/forbidden token will never recover — stop the listener
|
|
340
|
+
// and surface it rather than spinning forever on a 500ms back-off.
|
|
341
|
+
if (err?.code === 'MATRIX_AUTH_FATAL') {
|
|
342
|
+
logger(`[matrix] FATAL: ${err.message} — stopping listener\n`);
|
|
343
|
+
this._fatal = err;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
logger(`[matrix] poll error: ${err?.message || err}\n`);
|
|
347
|
+
// Back off a beat so we don't spin hot against a failing endpoint.
|
|
348
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const promise = loop();
|
|
353
|
+
this._pollHandle = {
|
|
354
|
+
stop: async () => {
|
|
355
|
+
stopped = true;
|
|
356
|
+
// Abort the held-open /sync so shutdown doesn't block ~30s.
|
|
357
|
+
try { this._inflight?.abort(); } catch { /* best-effort */ }
|
|
358
|
+
try { await promise; } catch { /* best-effort */ }
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// One /sync call. Uses the homeserver's server-side long-poll (timeout in
|
|
364
|
+
// ms) so an idle bot holds a single request open. The held-open request's
|
|
365
|
+
// AbortController is stashed in `this._inflight` so stop() can cut it
|
|
366
|
+
// short. Returns the parsed sync response (or null when aborted mid-flight
|
|
367
|
+
// during shutdown). Kept separate from the loop so it stays unit-testable.
|
|
368
|
+
async _fetchSync(isStopped = () => false) {
|
|
369
|
+
const base = String(this._env.apiBase).replace(/\/$/, '');
|
|
370
|
+
const params = new URLSearchParams();
|
|
371
|
+
if (this._since) params.set('since', this._since);
|
|
372
|
+
params.set('timeout', String(this._timeoutMs ?? LONG_POLL_MS));
|
|
373
|
+
const url = `${base}/_matrix/client/v3/sync?${params.toString()}`;
|
|
374
|
+
const controller = new AbortController();
|
|
375
|
+
this._inflight = controller;
|
|
376
|
+
let res;
|
|
377
|
+
try {
|
|
378
|
+
res = await fetch(url, {
|
|
379
|
+
method: 'GET',
|
|
380
|
+
headers: { 'Authorization': `Bearer ${this._env.accessToken}` },
|
|
381
|
+
signal: controller.signal,
|
|
382
|
+
});
|
|
383
|
+
} catch (err) {
|
|
384
|
+
if (err?.name === 'AbortError') {
|
|
385
|
+
if (isStopped()) return null;
|
|
386
|
+
const e = new MatrixError('matrix sync aborted', 'MATRIX_ABORTED');
|
|
387
|
+
throw e;
|
|
388
|
+
}
|
|
389
|
+
throw new MatrixError(`matrix sync transport error: ${err?.message || err}`, 'MATRIX_TRANSPORT');
|
|
390
|
+
} finally {
|
|
391
|
+
this._inflight = null;
|
|
392
|
+
}
|
|
393
|
+
if (!res.ok) {
|
|
394
|
+
// 401/403 mean the access token is dead/forbidden — retrying forever
|
|
395
|
+
// is pointless. Mark it fatal so the loop stops and surfaces instead
|
|
396
|
+
// of spinning on a 500ms back-off against a revoked credential.
|
|
397
|
+
if (res.status === 401 || res.status === 403) {
|
|
398
|
+
throw new MatrixError(`matrix sync auth failed: HTTP ${res.status} (check MATRIX_ACCESS_TOKEN)`, 'MATRIX_AUTH_FATAL');
|
|
399
|
+
}
|
|
400
|
+
throw new MatrixError(`matrix sync failed: HTTP ${res.status}`, 'MATRIX_HTTP_FAIL');
|
|
401
|
+
}
|
|
402
|
+
const json = await res.json().catch(() => ({}));
|
|
403
|
+
return json && typeof json === 'object' ? json : {};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async stop() {
|
|
407
|
+
if (this._pollHandle && typeof this._pollHandle.stop === 'function') {
|
|
408
|
+
try { await this._pollHandle.stop(); } catch { /* best-effort */ }
|
|
409
|
+
}
|
|
410
|
+
this._pollHandle = null;
|
|
411
|
+
// Defensive: abort any straggling in-flight request even if the loop
|
|
412
|
+
// wasn't running (e.g. a bare _fetchSync was driven by a test).
|
|
413
|
+
try { this._inflight?.abort(); } catch { /* best-effort */ }
|
|
414
|
+
this._inflight = null;
|
|
415
|
+
await super.stop();
|
|
416
|
+
}
|
|
417
|
+
}
|