reflectt-node 0.1.0 → 0.1.2

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.
@@ -1,789 +0,0 @@
1
- /**
2
- * reflectt-channel — OpenClaw channel plugin
3
- *
4
- * Connects to reflectt-node SSE. When a message @mentions an agent,
5
- * it routes through OpenClaw's inbound pipeline. Agent responses are
6
- * POSTed back to reflectt-node automatically.
7
- */
8
- import type { OpenClawPluginApi, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
9
- import { DEFAULT_ACCOUNT_ID, buildChannelConfigSchema } from "openclaw/plugin-sdk";
10
- import http from "node:http";
11
- import fs from "node:fs";
12
- import os from "node:os";
13
- import path from "node:path";
14
-
15
- const DEFAULT_URL = "http://127.0.0.1:4445";
16
-
17
- // Agent roster — loaded dynamically from reflectt-node /team/roles on connect and refreshed
18
- // every 5 minutes. No static fallback: if discovery fails the roster stays empty and a warning
19
- // is logged so the operator can investigate rather than silently routing to stale agent names.
20
- const FALLBACK_AGENTS: string[] = [];
21
- let WATCHED_AGENTS: readonly string[] = FALLBACK_AGENTS;
22
- let WATCHED_SET = new Set<string>(WATCHED_AGENTS);
23
- const IDLE_NUDGE_WINDOW_MS = 15 * 60 * 1000; // 15m
24
- const WATCHDOG_INTERVAL_MS = 60 * 1000; // 1m
25
- const ROSTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5m
26
- const ESCALATION_COOLDOWN_MS = 20 * 60 * 1000;
27
-
28
- // SSE reconnect config
29
- const SSE_INITIAL_RETRY_MS = 1000; // start at 1s
30
- const SSE_MAX_RETRY_MS = 30_000; // cap at 30s
31
- const SSE_SOCKET_TIMEOUT_MS = 30_000; // detect dead TCP after 30s silence
32
- const SSE_HEALTH_INTERVAL_MS = 15_000; // health-check ping every 15s
33
-
34
- const lastUpdateByAgent = new Map<string, number>();
35
- const lastEscalationAt = new Map<string, number>();
36
- const hasActiveTaskByAgent = new Map<string, { value: boolean; checkedAt: number }>();
37
- const TASK_CACHE_TTL_MS = 2 * 60 * 1000;
38
-
39
- // --- Config helpers ---
40
-
41
- interface ReflecttAccount {
42
- accountId: string;
43
- url: string;
44
- enabled: boolean;
45
- configured: boolean;
46
- }
47
-
48
- function purgeSessionIndexEntry(agentId: string, sessionKey: string, ctx: any): boolean {
49
- try {
50
- const storePath = path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
51
- if (!fs.existsSync(storePath)) return false;
52
-
53
- const raw = fs.readFileSync(storePath, "utf8");
54
- const data = JSON.parse(raw || "{}");
55
- const keyExact = sessionKey;
56
- const keyLower = sessionKey.toLowerCase();
57
-
58
- if (!Object.prototype.hasOwnProperty.call(data, keyExact) && !Object.prototype.hasOwnProperty.call(data, keyLower)) {
59
- return false;
60
- }
61
-
62
- delete data[keyExact];
63
- delete data[keyLower];
64
- fs.writeFileSync(storePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
65
- ctx.log?.warn(`[reflectt] Purged stale session index entry for ${sessionKey} at ${storePath}`);
66
- return true;
67
- } catch (err) {
68
- ctx.log?.error(`[reflectt] Failed to purge session entry for ${sessionKey}: ${err}`);
69
- return false;
70
- }
71
- }
72
-
73
- function resolveAccount(cfg: OpenClawConfig, accountId?: string | null, log?: any): ReflecttAccount {
74
- // Support both config paths:
75
- // 1. channels.reflectt.url (canonical — per OpenClaw channel plugin convention)
76
- // 2. plugins.entries.reflectt-channel.config.url (fallback — general plugin convention)
77
- // channels.reflectt takes precedence.
78
- const ch = (cfg as any)?.channels?.reflectt ?? {};
79
- const pluginCfg = (cfg as any)?.plugins?.entries?.["reflectt-channel"]?.config ?? {};
80
-
81
- const hasChannelConfig = !!ch.url;
82
- const hasPluginConfig = !!pluginCfg.url;
83
- const url = ch.url || pluginCfg.url || DEFAULT_URL;
84
- const enabled = ch.enabled !== undefined ? ch.enabled !== false
85
- : pluginCfg.enabled !== undefined ? pluginCfg.enabled !== false
86
- : true;
87
- const configured = hasChannelConfig || hasPluginConfig;
88
-
89
- if (!configured) {
90
- log?.warn(
91
- `[reflectt] No explicit URL configured — using default ${DEFAULT_URL}. ` +
92
- `To configure, set one of:\n` +
93
- ` 1. channels.reflectt.url in ~/.openclaw/openclaw.json (recommended)\n` +
94
- ` 2. plugins.entries.reflectt-channel.config.url in ~/.openclaw/openclaw.json\n` +
95
- ` Or run: openclaw config set channels.reflectt.url "http://your-node:4445"`
96
- );
97
- } else if (hasChannelConfig && hasPluginConfig && ch.url !== pluginCfg.url) {
98
- log?.warn(
99
- `[reflectt] Config found in both channels.reflectt.url (${ch.url}) and ` +
100
- `plugins.entries.reflectt-channel.config.url (${pluginCfg.url}). ` +
101
- `Using channels.reflectt.url (takes precedence).`
102
- );
103
- }
104
-
105
- return {
106
- accountId: accountId || DEFAULT_ACCOUNT_ID,
107
- url,
108
- enabled,
109
- configured,
110
- };
111
- }
112
-
113
- // --- HTTP helpers ---
114
-
115
- function postMessage(url: string, from: string, channel: string, content: string): Promise<void> {
116
- return new Promise((resolve, reject) => {
117
- const body = JSON.stringify({ from, channel, content });
118
- const parsed = new URL(`${url}/chat/messages`);
119
- const req = http.request({
120
- hostname: parsed.hostname,
121
- port: parsed.port,
122
- path: parsed.pathname,
123
- method: "POST",
124
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
125
- }, (res) => { res.resume(); resolve(); });
126
- req.on("error", reject);
127
- req.end(body);
128
- });
129
- }
130
-
131
- function normalizeSenderId(value: unknown): string | null {
132
- if (typeof value !== "string") return null;
133
- const id = value.trim().toLowerCase().replace(/^@+/, "");
134
- return id.length > 0 ? id : null;
135
- }
136
-
137
- function markAgentActivity(from: unknown, channel: unknown, timestamp: unknown) {
138
- if (channel !== "general") return;
139
- const id = normalizeSenderId(from);
140
- if (!id || !WATCHED_SET.has(id)) return;
141
- const ts =
142
- typeof timestamp === "number" && Number.isFinite(timestamp)
143
- ? timestamp
144
- : typeof timestamp === "string" && Number.isFinite(Number(timestamp))
145
- ? Number(timestamp)
146
- : Date.now();
147
- const cur = lastUpdateByAgent.get(id) ?? 0;
148
- if (ts > cur) lastUpdateByAgent.set(id, ts);
149
- }
150
-
151
- function shouldEscalate(key: string, now: number): boolean {
152
- const last = lastEscalationAt.get(key) ?? 0;
153
- if (now - last < ESCALATION_COOLDOWN_MS) return false;
154
- lastEscalationAt.set(key, now);
155
- return true;
156
- }
157
-
158
- async function refreshAgentRoster(url: string, log?: any): Promise<void> {
159
- // /team/roles is the canonical source: { agents: [{ name: "kai", role: "builder", ... }] }
160
- // Fall back to legacy endpoints if /team/roles is unavailable.
161
- const endpoints = [
162
- { path: '/team/roles', extract: (d: any) => (d?.agents || []).map((a: any) => a?.name).filter(Boolean) },
163
- { path: '/health/agents', extract: (d: any) => (d?.agents || []).map((a: any) => a?.agent || a?.name).filter(Boolean) },
164
- { path: '/capabilities', extract: (d: any) => (d?.assignment?.agents || []).map((a: any) => a?.name).filter(Boolean) },
165
- ];
166
-
167
- for (const ep of endpoints) {
168
- try {
169
- const res = await fetch(`${url}${ep.path}`, { signal: AbortSignal.timeout(5000) });
170
- if (res.ok) {
171
- const data = await res.json() as any;
172
- const names: string[] = ep.extract(data);
173
- if (names.length > 0) {
174
- WATCHED_AGENTS = Object.freeze(names);
175
- WATCHED_SET = new Set<string>(names);
176
- log?.info(`[reflectt] Loaded ${names.length} agents from ${ep.path}: ${names.join(', ')}`);
177
- return;
178
- }
179
- }
180
- } catch { /* try next endpoint */ }
181
- }
182
- log?.warn(
183
- `[reflectt] Could not load agent roster from ${url} — ` +
184
- (FALLBACK_AGENTS.length > 0
185
- ? `using fallback list (${FALLBACK_AGENTS.join(', ')})`
186
- : "roster will be empty until next successful refresh")
187
- );
188
- }
189
-
190
- async function fetchRecentMessages(url: string): Promise<Array<Record<string, unknown>>> {
191
- const endpoints = ["/chat/messages?limit=500", "/chat/messages", "/messages"];
192
- for (const endpoint of endpoints) {
193
- try {
194
- const response = await fetch(`${url}${endpoint}`);
195
- if (!response.ok) continue;
196
- const data = await response.json();
197
- const messages = Array.isArray(data)
198
- ? data
199
- : data && typeof data === "object" && Array.isArray((data as { messages?: unknown[] }).messages)
200
- ? (data as { messages: unknown[] }).messages
201
- : [];
202
- return messages.filter((m): m is Record<string, unknown> => Boolean(m) && typeof m === "object");
203
- } catch {
204
- // best effort
205
- }
206
- }
207
- return [];
208
- }
209
-
210
- async function seedAgentActivity(url: string, log?: any) {
211
- const now = Date.now();
212
- for (const agent of WATCHED_AGENTS) {
213
- lastUpdateByAgent.set(agent, now);
214
- }
215
-
216
- const messages = await fetchRecentMessages(url);
217
- for (const msg of messages) {
218
- markAgentActivity(msg.from, msg.channel, msg.timestamp);
219
- }
220
-
221
- log?.info?.(`[reflectt][watchdog] seeded activity from ${messages.length} recent message(s)`);
222
- }
223
-
224
- async function hasActiveTask(url: string, agent: string, now = Date.now()): Promise<boolean> {
225
- const cached = hasActiveTaskByAgent.get(agent);
226
- if (cached && now - cached.checkedAt < TASK_CACHE_TTL_MS) {
227
- return cached.value;
228
- }
229
-
230
- try {
231
- const response = await fetch(`${url}/tasks/next?agent=${encodeURIComponent(agent)}`);
232
- if (!response.ok) {
233
- hasActiveTaskByAgent.set(agent, { value: true, checkedAt: now });
234
- return true;
235
- }
236
-
237
- const data = await response.json() as { task?: unknown };
238
- const value = Boolean(data?.task);
239
- hasActiveTaskByAgent.set(agent, { value, checkedAt: now });
240
- return value;
241
- } catch {
242
- // fail-open so task API hiccups do not suppress legitimate nudges
243
- hasActiveTaskByAgent.set(agent, { value: true, checkedAt: now });
244
- return true;
245
- }
246
- }
247
-
248
- // --- Dedup ---
249
- const seen = new Set<string>();
250
- function dedup(id: string): boolean {
251
- if (seen.has(id)) return false;
252
- seen.add(id);
253
- if (seen.size > 500) { const f = seen.values().next().value; if (f) seen.delete(f); }
254
- return true;
255
- }
256
-
257
- // --- Runtime dispatch telemetry ---
258
- const dispatchCountByMessageId = new Map<string, number>();
259
- function incrementDispatchCount(messageId: string): number {
260
- const next = (dispatchCountByMessageId.get(messageId) ?? 0) + 1;
261
- dispatchCountByMessageId.set(messageId, next);
262
- if (dispatchCountByMessageId.size > 1000) {
263
- const oldest = dispatchCountByMessageId.keys().next().value;
264
- if (oldest) dispatchCountByMessageId.delete(oldest);
265
- }
266
- return next;
267
- }
268
-
269
- // --- SSE connection ---
270
-
271
- let sseRequest: http.ClientRequest | null = null;
272
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
273
- let healthCheckTimer: ReturnType<typeof setInterval> | null = null;
274
- let watchdogTimer: ReturnType<typeof setInterval> | null = null;
275
- let rosterRefreshTimer: ReturnType<typeof setInterval> | null = null;
276
- let stopped = false;
277
- let pluginRuntime: any = null;
278
- let currentRetryMs = SSE_INITIAL_RETRY_MS;
279
- let lastSSEDataAt = 0;
280
- let sseConnected = false;
281
-
282
- function destroySSE(ctx: any, reason: string) {
283
- if (sseRequest) {
284
- ctx.log?.info(`[reflectt] Destroying SSE connection: ${reason}`);
285
- try { sseRequest.destroy(); } catch {}
286
- sseRequest = null;
287
- }
288
- sseConnected = false;
289
- }
290
-
291
- function connectSSE(url: string, account: ReflecttAccount, ctx: any) {
292
- if (stopped) return;
293
-
294
- // Clean up any lingering connection
295
- if (sseRequest) {
296
- destroySSE(ctx, "new connection attempt");
297
- }
298
-
299
- ctx.log?.info(`[reflectt] Connecting SSE: ${url}/events/subscribe (retry backoff: ${currentRetryMs}ms)`);
300
-
301
- const req = http.get(`${url}/events/subscribe`, (res) => {
302
- if (res.statusCode !== 200) {
303
- ctx.log?.error(`[reflectt] SSE status ${res.statusCode}`);
304
- res.resume();
305
- sseRequest = null;
306
- scheduleReconnect(url, account, ctx);
307
- return;
308
- }
309
-
310
- // Connection succeeded — reset backoff
311
- currentRetryMs = SSE_INITIAL_RETRY_MS;
312
- sseConnected = true;
313
- lastSSEDataAt = Date.now();
314
- ctx.log?.info("[reflectt] SSE connected ✓");
315
-
316
- // Re-seed agent activity after reconnect
317
- seedAgentActivity(url, ctx.log).catch((err) => {
318
- ctx.log?.warn?.(`[reflectt] post-reconnect seed failed: ${err}`);
319
- });
320
-
321
- // NOTE: No socket timeout here — SSE streams are idle by nature.
322
- // Staleness is detected by the periodic health-check pinger instead.
323
-
324
- let buffer = "";
325
- res.setEncoding("utf8");
326
-
327
- res.on("data", (chunk: string) => {
328
- lastSSEDataAt = Date.now();
329
- buffer += chunk;
330
- const frames = buffer.split("\n\n");
331
- buffer = frames.pop() || "";
332
-
333
- for (const frame of frames) {
334
- if (!frame.trim()) continue;
335
- let eventType = "", eventData = "";
336
- for (const line of frame.split("\n")) {
337
- if (line.startsWith("event: ")) eventType = line.slice(7).trim();
338
- else if (line.startsWith("data: ")) eventData = line.slice(6);
339
- }
340
- if (eventType === "message_posted" && eventData) {
341
- handleInbound(eventData, url, account, ctx);
342
- } else if (eventType === "batch" && eventData) {
343
- try {
344
- const events = JSON.parse(eventData);
345
- for (const evt of events) {
346
- if (evt.type === "message_posted" && evt.data) {
347
- handleInbound(JSON.stringify(evt.data), url, account, ctx);
348
- }
349
- }
350
- } catch (e) {
351
- ctx.log?.error(`[reflectt] batch parse error: ${e}`);
352
- }
353
- }
354
- }
355
- });
356
-
357
- res.on("end", () => {
358
- ctx.log?.warn("[reflectt] SSE stream ended by server");
359
- sseRequest = null;
360
- sseConnected = false;
361
- scheduleReconnect(url, account, ctx);
362
- });
363
- res.on("error", (err) => {
364
- ctx.log?.error(`[reflectt] SSE response error: ${err.message}`);
365
- sseRequest = null;
366
- sseConnected = false;
367
- scheduleReconnect(url, account, ctx);
368
- });
369
- });
370
-
371
- req.on("error", (err) => {
372
- ctx.log?.error(`[reflectt] SSE connect error: ${err.message}`);
373
- sseRequest = null;
374
- sseConnected = false;
375
- scheduleReconnect(url, account, ctx);
376
- });
377
-
378
- sseRequest = req;
379
- }
380
-
381
- function scheduleReconnect(url: string, account: ReflecttAccount, ctx: any) {
382
- if (stopped || reconnectTimer) return;
383
-
384
- // Exponential backoff with jitter
385
- const jitter = Math.random() * currentRetryMs * 0.3;
386
- const delay = Math.min(currentRetryMs + jitter, SSE_MAX_RETRY_MS);
387
- ctx.log?.info(`[reflectt] Reconnecting in ${Math.round(delay)}ms`);
388
-
389
- reconnectTimer = setTimeout(() => {
390
- reconnectTimer = null;
391
- connectSSE(url, account, ctx);
392
- }, delay);
393
-
394
- // Increase backoff for next attempt
395
- currentRetryMs = Math.min(currentRetryMs * 2, SSE_MAX_RETRY_MS);
396
- }
397
-
398
- /**
399
- * Periodic health-check: ping /health to detect server availability
400
- * even when the SSE socket hasn't timed out yet. If the server is up
401
- * but we're not connected, force a reconnect.
402
- */
403
- function startHealthCheck(url: string, account: ReflecttAccount, ctx: any) {
404
- if (healthCheckTimer) return;
405
-
406
- healthCheckTimer = setInterval(async () => {
407
- if (stopped) return;
408
-
409
- try {
410
- const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
411
- if (res.ok) {
412
- // Server is alive
413
- if (!sseConnected && !reconnectTimer) {
414
- ctx.log?.warn("[reflectt] Health OK but SSE not connected — forcing reconnect");
415
- currentRetryMs = SSE_INITIAL_RETRY_MS; // reset backoff since server is up
416
- connectSSE(url, account, ctx);
417
- }
418
- }
419
- } catch {
420
- // Server unreachable — SSE reconnect loop will handle it
421
- if (sseConnected) {
422
- ctx.log?.warn("[reflectt] Health check failed while SSE appears connected — destroying stale connection");
423
- destroySSE(ctx, "health check failed");
424
- scheduleReconnect(url, account, ctx);
425
- }
426
- }
427
- }, SSE_HEALTH_INTERVAL_MS);
428
- }
429
-
430
- function stopHealthCheck() {
431
- if (healthCheckTimer) {
432
- clearInterval(healthCheckTimer);
433
- healthCheckTimer = null;
434
- }
435
- }
436
-
437
- function startWatchdog(url: string, ctx: any) {
438
- if (watchdogTimer) return;
439
-
440
- watchdogTimer = setInterval(async () => {
441
- const now = Date.now();
442
- for (const agent of WATCHED_AGENTS) {
443
- const lastUpdateAt = lastUpdateByAgent.get(agent) ?? now;
444
- if (now - lastUpdateAt <= IDLE_NUDGE_WINDOW_MS) continue;
445
-
446
- const activeTask = await hasActiveTask(url, agent, now);
447
- if (!activeTask) {
448
- ctx.log?.info?.(`[reflectt][watchdog] idle nudge suppressed for @${agent}: no active task`);
449
- continue;
450
- }
451
-
452
- const key = `idle:${agent}`;
453
- if (!shouldEscalate(key, now)) continue;
454
-
455
- const content = `@${agent} idle nudge: no update in #general for 15m+. Post shipped / blocker / next+ETA now.`;
456
- try {
457
- await postMessage(url, "watchdog", "general", content);
458
- ctx.log?.info?.(`[reflectt][watchdog] idle nudge fired for @${agent} (last=${lastUpdateAt})`);
459
- } catch (err) {
460
- ctx.log?.warn?.(`[reflectt][watchdog] idle nudge failed for @${agent}: ${err}`);
461
- }
462
- }
463
- }, WATCHDOG_INTERVAL_MS);
464
- }
465
-
466
- function stopWatchdog() {
467
- if (watchdogTimer) {
468
- clearInterval(watchdogTimer);
469
- watchdogTimer = null;
470
- }
471
- }
472
-
473
- function startRosterRefresh(url: string, log?: any) {
474
- if (rosterRefreshTimer) return;
475
- rosterRefreshTimer = setInterval(() => {
476
- refreshAgentRoster(url, log).catch((err) => {
477
- log?.warn(`[reflectt] Periodic roster refresh failed: ${err}`);
478
- });
479
- }, ROSTER_REFRESH_INTERVAL_MS);
480
- }
481
-
482
- function stopRosterRefresh() {
483
- if (rosterRefreshTimer) {
484
- clearInterval(rosterRefreshTimer);
485
- rosterRefreshTimer = null;
486
- }
487
- }
488
-
489
- function handleInbound(data: string, url: string, account: ReflecttAccount, ctx: any) {
490
- try {
491
- const msg = JSON.parse(data);
492
- const content: string = msg.content || "";
493
- const msgId: string = msg.id || "";
494
- const from: string = msg.from || "unknown";
495
- const channel: string = msg.channel || "general";
496
-
497
- // Re-enabled by operator request: system/watchdog messages should be dispatch-eligible.
498
- // Keep activity tracking consistent for all senders.
499
- markAgentActivity(from, channel, msg.timestamp);
500
-
501
- if (!msgId) return;
502
- if (!dedup(msgId)) {
503
- ctx.log?.debug(`[reflectt][dispatch-telemetry] duplicate inbound ignored message_id=${msgId}`);
504
- return;
505
- }
506
-
507
- // Extract @mentions
508
- const mentions: string[] = [];
509
- const regex = /@(\w+)/g;
510
- let match: RegExpExecArray | null;
511
- while ((match = regex.exec(content)) !== null) mentions.push(match[1].toLowerCase());
512
- if (mentions.length === 0) return;
513
-
514
- // Build agent ID map
515
- const cfg = pluginRuntime?.config?.loadConfig?.() ?? {};
516
- const agentList: Array<{ id: string; identity?: { name?: string } }> = cfg?.agents?.list || [];
517
- const agentIds = new Set<string>();
518
- const agentNameToId = new Map<string, string>();
519
- for (const a of agentList) {
520
- agentIds.add(a.id);
521
- agentNameToId.set(a.id, a.id);
522
- if (a.identity?.name) {
523
- const name = a.identity.name.toLowerCase();
524
- agentIds.add(name);
525
- agentNameToId.set(name, a.id);
526
- }
527
- }
528
-
529
- // Determine sender's agent ID (if message is from an agent)
530
- const senderAgentId = agentNameToId.get(from.toLowerCase());
531
-
532
- // Find mentioned agent
533
- ctx.log?.debug(`[reflectt] Processing mentions: ${mentions.join(", ")}`);
534
- let matchedMentions = 0;
535
- let skippedSelfMentions = 0;
536
- let unmatchedMentions = 0;
537
- const dispatchedTargets: string[] = [];
538
- const dispatchedSet = new Set<string>();
539
-
540
- for (const mention of mentions) {
541
- let agentId: string | undefined;
542
- for (const a of agentList) {
543
- if (a.id === mention) { agentId = a.id; break; }
544
- if (a.identity?.name?.toLowerCase() === mention) { agentId = a.id; break; }
545
- }
546
- if (!agentId && mention === "kai") agentId = "main";
547
- if (!agentId) {
548
- unmatchedMentions += 1;
549
- ctx.log?.debug(`[reflectt] Mention @${mention} did not match any agent`);
550
- continue;
551
- }
552
-
553
- // Skip routing to yourself (avoid self-loops)
554
- if (senderAgentId && agentId === senderAgentId) {
555
- skippedSelfMentions += 1;
556
- ctx.log?.debug(`[reflectt] Skipping self-mention: @${agentId}`);
557
- continue;
558
- }
559
-
560
- if (dispatchedSet.has(agentId)) {
561
- ctx.log?.debug(`[reflectt] Skipping duplicate mention target: @${agentId}`);
562
- continue;
563
- }
564
-
565
- matchedMentions += 1;
566
- dispatchedSet.add(agentId);
567
- ctx.log?.info(`[reflectt] ${from} → @${agentId}: ${content.slice(0, 60)}...`);
568
-
569
- // Build inbound message context
570
- const runtime = pluginRuntime;
571
- if (!runtime?.channel?.reply) continue;
572
-
573
- // All reflectt rooms share one session per agent (peer: null).
574
- // Room identity is preserved in OriginatingTo so replies route correctly.
575
- const sessionKey = `agent:${agentId}:reflectt:main`;
576
-
577
- // Create message context
578
- const msgContext = {
579
- Body: content,
580
- BodyForAgent: content,
581
- CommandBody: content,
582
- BodyForCommands: content,
583
- From: `reflectt:${channel}`,
584
- To: channel,
585
- SessionKey: sessionKey,
586
- AccountId: account.accountId,
587
- MessageSid: msgId,
588
- ChatType: "group",
589
- ConversationLabel: `reflectt-node #${channel}`,
590
- SenderName: from,
591
- SenderId: from,
592
- Timestamp: msg.timestamp || Date.now(),
593
- Provider: "reflectt",
594
- Surface: "reflectt",
595
- OriginatingChannel: "reflectt" as const,
596
- OriginatingTo: channel,
597
- WasMentioned: true,
598
- CommandAuthorized: false,
599
- };
600
-
601
- // Finalize context
602
- const finalizedCtx = runtime.channel.reply.finalizeInboundContext(msgContext);
603
-
604
- // Guard against stale/unsafe session path metadata leaking from prior context.
605
- // OpenClaw now enforces that any session file path must be under the agent sessions dir.
606
- const safeCtx: any = { ...finalizedCtx };
607
- delete safeCtx.SessionFilePath;
608
- delete safeCtx.sessionFilePath;
609
- delete safeCtx.SessionPath;
610
- delete safeCtx.sessionPath;
611
- delete safeCtx.TranscriptPath;
612
- delete safeCtx.transcriptPath;
613
- delete safeCtx.SessionFile;
614
- delete safeCtx.sessionFile;
615
-
616
- // Create reply dispatcher
617
- const agentName = agentId === "main" ? "kai" : agentId;
618
- const dispatcher = runtime.channel.reply.createReplyDispatcherWithTyping({
619
- deliver: async (payload: any) => {
620
- const text = payload.text || payload.content || "";
621
- if (text) {
622
- ctx.log?.info(`[reflectt] Reply → ${channel}: ${text.slice(0, 60)}...`);
623
- await postMessage(url, agentName!, channel, text);
624
- }
625
- },
626
- onError: (err: unknown) => {
627
- ctx.log?.error(`[reflectt] Dispatch error: ${err}`);
628
- },
629
- });
630
-
631
- // Dispatch reply using OpenClaw's pipeline
632
- const dispatchCount = incrementDispatchCount(msgId);
633
- dispatchedTargets.push(agentId);
634
- ctx.log?.info(
635
- `[reflectt][dispatch-telemetry] message_id=${msgId} dispatch_count=${dispatchCount} target=${agentId} mentions_total=${mentions.length}`,
636
- );
637
-
638
- runtime.channel.reply.dispatchReplyFromConfig({
639
- ctx: safeCtx,
640
- cfg,
641
- dispatcher: dispatcher.dispatcher,
642
- replyOptions: dispatcher.replyOptions,
643
- }).catch((err: unknown) => {
644
- const errText = String(err ?? "");
645
- if (errText.includes("Session file path must be within sessions directory")) {
646
- const healed = purgeSessionIndexEntry(agentId!, sessionKey, ctx);
647
- if (healed) {
648
- ctx.log?.warn(`[reflectt] Retrying dispatch after purging stale session entry: ${sessionKey}`);
649
- runtime.channel.reply.dispatchReplyFromConfig({
650
- ctx: safeCtx,
651
- cfg,
652
- dispatcher: dispatcher.dispatcher,
653
- replyOptions: dispatcher.replyOptions,
654
- }).catch((retryErr: unknown) => {
655
- ctx.log?.error(`[reflectt] dispatch retry failed: ${retryErr}`);
656
- });
657
- return;
658
- }
659
- }
660
- ctx.log?.error(`[reflectt] dispatchReplyFromConfig error: ${err}`);
661
- });
662
- }
663
-
664
- ctx.log?.info(
665
- `[reflectt][dispatch-telemetry] summary message_id=${msgId} mentions_total=${mentions.length} matched=${matchedMentions} unmatched=${unmatchedMentions} skipped_self=${skippedSelfMentions} dispatched=${dispatchedTargets.length} targets=${dispatchedTargets.join(",") || "none"}`,
666
- );
667
- } catch (err) {
668
- ctx.log?.error(`[reflectt] Parse error: ${err}`);
669
- }
670
- }
671
-
672
- // --- Channel Plugin ---
673
-
674
- const reflecttPlugin: ChannelPlugin<ReflecttAccount> = {
675
- id: "reflectt",
676
- meta: {
677
- id: "reflectt",
678
- label: "Reflectt",
679
- selectionLabel: "Reflectt (Local)",
680
- docsPath: "/channels/reflectt",
681
- docsLabel: "reflectt",
682
- blurb: "Real-time agent collaboration via reflectt-node",
683
- order: 110,
684
- },
685
- capabilities: {
686
- chatTypes: ["group"],
687
- media: false,
688
- },
689
- reload: { configPrefixes: ["channels.reflectt"] },
690
-
691
- config: {
692
- listAccountIds: () => [DEFAULT_ACCOUNT_ID],
693
- resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId, pluginRuntime?.logger),
694
- defaultAccountId: () => DEFAULT_ACCOUNT_ID,
695
- isConfigured: (account) => account.configured,
696
- describeAccount: (account) => ({
697
- accountId: account.accountId,
698
- name: "Reflectt",
699
- enabled: account.enabled,
700
- configured: account.configured,
701
- }),
702
- },
703
-
704
- outbound: {
705
- deliveryMode: "direct",
706
- textChunkLimit: 4000,
707
- sendText: async ({ to, text, accountId }) => {
708
- const cfg = pluginRuntime?.config?.loadConfig?.() ?? {};
709
- const account = resolveAccount(cfg, accountId, pluginRuntime?.logger);
710
- // Determine agent name for "from" field
711
- const agentName = "kai"; // TODO: resolve from session context
712
- await postMessage(account.url, agentName, "general", text ?? "");
713
- return { channel: "reflectt" as const, to, messageId: `rn-${Date.now()}` };
714
- },
715
- },
716
-
717
- gateway: {
718
- startAccount: async (ctx) => {
719
- const account = ctx.account;
720
- if (!account.enabled) return;
721
-
722
- stopped = false;
723
-
724
- // Validate connectivity and show actionable error if server unreachable
725
- try {
726
- const healthRes = await fetch(`${account.url}/health`, { signal: AbortSignal.timeout(5000) });
727
- if (!healthRes.ok) {
728
- ctx.log?.warn(`[reflectt] Server at ${account.url} returned ${healthRes.status}. Will retry via SSE reconnect.`);
729
- } else {
730
- ctx.log?.info(`[reflectt] Server at ${account.url} is healthy ✓`);
731
- }
732
- } catch {
733
- ctx.log?.error(
734
- `[reflectt] Cannot reach reflectt-node at ${account.url}. ` +
735
- `Make sure reflectt-node is running, then set the URL in your OpenClaw config:\n` +
736
- ` Option 1 (recommended): channels.reflectt.url = "${account.url}"\n` +
737
- ` Option 2: plugins.entries.reflectt-channel.config.url = "${account.url}"\n` +
738
- `Will keep retrying via SSE reconnect.`
739
- );
740
- }
741
-
742
- ctx.setStatus({
743
- accountId: account.accountId,
744
- name: "Reflectt",
745
- enabled: true,
746
- configured: true,
747
- });
748
-
749
- // Load agent roster from reflectt-node before starting SSE, then refresh every 5 minutes.
750
- await refreshAgentRoster(account.url, ctx.log);
751
- startRosterRefresh(account.url, ctx.log);
752
-
753
- seedAgentActivity(account.url, ctx.log).catch((err) => {
754
- ctx.log?.warn?.(`[reflectt][watchdog] seed failed: ${err}`);
755
- });
756
- startWatchdog(account.url, ctx);
757
- startHealthCheck(account.url, account, ctx);
758
- connectSSE(account.url, account, ctx);
759
-
760
- return {
761
- stop: () => {
762
- stopped = true;
763
- stopRosterRefresh();
764
- stopWatchdog();
765
- stopHealthCheck();
766
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
767
- destroySSE(ctx, "plugin stopped");
768
- ctx.log?.info("[reflectt] Stopped");
769
- },
770
- };
771
- },
772
- },
773
- };
774
-
775
- // --- Plugin entry ---
776
-
777
- const plugin = {
778
- id: "reflectt-channel",
779
- name: "Reflectt Channel",
780
- description: "Real-time agent collaboration via reflectt-node SSE",
781
-
782
- register(api: OpenClawPluginApi) {
783
- pluginRuntime = api.runtime;
784
- api.logger.info("[reflectt] Registering channel plugin");
785
- api.registerChannel({ plugin: reflecttPlugin });
786
- },
787
- };
788
-
789
- export default plugin;