lazyclaw 4.2.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -301,6 +301,50 @@ lazyclaw agent reflect planner --task t_20260518_xxxxxx
301
301
  # and set "memoryWrite": "off" (other values: "auto" default, "manual").
302
302
  ```
303
303
 
304
+ ### Self-improving skills (v4.3)
305
+
306
+ Reflection writes free-text *lessons* to an agent's memory. A **skill**
307
+ goes further: it distils a finished task into a reusable, structured
308
+ `SKILL.md` (`## When to Use` / `## Procedure` / `## Pitfalls` /
309
+ `## Verification`) that any future agent can load. This is the Hermes
310
+ self-improving-skill pattern — synthesise once, recall forever.
311
+
312
+ ```bash
313
+ # Synthesise a skill from a finished task. Mirrors `agent reflect`:
314
+ # one LLM call over the transcript → a SKILL.md installed into
315
+ # ~/.lazyclaw/skills/<name>.md (frontmatter created_by: agent).
316
+ lazyclaw agent skill-synth planner --task t_20260518_xxxxxx
317
+ lazyclaw agent skill-synth planner --task t_20260518_xxxxxx --dry-run # print, don't write
318
+
319
+ # Opt an agent into AUTOMATIC synthesis on task done (default is manual):
320
+ lazyclaw agent add researcher --skill-write auto # auto | manual (default) | off
321
+ ```
322
+
323
+ **Recall is progressive-disclosure.** Every agent turn gets a compact
324
+ *index* of installed skills (name + one-line summary) injected into its
325
+ system prompt — cheap, a line per skill. The agent pulls a full skill
326
+ body on demand with the built-in read-only **`skill_view`** tool, so
327
+ skill bodies never bloat the prompt until they're actually needed.
328
+ `skill_view` ships in the default tool whitelist, so newly-created
329
+ agents recall skills out of the box; older agents pick it up via
330
+ `lazyclaw agent edit <name> --tools bash,read,write,grep,skill_view`.
331
+
332
+ `skill-synth` defaults to `manual` (you run the command, or pass
333
+ `--dry-run` to review first) because a synthesised skill feeds every
334
+ future agent's prompt — keep it opt-in until you trust an agent's
335
+ output. Flip the trigger any time with
336
+ `lazyclaw agent edit <name> --skill-write auto|manual|off`.
337
+
338
+ Auto-synthesis is defended for the cases where it runs unattended:
339
+ secret-shaped tokens (`sk-…`, `ghp_…`, `AKIA…`, bearer tokens,
340
+ `*_KEY=…`, PEM blocks) are redacted from both the transcript sent to
341
+ the model and the saved skill; synthesised bodies are size-capped and
342
+ the `[[TASK_DONE]]` marker is neutralised; and a synthesised skill
343
+ **never overwrites a human-authored skill** — on a name collision it
344
+ takes the next free `name-N` slug, only ever overwriting (and
345
+ version-bumping) its own prior output. Skill bodies are framed to the
346
+ model as untrusted reference, not instructions.
347
+
304
348
  Slack inbound (a user pings `@lazyclaw` in a channel, the bot replies)
305
349
  runs through the Socket Mode listener:
306
350
 
@@ -308,6 +352,55 @@ runs through the Socket Mode listener:
308
352
  lazyclaw slack listen # foreground; connects, reacts with :eyes:, replies in thread
309
353
  ```
310
354
 
355
+ ### Telegram — zero-install mobile control (v4.3)
356
+
357
+ Control lazyclaw from your phone with no app to install and no public
358
+ URL: the Telegram listener long-polls the Bot API (works behind NAT),
359
+ pipes each inbound message through the active provider, and replies in
360
+ the same chat. Push notifications are handled by Telegram itself.
361
+
362
+ ```bash
363
+ # 1) Create a bot with @BotFather, then store its token:
364
+ echo 'TELEGRAM_BOT_TOKEN=123456:ABC...' >> ~/.lazyclaw/.env
365
+
366
+ # 2) (recommended) restrict who can drive it — your Telegram numeric user id:
367
+ lazyclaw pairing add 987654321
368
+
369
+ # 3) Listen. Foreground; Ctrl-C to stop. An empty pairing allowlist
370
+ # means "reply to anyone who messages the bot".
371
+ lazyclaw telegram listen --provider anthropic --model claude-opus-4-7
372
+ ```
373
+
374
+ The `pairing` allowlist doubles as the Telegram sender allowlist, so
375
+ only paired ids get a reply.
376
+
377
+ ### Matrix + generic inbound (v4.3)
378
+
379
+ The same pattern extends to **Matrix** over the client-server `/sync`
380
+ long-poll (no SDK):
381
+
382
+ ```bash
383
+ printf 'MATRIX_HOMESERVER=https://matrix.org\nMATRIX_ACCESS_TOKEN=...\nMATRIX_USER_ID=@you:matrix.org\n' >> ~/.lazyclaw/.env
384
+ lazyclaw matrix listen # pairing allowlist = @user:server ids
385
+ ```
386
+
387
+ For any platform lazyclaw doesn't natively speak (Discord DMs, WhatsApp,
388
+ Signal, Email — each needs a heavy SDK or external binary), run your own
389
+ relay and forward messages to the **generic inbound webhook** on the
390
+ daemon — no extra dependency in lazyclaw:
391
+
392
+ ```bash
393
+ curl -s localhost:<port>/inbound -H 'content-type: application/json' \
394
+ -d '{"text":"hi from anywhere","senderId":"123","threadId":"discord:42"}'
395
+ # → { "reply": "...", "threadId": "discord:42" }
396
+ ```
397
+
398
+ `/inbound` runs the active provider and is auth-token-gated; when a
399
+ `pairing` allowlist exists, `senderId` must be on it. Native adapters
400
+ (Telegram, Matrix, Slack) all implement the same `channels/base.mjs`
401
+ contract, so an SDK-backed channel can be dropped in later behind an
402
+ explicit dependency review.
403
+
311
404
  The CLI is mirrored by daemon HTTP routes (`GET/POST/PATCH/DELETE
