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 +155 -0
- package/agents.mjs +19 -3
- package/channels/matrix.mjs +417 -0
- package/channels/telegram.mjs +362 -0
- package/cli.mjs +381 -60
- package/daemon.mjs +98 -0
- package/gateway/device_auth.mjs +664 -0
- package/gateway/http_gateway.mjs +304 -0
- package/mas/agent_memory.mjs +35 -34
- package/mas/agent_turn.mjs +2 -1
- package/mas/mention_router.mjs +75 -4
- package/mas/provider_adapters.mjs +83 -0
- package/mas/redact.mjs +46 -0
- package/mas/skill_synth.mjs +232 -0
- package/mas/tool_runner.mjs +24 -2
- package/mas/tools/skill_view.mjs +43 -0
- package/package.json +3 -1
- package/skills.mjs +101 -8
- package/skills_curator.mjs +323 -0
- package/workspace.mjs +18 -3
package/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
|
+
}
|