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,23 +0,0 @@
1
- {
2
- "id": "reflectt-channel",
3
- "name": "Reflectt Channel",
4
- "description": "Real-time agent collaboration via reflectt-node SSE",
5
- "version": "0.2.1",
6
- "channels": ["reflectt"],
7
- "configSchema": {
8
- "type": "object",
9
- "additionalProperties": false,
10
- "properties": {
11
- "url": {
12
- "type": "string",
13
- "description": "Reflectt-node server URL",
14
- "default": "http://127.0.0.1:4445"
15
- },
16
- "enabled": {
17
- "type": "boolean",
18
- "description": "Enable Reflectt channel",
19
- "default": true
20
- }
21
- }
22
- }
23
- }
@@ -1,23 +0,0 @@
1
- {
2
- "name": "@openclaw/reflectt-channel",
3
- "version": "1.0.0",
4
- "description": "OpenClaw channel plugin for reflectt-node",
5
- "type": "module",
6
- "main": "index.ts",
7
- "openclaw": {
8
- "extensions": ["./index.ts"],
9
- "channel": {
10
- "id": "reflectt",
11
- "label": "Reflectt Node",
12
- "selectionLabel": "Reflectt Node (local)",
13
- "docsPath": "/channels/reflectt",
14
- "docsLabel": "reflectt",
15
- "blurb": "Real-time messaging via reflectt-node event stream",
16
- "order": 100,
17
- "aliases": ["reflectt"]
18
- }
19
- },
20
- "keywords": ["openclaw", "channel", "reflectt"],
21
- "author": "Reflectt Team",
22
- "license": "MIT"
23
- }
@@ -1,433 +0,0 @@
1
- import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
3
- import type { ResolvedReflecttAccount, ReflecttConfig } from "./types.js";
4
-
5
- const DEFAULT_REFLECTT_URL = "http://localhost:4445";
6
-
7
- // NOTE: This file is a legacy standalone implementation and is NOT imported by ../index.ts.
8
- // Agent discovery is handled dynamically in index.ts via refreshAgentRoster() which queries
9
- // /team/roles on connect and refreshes every 5 minutes. The hardcoded roster has been removed;
10
- // if this file is ever wired up it should import the shared roster from index.ts instead.
11
- let WATCHED_AGENTS: string[] = [];
12
- let WATCHED_SET = new Set<string>();
13
-
14
- const IDLE_NUDGE_WINDOW_MS = 15 * 60 * 1000; // 15m
15
- const WATCHDOG_INTERVAL_MS = 60 * 1000; // 1m poll
16
- const ESCALATION_COOLDOWN_MS = 20 * 60 * 1000; // anti-spam
17
-
18
- type WatchdogState = {
19
- lastUpdateByAgent: Map<string, number>;
20
- lastEscalationAt: Map<string, number>;
21
- };
22
-
23
- function normalizeSenderId(value: unknown): string | null {
24
- if (typeof value !== "string") return null;
25
- const id = value.trim().toLowerCase();
26
- return id.length > 0 ? id : null;
27
- }
28
-
29
- function shouldEscalate(state: WatchdogState, key: string, now: number): boolean {
30
- const last = state.lastEscalationAt.get(key) ?? 0;
31
- if (now - last < ESCALATION_COOLDOWN_MS) return false;
32
- state.lastEscalationAt.set(key, now);
33
- return true;
34
- }
35
-
36
- async function fetchRecentMessages(url: string): Promise<Array<Record<string, unknown>>> {
37
- const endpoints = ["/chat/messages?limit=500", "/chat/messages", "/messages"];
38
- for (const endpoint of endpoints) {
39
- try {
40
- const response = await fetch(`${url}${endpoint}`);
41
- if (!response.ok) continue;
42
- const data = await response.json();
43
- const messages = Array.isArray(data)
44
- ? data
45
- : data && typeof data === "object" && Array.isArray((data as { messages?: unknown[] }).messages)
46
- ? (data as { messages: unknown[] }).messages
47
- : [];
48
- return messages.filter((m): m is Record<string, unknown> => Boolean(m) && typeof m === "object");
49
- } catch {
50
- // best effort
51
- }
52
- }
53
- return [];
54
- }
55
-
56
- async function seedWatchdogState(url: string, state: WatchdogState): Promise<void> {
57
- const now = Date.now();
58
- for (const agent of WATCHED_AGENTS) {
59
- state.lastUpdateByAgent.set(agent, now);
60
- }
61
-
62
- const messages = await fetchRecentMessages(url);
63
- for (const msg of messages) {
64
- const channel = typeof msg.channel === "string" ? msg.channel : "";
65
- if (channel !== "general") continue;
66
-
67
- const senderId = normalizeSenderId(msg.from);
68
- if (!senderId || !WATCHED_SET.has(senderId)) continue;
69
-
70
- const rawTs = msg.timestamp;
71
- const ts =
72
- typeof rawTs === "number" && Number.isFinite(rawTs)
73
- ? rawTs
74
- : typeof rawTs === "string" && Number.isFinite(Number(rawTs))
75
- ? Number(rawTs)
76
- : now;
77
-
78
- const current = state.lastUpdateByAgent.get(senderId) ?? 0;
79
- if (ts > current) state.lastUpdateByAgent.set(senderId, ts);
80
- }
81
- }
82
-
83
- async function postWatchdogNudge(url: string, agent: string): Promise<void> {
84
- await fetch(`${url}/chat/messages`, {
85
- method: "POST",
86
- headers: { "Content-Type": "application/json" },
87
- body: JSON.stringify({
88
- from: "watchdog",
89
- channel: "general",
90
- content: `@${agent} idle nudge: no update in #general for 15m+. Post shipped / blocker / next+ETA now.`,
91
- timestamp: Date.now(),
92
- }),
93
- });
94
- }
95
-
96
- function resolveReflecttAccount(params: {
97
- cfg: OpenClawConfig;
98
- accountId?: string | null;
99
- }): ResolvedReflecttAccount {
100
- const { cfg, accountId } = params;
101
- const effectiveAccountId = accountId || DEFAULT_ACCOUNT_ID;
102
-
103
- const channelConfig = (cfg.channels?.reflectt as ReflecttConfig) || {};
104
- const enabled = channelConfig.enabled !== false;
105
- const url = channelConfig.url || DEFAULT_REFLECTT_URL;
106
-
107
- return {
108
- accountId: effectiveAccountId,
109
- enabled,
110
- config: channelConfig,
111
- url,
112
- };
113
- }
114
-
115
- export const reflecttPlugin: ChannelPlugin<ResolvedReflecttAccount> = {
116
- id: "reflectt-channel",
117
- meta: {
118
- id: "reflectt-channel",
119
- label: "Reflectt Node",
120
- selectionLabel: "Reflectt Node (local)",
121
- docsPath: "/channels/reflectt",
122
- blurb: "Real-time messaging via reflectt-node event stream.",
123
- aliases: ["reflectt"],
124
- },
125
- capabilities: {
126
- chatTypes: ["direct", "channel"],
127
- media: false,
128
- reactions: false,
129
- threads: false,
130
- polls: false,
131
- nativeCommands: false,
132
- blockStreaming: false,
133
- },
134
- reload: {
135
- configPrefixes: ["channels.reflectt"],
136
- },
137
- config: {
138
- listAccountIds: () => [DEFAULT_ACCOUNT_ID],
139
- resolveAccount: (cfg, accountId) => resolveReflecttAccount({ cfg, accountId }),
140
- defaultAccountId: () => DEFAULT_ACCOUNT_ID,
141
- isConfigured: (account) => Boolean(account.url),
142
- isEnabled: (account) => account.enabled,
143
- describeAccount: (account) => ({
144
- accountId: account.accountId,
145
- name: "Reflectt Node",
146
- enabled: account.enabled,
147
- configured: Boolean(account.url),
148
- }),
149
- },
150
- outbound: {
151
- deliveryMode: "direct",
152
- sendText: async (ctx) => {
153
- const account = resolveReflecttAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
154
-
155
- try {
156
- const response = await fetch(`${account.url}/chat/messages`, {
157
- method: "POST",
158
- headers: { "Content-Type": "application/json" },
159
- body: JSON.stringify({
160
- from: "openclaw_agent",
161
- channel: ctx.to || "general",
162
- content: ctx.text,
163
- }),
164
- });
165
-
166
- if (!response.ok) {
167
- return {
168
- ok: false,
169
- error: new Error(`Failed to send message: ${response.statusText}`),
170
- };
171
- }
172
-
173
- return { ok: true };
174
- } catch (error) {
175
- return {
176
- ok: false,
177
- error: error instanceof Error ? error : new Error(String(error)),
178
- };
179
- }
180
- },
181
- },
182
- gateway: {
183
- startAccount: async (ctx) => {
184
- const { cfg, account, runtime, abortSignal, log } = ctx;
185
-
186
- log?.info?.(`Starting reflectt channel monitor: ${account.url}`);
187
-
188
- const watchdogState: WatchdogState = {
189
- lastUpdateByAgent: new Map(),
190
- lastEscalationAt: new Map(),
191
- };
192
-
193
- try {
194
- await seedWatchdogState(account.url, watchdogState);
195
- } catch (error) {
196
- log?.warn?.(`Failed to seed watchdog state: ${error}`);
197
- }
198
-
199
- const watchdogTimer = setInterval(async () => {
200
- const now = Date.now();
201
-
202
- for (const agent of WATCHED_AGENTS) {
203
- const lastUpdateAt = watchdogState.lastUpdateByAgent.get(agent) ?? now;
204
- if (now - lastUpdateAt <= IDLE_NUDGE_WINDOW_MS) continue;
205
-
206
- const key = `idle:${agent}`;
207
- if (!shouldEscalate(watchdogState, key, now)) continue;
208
-
209
- try {
210
- await postWatchdogNudge(account.url, agent);
211
- log?.info?.(`[reflectt-channel] idle nudge fired for @${agent} (last=${lastUpdateAt})`);
212
- } catch (error) {
213
- log?.warn?.(`[reflectt-channel] idle nudge failed for @${agent}: ${error}`);
214
- }
215
- }
216
- }, WATCHDOG_INTERVAL_MS);
217
-
218
- abortSignal.addEventListener("abort", () => {
219
- clearInterval(watchdogTimer);
220
- });
221
-
222
- // Connect to SSE event stream
223
- const connectSSE = async () => {
224
- try {
225
- const response = await fetch(`${account.url}/events`, {
226
- signal: abortSignal,
227
- });
228
-
229
- if (!response.ok) {
230
- throw new Error(`SSE connection failed: ${response.statusText}`);
231
- }
232
-
233
- const reader = response.body?.getReader();
234
- const decoder = new TextDecoder();
235
-
236
- if (!reader) {
237
- throw new Error("No response body available");
238
- }
239
-
240
- let buffer = "";
241
-
242
- while (!abortSignal.aborted) {
243
- const { done, value } = await reader.read();
244
-
245
- if (done) break;
246
-
247
- buffer += decoder.decode(value, { stream: true });
248
- const lines = buffer.split("\n");
249
- buffer = lines.pop() || "";
250
-
251
- for (const line of lines) {
252
- if (!line.trim() || line.startsWith(":")) continue;
253
-
254
- if (line.startsWith("data: ")) {
255
- const data = line.slice(6);
256
-
257
- try {
258
- const event = JSON.parse(data);
259
- const message = event?.data ?? event?.message;
260
-
261
- if ((event.type === "chat_message" || event.type === "message") && message) {
262
- const senderId = normalizeSenderId(message.from);
263
- if (senderId && WATCHED_SET.has(senderId) && message.channel === "general") {
264
- const rawTs = message.timestamp;
265
- const ts =
266
- typeof rawTs === "number" && Number.isFinite(rawTs)
267
- ? rawTs
268
- : typeof rawTs === "string" && Number.isFinite(Number(rawTs))
269
- ? Number(rawTs)
270
- : Date.now();
271
- watchdogState.lastUpdateByAgent.set(senderId, ts);
272
- }
273
-
274
- await handleChatMessage(message, cfg, runtime, log);
275
- }
276
- } catch (parseError) {
277
- log?.warn?.(`Failed to parse SSE data: ${parseError}`);
278
- }
279
- }
280
- }
281
- }
282
- } catch (error) {
283
- if (abortSignal.aborted) {
284
- log?.info?.("SSE connection closed (aborted)");
285
- return;
286
- }
287
-
288
- log?.error?.(`SSE connection error: ${error}`);
289
-
290
- // Retry after delay if not aborted
291
- if (!abortSignal.aborted) {
292
- await new Promise((resolve) => setTimeout(resolve, 5000));
293
- if (!abortSignal.aborted) {
294
- log?.info?.("Reconnecting to reflectt SSE stream...");
295
- await connectSSE();
296
- }
297
- }
298
- }
299
- };
300
-
301
- await connectSSE();
302
- },
303
- stopAccount: async (ctx) => {
304
- ctx.log?.info?.("Stopping reflectt channel monitor");
305
- // Abort signal will handle cleanup
306
- },
307
- },
308
- };
309
-
310
- async function handleChatMessage(
311
- message: any,
312
- cfg: OpenClawConfig,
313
- runtime: any,
314
- log: any,
315
- ) {
316
- try {
317
- const { from, channel, content, timestamp, id } = message;
318
- const safeContent = typeof content === "string" ? content : "";
319
- const safeChannel = typeof channel === "string" ? channel : "general";
320
-
321
- // Check if message mentions an agent
322
- const mentionedAgents = extractAgentMentions(safeContent, cfg);
323
-
324
- if (mentionedAgents.length === 0) {
325
- // No mentions, ignore
326
- return;
327
- }
328
-
329
- log?.info?.(`Processing reflectt message from ${from} in ${safeChannel} (mentions: ${mentionedAgents.join(", ")})`);
330
-
331
- // Route to each mentioned agent
332
- for (const agentId of mentionedAgents) {
333
- try {
334
- // Resolve agent route — all reflectt rooms share one session per agent.
335
- // Room identity is preserved in To/OriginatingTo so replies route correctly.
336
- const route = runtime.channel.routing.resolveAgentRoute({
337
- cfg,
338
- channel: "reflectt",
339
- accountId: "default",
340
- peer: null,
341
- });
342
-
343
- // Build envelope-formatted body
344
- const body = runtime.channel.reply.formatInboundEnvelope({
345
- channel: "Reflectt",
346
- from: from,
347
- timestamp: new Date(timestamp || Date.now()),
348
- envelope: runtime.channel.reply.resolveEnvelopeFormatOptions({ cfg, channel: "reflectt" }),
349
- body: safeContent,
350
- });
351
-
352
- // Finalize inbound context
353
- const ctxPayload = runtime.channel.reply.finalizeInboundContext({
354
- Body: body,
355
- RawBody: safeContent,
356
- CommandBody: safeContent,
357
- From: from,
358
- To: safeChannel,
359
- SessionKey: route.sessionKey,
360
- AccountId: route.accountId,
361
- ChatType: "channel",
362
- GroupSubject: safeChannel,
363
- SenderName: from,
364
- SenderId: from,
365
- Provider: "reflectt",
366
- Surface: "reflectt",
367
- MessageSid: id || String(timestamp || Date.now()),
368
- Timestamp: timestamp || Date.now(),
369
- WasMentioned: true,
370
- CommandAuthorized: true,
371
- OriginatingChannel: "reflectt",
372
- OriginatingTo: safeChannel,
373
- });
374
-
375
- // Create reply dispatcher
376
- const dispatcher = {
377
- deliver: async (payload: any) => {
378
- const account = resolveReflecttAccount({ cfg, accountId: route.accountId });
379
-
380
- // Send text messages back to reflectt-node
381
- if (payload.text) {
382
- await fetch(`${account.url}/chat/messages`, {
383
- method: "POST",
384
- headers: { "Content-Type": "application/json" },
385
- body: JSON.stringify({
386
- from: agentId,
387
- channel: safeChannel,
388
- content: payload.text,
389
- }),
390
- });
391
- }
392
- },
393
- onError: (err: unknown) => {
394
- log?.error?.(`Reflectt reply error: ${err}`);
395
- },
396
- };
397
-
398
- // Dispatch reply
399
- await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
400
- ctx: ctxPayload,
401
- cfg,
402
- dispatcherOptions: dispatcher,
403
- });
404
-
405
- log?.info?.(`Dispatched reflectt message to agent ${agentId}`);
406
- } catch (agentError) {
407
- log?.error?.(`Error dispatching to agent ${agentId}: ${agentError}`);
408
- }
409
- }
410
- } catch (error) {
411
- log?.error?.(`Error handling chat message: ${error}`);
412
- }
413
- }
414
-
415
- function extractAgentMentions(content: string, cfg: OpenClawConfig): string[] {
416
- const mentions: string[] = [];
417
- const mentionPattern = /@([\w][\w-]*[\w]|[\w]+)/g;
418
-
419
- let match;
420
- while ((match = mentionPattern.exec(content)) !== null) {
421
- const mentionedName = match[1];
422
-
423
- // Check if this matches an agent
424
- const agents = cfg.agents?.list || [];
425
- for (const agent of agents) {
426
- if (agent.id === mentionedName || agent.name?.toLowerCase() === mentionedName.toLowerCase()) {
427
- mentions.push(agent.id);
428
- }
429
- }
430
- }
431
-
432
- return [...new Set(mentions)]; // dedupe
433
- }
@@ -1,29 +0,0 @@
1
- /**
2
- * Types for reflectt-node integration
3
- */
4
-
5
- export type ReflecttConfig = {
6
- enabled?: boolean;
7
- url?: string;
8
- };
9
-
10
- export type ResolvedReflecttAccount = {
11
- accountId: string;
12
- enabled: boolean;
13
- config: ReflecttConfig;
14
- url: string;
15
- };
16
-
17
- export type ReflecttChatMessage = {
18
- id: string;
19
- from: string;
20
- channel: string;
21
- content: string;
22
- timestamp: number;
23
- mentions?: string[];
24
- };
25
-
26
- export type ReflecttEvent = {
27
- type: "chat_message" | "system" | "agent_status";
28
- data: ReflecttChatMessage | Record<string, unknown>;
29
- };