312
405
  /agents|teams|tasks`, `GET /tasks/<id>/transcript`) and by the
313
406
  browser dashboard's Agents / Teams / Tasks tabs:
@@ -342,8 +435,18 @@ lazyclaw skills list # markdown skill bundles
342
435
  lazyclaw skills show review
343
436
  lazyclaw skills install ./my-skill.md
344
437
  lazyclaw skills remove review
438
+
439
+ lazyclaw skills classify deploy-flow # active | stale (30d) | archived (90d)
440
+ lazyclaw skills curate # archive agent skills unused >90d → skills/.archive/
345
441
  ```
346
442
 
443
+ `skills curate` is the lifecycle sweep for self-improving skills:
444
+ agent-authored skills that haven't been recalled (`skill_view`) in 90
445
+ days move to `skills/.archive/` (recoverable); human-authored skills are
446
+ never touched. Pair it with a `HEARTBEAT.md` routine (see
447
+ `lazyclaw workspace init`, which now scaffolds AGENTS / SOUL / TOOLS /
448
+ **HEARTBEAT**) and `lazyclaw cron` to run it on a schedule.
449
+
347
450
  <img src="docs/screenshots/providers.png" alt="lazyclaw providers info anthropic — model list + capabilities" width="760">
348
451
 
349
452
  ## Workflows (DAG / sequential / persistent)
@@ -372,6 +475,58 @@ lazyclaw daemon --rate-limit 60 --log info # 60 req/min/IP, JSON access logs
372
475
  lazyclaw daemon --once # serve a single request, then exit
373
476
  ```
374
477
 
478
+ ### Device gateway — companion nodes (v4.3)
479
+
480
+ A companion node (a future mobile/menu-bar app, or just `curl`)
481
+ authenticates to the daemon with per-device Ed25519 keys, gated by
482
+ explicit operator approval — the OpenClaw gateway model, realised over
483
+ HTTP + SSE (no extra dependency). The daemon stays loopback-bound;
484
+ expose it remotely only behind a tunnel (Tailscale / Cloudflare) + TLS,
485
+ and set `--auth-token` for the non-gateway routes.
486
+
487
+ Handshake (all under `/gateway/`, which has its own device-auth so it
488
+ sits outside the daemon's shared `--auth-token` gate):
489
+
490
+ 1. `POST /gateway/connect/challenge` → `{ nonce, ts }` (single-use, time-boxed).
491
+ 2. `POST /gateway/connect` with `{ payload, signature, publicKey, nonce }`
492
+ — the node signs the canonical payload with its Ed25519 key. The
493
+ gateway verifies the signature, **binds the key to the claimed device
494
+ id**, enforces nonce single-use (anti-replay) and freshness. An
495
+ unapproved device gets `403 { status: 'pending', requestId }`; an
496
+ approved one gets its rotated bearer `token`.
497
+ 3. Operator approves out-of-band:
498
+
499
+ ```bash
500
+ lazyclaw nodes pending # list pending pairing requests
501
+ lazyclaw nodes approve <requestId> # mint + rotate the device token (never printed)
502
+ lazyclaw nodes devices # approved devices (token masked)
503
+ lazyclaw nodes revoke <deviceId> # drop a device's approval + token
504
+ ```
505
+
506
+ 4. The node reconnects → receives its `token`, then calls authenticated
507
+ routes with `Authorization: Bearer <token>` + `x-device-id: <id>`:
508
+ `GET /gateway/whoami` and `GET /gateway/events` (an SSE push stream).
509
+
510
+ **Remote tool-call approval (SSE event producer).** A trusted local
511
+ caller requests human approval for a sensitive action; the paired device
512
+ approves it from anywhere:
513
+
514
+ ```text
515
+ POST /exec/request {tool,args,summary} ← auth-token-gated (local/operator)
516
+ → broadcasts `exec.approval.requested` over /gateway/events
517
+ → device POSTs /gateway/exec/resolve {id, decision:"approve"} (device-authed)
518
+ → the request long-poll resolves { approved, by } (or denied on timeout)
519
+ ```
520
+
521
+ The MAS tool-use loop accepts an `approve` hook (`runTaskTurn` →
522
+ `runAgentTurn` → `runTool`) that gates the sensitive tools (`bash`,
523
+ `write`) on exactly this decision; read-only tools run ungated. Pending
524
+ approvals are bounded and the summary shown to the device is redacted.
525
+
526
+ Tokens are stored owner-only (`0600`) under
527
+ `~/.lazyclaw/gateway/devices.json`, compared in constant time, and
528
+ rotated on every re-approval.
529
+
375
530
  ## Cost rate cards
376
531
 
377
532
  ```bash
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,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
+ }