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.
Files changed (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. 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
+ }