@egoai/platform 0.1.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/src/brain.ts ADDED
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Brain — Second Brain (Mnemo) hooks for the platform plugin.
3
+ *
4
+ * Hooks:
5
+ * 1. before_prompt_build — RAG injection: keyword-matches brain/index.md
6
+ * against the user's prompt and prepends relevant wiki pages.
7
+ * 2. message_received — buffers user message content in memory.
8
+ * 3. agent_end — appends the turn (user + assistant) to an append-only
9
+ * session conversation file: brain/conversations/{sessionId}.md
10
+ * 4. before_reset — queues the finished session for extraction processing.
11
+ *
12
+ * Conversation files are append-only markdown, one per session. Turns are
13
+ * appended as they happen. No per-turn log.md writes — log.md is reserved
14
+ * for significant events (ingests, wiki updates, session summaries).
15
+ *
16
+ * All operations are non-fatal: errors are logged, never thrown.
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+
23
+ import type { PluginLogger } from "openclaw/plugin-sdk";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Hook event/context types (mirrors openclaw SDK .d.ts)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ type AgentContext = {
30
+ runId?: string;
31
+ agentId?: string;
32
+ sessionKey?: string;
33
+ sessionId?: string;
34
+ workspaceDir?: string;
35
+ channelId?: string;
36
+ trigger?: string;
37
+ };
38
+
39
+ type BeforePromptBuildEvent = {
40
+ prompt: string;
41
+ messages: unknown[];
42
+ };
43
+
44
+ type BeforePromptBuildResult = {
45
+ systemPrompt?: string;
46
+ prependContext?: string;
47
+ prependSystemContext?: string;
48
+ appendSystemContext?: string;
49
+ };
50
+
51
+ type MessageReceivedEvent = {
52
+ from: string;
53
+ content: string;
54
+ timestamp?: number;
55
+ metadata?: Record<string, unknown>;
56
+ };
57
+
58
+ type MessageSentEvent = {
59
+ to: string;
60
+ content: string;
61
+ success: boolean;
62
+ error?: string;
63
+ };
64
+
65
+ type MessageContext = {
66
+ channelId: string;
67
+ accountId?: string;
68
+ conversationId?: string;
69
+ };
70
+
71
+ type AgentEndEvent = {
72
+ messages: unknown[];
73
+ success: boolean;
74
+ error?: string;
75
+ durationMs?: number;
76
+ };
77
+
78
+ type BeforeResetEvent = {
79
+ sessionFile?: string;
80
+ messages?: unknown[];
81
+ reason?: string;
82
+ };
83
+
84
+ type SessionEndEvent = {
85
+ sessionId: string;
86
+ sessionKey?: string;
87
+ messageCount: number;
88
+ durationMs?: number;
89
+ };
90
+
91
+ type SessionContext = {
92
+ agentId?: string;
93
+ sessionId: string;
94
+ sessionKey?: string;
95
+ };
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Constants
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const STOP_WORDS = new Set([
102
+ "the", "and", "for", "are", "but", "not", "you", "all", "can",
103
+ "her", "was", "one", "our", "out", "has", "have", "had", "this",
104
+ "that", "with", "from", "they", "been", "said", "each", "which",
105
+ "their", "will", "other", "about", "many", "then", "them", "these",
106
+ "some", "would", "make", "like", "into", "could", "time", "very",
107
+ "when", "what", "your", "how", "been", "just", "know", "take",
108
+ "people", "come", "could", "than", "look", "only", "also", "over",
109
+ ]);
110
+
111
+ const MAX_PAGES = 5;
112
+ const PAGE_TRUNCATE_CHARS = 2000;
113
+ const CONV_SECTION_CHARS = 1000;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // State
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Map key → workspaceDir, populated by before_prompt_build. */
120
+ const workspaceCache = new Map<string, string>();
121
+
122
+ /** Buffer last user message per conversationId for pairing with agent_end. */
123
+ const messageBuffer = new Map<string, { content: string; timestamp: number }>();
124
+
125
+ /** Track which sessions have had their frontmatter written. */
126
+ const initializedSessions = new Set<string>();
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Helpers
130
+ // ---------------------------------------------------------------------------
131
+
132
+ function today(): string {
133
+ return new Date().toISOString().split("T")[0];
134
+ }
135
+
136
+ function now(): string {
137
+ return new Date().toISOString();
138
+ }
139
+
140
+ function timeOnly(): string {
141
+ return new Date().toISOString().split("T")[1].replace("Z", "").split(".")[0];
142
+ }
143
+
144
+ function truncate(text: string, maxLen = 300): string {
145
+ if (!text || text.length <= maxLen) return text || "";
146
+ return text.slice(0, maxLen) + "...";
147
+ }
148
+
149
+ function readSafe(filePath: string): string {
150
+ try {
151
+ return fs.readFileSync(filePath, "utf-8");
152
+ } catch {
153
+ return "";
154
+ }
155
+ }
156
+
157
+ function extractKeywords(text: string): string[] {
158
+ return text
159
+ .toLowerCase()
160
+ .replace(/[^a-z0-9\s]/g, " ")
161
+ .split(/\s+/)
162
+ .filter((w) => w.length >= 3 && !STOP_WORDS.has(w));
163
+ }
164
+
165
+ function resolveBrainDir(ctx: AgentContext): string | null {
166
+ if (ctx.workspaceDir) {
167
+ const brainDir = path.join(ctx.workspaceDir, "brain");
168
+ if (fs.existsSync(brainDir)) return brainDir;
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Build a short session filename from sessionId.
175
+ * Uses first 8 chars of the UUID for readability.
176
+ */
177
+ function sessionFilename(sessionId: string): string {
178
+ const short = sessionId.slice(0, 8);
179
+ return `${today()}--${short}.md`;
180
+ }
181
+
182
+ /**
183
+ * Get or create the append-only conversation file for a session.
184
+ * Writes frontmatter on first access, then returns the path.
185
+ */
186
+ function ensureSessionFile(
187
+ brainDir: string,
188
+ ctx: AgentContext,
189
+ ): string {
190
+ const convDir = path.join(brainDir, "conversations");
191
+ if (!fs.existsSync(convDir)) {
192
+ fs.mkdirSync(convDir, { recursive: true });
193
+ }
194
+
195
+ const sid = ctx.sessionId || "unknown";
196
+ const filename = sessionFilename(sid);
197
+ const filePath = path.join(convDir, filename);
198
+
199
+ // Write frontmatter once per session
200
+ if (!initializedSessions.has(sid) && !fs.existsSync(filePath)) {
201
+ const channel = ctx.channelId || "tui";
202
+ const frontmatter = [
203
+ `---`,
204
+ `type: conversation`,
205
+ `sessionId: ${sid}`,
206
+ `sessionKey: ${ctx.sessionKey || "unknown"}`,
207
+ `channel: ${channel}`,
208
+ `started: ${now()}`,
209
+ `---`,
210
+ "",
211
+ ].join("\n");
212
+ fs.writeFileSync(filePath, frontmatter, "utf-8");
213
+ initializedSessions.add(sid);
214
+ } else if (!initializedSessions.has(sid) && fs.existsSync(filePath)) {
215
+ // File exists from a prior gateway restart — just mark as initialized
216
+ initializedSessions.add(sid);
217
+ }
218
+
219
+ return filePath;
220
+ }
221
+
222
+ /**
223
+ * Append a single turn to the session conversation file.
224
+ */
225
+ function appendTurn(
226
+ brainDir: string,
227
+ ctx: AgentContext,
228
+ userContent: string,
229
+ assistantContent: string,
230
+ ): string {
231
+ const filePath = ensureSessionFile(brainDir, ctx);
232
+ const time = timeOnly();
233
+
234
+ const turnBlock = [
235
+ "",
236
+ `**User** (${time}): ${truncate(userContent)}`,
237
+ `**Mnemo** (${time}): ${truncate(assistantContent)}`,
238
+ "",
239
+ "---",
240
+ "",
241
+ ].join("\n");
242
+
243
+ fs.appendFileSync(filePath, turnBlock, "utf-8");
244
+
245
+ return path.basename(filePath);
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Index parsing + keyword matching
250
+ // ---------------------------------------------------------------------------
251
+
252
+ type IndexEntry = { path: string; keywords: string };
253
+
254
+ function parseIndex(indexContent: string): IndexEntry[] {
255
+ const entries: IndexEntry[] = [];
256
+ for (const line of indexContent.split("\n")) {
257
+ const match = line.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]\s*[-—]\s*(.+)/);
258
+ if (match) {
259
+ entries.push({ path: match[1], keywords: match[2].toLowerCase() });
260
+ }
261
+ }
262
+ return entries;
263
+ }
264
+
265
+ function findRelevantPages(
266
+ indexEntries: IndexEntry[],
267
+ messageKeywords: string[],
268
+ ): IndexEntry[] {
269
+ const scored = indexEntries.map((entry) => {
270
+ let score = 0;
271
+ for (const kw of messageKeywords) {
272
+ if (entry.keywords.includes(kw)) score += 1;
273
+ }
274
+ return { ...entry, score };
275
+ });
276
+
277
+ return scored
278
+ .filter((e) => e.score > 0)
279
+ .sort((a, b) => b.score - a.score)
280
+ .slice(0, MAX_PAGES);
281
+ }
282
+
283
+ function getRecentConversations(brainDir: string): string {
284
+ const convDir = path.join(brainDir, "conversations");
285
+ if (!fs.existsSync(convDir)) return "";
286
+
287
+ try {
288
+ const files = fs
289
+ .readdirSync(convDir)
290
+ .filter((f) => f.endsWith(".md") && f !== "index.md")
291
+ .sort()
292
+ .slice(-3);
293
+
294
+ return files
295
+ .map((f) => readSafe(path.join(convDir, f)))
296
+ .filter(Boolean)
297
+ .join("\n\n---\n\n");
298
+ } catch {
299
+ return "";
300
+ }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Hook handlers
305
+ // ---------------------------------------------------------------------------
306
+
307
+ /**
308
+ * before_prompt_build — inject brain context into the agent's system prompt.
309
+ *
310
+ * Two responsibilities:
311
+ * 1. RAG: keyword-match wiki pages and inject as context
312
+ * 2. Pending work nudge: if there are unprocessed extractions, remind the agent
313
+ */
314
+ export function handleBeforePromptBuild(
315
+ event: BeforePromptBuildEvent,
316
+ ctx: AgentContext,
317
+ logger?: PluginLogger,
318
+ ): BeforePromptBuildResult | void {
319
+ try {
320
+ const brainDir = resolveBrainDir(ctx);
321
+ if (!brainDir) return;
322
+
323
+ // Cache workspace for other hooks
324
+ if (ctx.channelId && ctx.workspaceDir) {
325
+ workspaceCache.set(ctx.channelId, ctx.workspaceDir);
326
+ }
327
+ if (ctx.agentId && ctx.workspaceDir) {
328
+ workspaceCache.set(`agent:${ctx.agentId}`, ctx.workspaceDir);
329
+ }
330
+
331
+ const sections: string[] = [];
332
+
333
+ // --- RAG: keyword match wiki pages ---
334
+ const prompt = event.prompt || "";
335
+ if (prompt.trim()) {
336
+ const indexContent = readSafe(path.join(brainDir, "index.md"));
337
+ if (indexContent) {
338
+ const keywords = extractKeywords(prompt);
339
+ if (keywords.length > 0) {
340
+ const indexEntries = parseIndex(indexContent);
341
+ const matches = findRelevantPages(indexEntries, keywords);
342
+ logger?.info(`brain: ${keywords.length} keywords, ${indexEntries.length} index entries, ${matches.length} matches`);
343
+
344
+ const pageContents = matches
345
+ .map((m) => {
346
+ const content = readSafe(path.join(brainDir, m.path + ".md"));
347
+ if (!content) return null;
348
+ const truncated =
349
+ content.length > PAGE_TRUNCATE_CHARS
350
+ ? content.slice(0, PAGE_TRUNCATE_CHARS) + "\n...(truncated)"
351
+ : content;
352
+ return `### [[${m.path}]]\n${truncated}`;
353
+ })
354
+ .filter(Boolean);
355
+
356
+ if (pageContents.length > 0) {
357
+ sections.push(
358
+ "The following pages from your knowledge base are relevant to this query:",
359
+ "",
360
+ ...(pageContents as string[]),
361
+ );
362
+ logger?.info(`brain: injecting ${pageContents.length} wiki pages as context`);
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ // --- Pending work nudge ---
369
+ const pendingPath = path.join(brainDir, ".pending-extractions.json");
370
+ try {
371
+ const raw = readSafe(pendingPath);
372
+ if (raw) {
373
+ const pending = JSON.parse(raw);
374
+ if (Array.isArray(pending)) {
375
+ const unprocessed = pending.filter(
376
+ (e: { processed?: boolean }) => !e.processed,
377
+ );
378
+ if (unprocessed.length > 0) {
379
+ sections.push(
380
+ "",
381
+ `**Pending work:** You have ${unprocessed.length} unprocessed conversation(s) in \`.pending-extractions.json\`. ` +
382
+ `Review them and extract any noteworthy entities or concepts into wiki pages. ` +
383
+ `Mark each entry as \`"processed": true\` when done.`,
384
+ );
385
+ logger?.info(`brain: nudging agent about ${unprocessed.length} pending extractions`);
386
+ }
387
+ }
388
+ }
389
+ } catch {
390
+ // non-fatal
391
+ }
392
+
393
+ if (sections.length === 0) return;
394
+
395
+ const context = [
396
+ "--- SECOND BRAIN CONTEXT ---",
397
+ ...sections,
398
+ "--- END BRAIN CONTEXT ---",
399
+ ].join("\n");
400
+
401
+ return { prependContext: context };
402
+ } catch (err) {
403
+ logger?.warn(`brain: before_prompt_build error: ${err}`);
404
+ }
405
+ }
406
+
407
+ /**
408
+ * message_received — buffer the user's message for pairing with agent_end.
409
+ */
410
+ export function handleMessageReceived(
411
+ event: MessageReceivedEvent,
412
+ ctx: MessageContext,
413
+ logger?: PluginLogger,
414
+ ): void {
415
+ try {
416
+ const content = event.content?.trim();
417
+ if (!content) return;
418
+
419
+ const key = ctx.conversationId || ctx.channelId || "default";
420
+ messageBuffer.set(key, { content, timestamp: Date.now() });
421
+ // Also buffer under a generic key that agent_end can find
422
+ messageBuffer.set("_last", { content, timestamp: Date.now() });
423
+
424
+ logger?.info(`brain: buffered user message for ${key}`);
425
+ } catch (err) {
426
+ logger?.warn(`brain: message_received error: ${err}`);
427
+ }
428
+ }
429
+
430
+ /**
431
+ * message_sent — pair with buffered user message and log the turn.
432
+ * Only fires for channel messages (not TUI). Kept as a fallback.
433
+ */
434
+ export function handleMessageSent(
435
+ _event: MessageSentEvent,
436
+ _ctx: MessageContext,
437
+ _logger?: PluginLogger,
438
+ ): void {
439
+ // message_sent does not fire for TUI — agent_end handles that.
440
+ // This is kept for channel messages (Discord, Telegram, etc.)
441
+ // if they're ever connected. No-op for now.
442
+ }
443
+
444
+ /**
445
+ * agent_end — append the turn to the session's conversation file.
446
+ *
447
+ * Fires for all agent runs including TUI. Extracts the last user +
448
+ * assistant messages and appends them to brain/conversations/{sessionId}.md
449
+ */
450
+ export function handleAgentEnd(
451
+ event: AgentEndEvent,
452
+ ctx: AgentContext,
453
+ logger?: PluginLogger,
454
+ ): void {
455
+ try {
456
+ if (!event.success) return;
457
+
458
+ const brainDir = resolveBrainDir(ctx);
459
+ if (!brainDir) return;
460
+
461
+ const messages = event.messages || [];
462
+
463
+ // Use the buffered user message from message_received — it has the
464
+ // clean user input before any prependContext injection.
465
+ const buffered = messageBuffer.get("_last");
466
+ const lastUserContent = buffered?.content || "";
467
+ if (buffered) messageBuffer.delete("_last");
468
+
469
+ // Find last assistant message from event.messages
470
+ let lastAssistantContent = "";
471
+ for (let i = messages.length - 1; i >= 0; i--) {
472
+ const msg = messages[i] as { role?: string; content?: string | Array<{ text?: string }> };
473
+ if (msg?.role !== "assistant") continue;
474
+
475
+ let content = "";
476
+ if (typeof msg.content === "string") {
477
+ content = msg.content.trim();
478
+ } else if (Array.isArray(msg.content)) {
479
+ content = msg.content
480
+ .filter((b): b is { text: string } => typeof b === "object" && !!b?.text)
481
+ .map((b) => b.text)
482
+ .join("\n")
483
+ .trim();
484
+ }
485
+ if (!content) continue;
486
+
487
+ lastAssistantContent = content.replace(/^\[\[reply_to_current\]\]\s*/i, "");
488
+ break;
489
+ }
490
+
491
+ if (!lastUserContent || !lastAssistantContent) return;
492
+
493
+ const filename = appendTurn(brainDir, ctx, lastUserContent, lastAssistantContent);
494
+ logger?.info(`brain: appended turn to conversations/${filename}`);
495
+ } catch (err) {
496
+ logger?.warn(`brain: agent_end error: ${err}`);
497
+ }
498
+ }
499
+
500
+ /**
501
+ * session_end — queue the finished session for extraction processing.
502
+ *
503
+ * Writes a single log.md entry for the session and adds it to
504
+ * .pending-extractions.json so the agent's Auto-Curate workflow
505
+ * can process it into wiki pages.
506
+ */
507
+ export function handleSessionEnd(
508
+ event: SessionEndEvent,
509
+ ctx: SessionContext,
510
+ logger?: PluginLogger,
511
+ ): void {
512
+ try {
513
+ logger?.info(`brain: session_end fired, sessionId=${ctx.sessionId}, agentId=${ctx.agentId}`);
514
+
515
+ // Resolve workspace from cache (populated by before_prompt_build)
516
+ // Fall back to scanning ~/.openclaw/ if cache miss
517
+ const agentId = ctx.agentId;
518
+ let cachedWorkspace = agentId ? workspaceCache.get(`agent:${agentId}`) : undefined;
519
+ if (!cachedWorkspace && agentId) {
520
+ // Fallback: construct workspace path from agentId
521
+ const candidate = path.join(os.homedir(), ".openclaw", `workspace-${agentId}`);
522
+ if (fs.existsSync(candidate)) cachedWorkspace = candidate;
523
+ }
524
+ const brainDir = cachedWorkspace
525
+ ? (() => { const d = path.join(cachedWorkspace, "brain"); return fs.existsSync(d) ? d : null; })()
526
+ : null;
527
+ if (!brainDir) {
528
+ logger?.info(`brain: session_end — no brain dir found for agent ${agentId}, cache keys: [${[...workspaceCache.keys()].join(", ")}]`);
529
+ return;
530
+ }
531
+
532
+ const sid = ctx.sessionId || "unknown";
533
+ const filename = sessionFilename(sid);
534
+ const convPath = path.join(brainDir, "conversations", filename);
535
+
536
+ logger?.info(`brain: session_end — looking for ${convPath}, exists=${fs.existsSync(convPath)}`);
537
+ if (!fs.existsSync(convPath)) return;
538
+
539
+ // Count turns in the file (each "**User**" line = 1 turn)
540
+ const content = readSafe(convPath);
541
+ const turnCount = (content.match(/^\*\*User\*\*/gm) || []).length;
542
+ if (turnCount === 0) return;
543
+
544
+ // Write one log.md entry for the session
545
+ const logPath = path.join(brainDir, "log.md");
546
+ if (fs.existsSync(logPath)) {
547
+ fs.appendFileSync(
548
+ logPath,
549
+ `\n## [${today()}] session | ${turnCount} turns\n\n` +
550
+ `- Session: \`conversations/${filename}\`\n` +
551
+ `- Channel: ${(ctx.sessionKey || "").includes("platform") ? "platform" : "tui"}\n` +
552
+ `- Ended: ${now()}\n\n`,
553
+ "utf-8",
554
+ );
555
+ }
556
+
557
+ // Queue for extraction
558
+ const pendingPath = path.join(brainDir, ".pending-extractions.json");
559
+ let pending: unknown[] = [];
560
+ try {
561
+ pending = JSON.parse(readSafe(pendingPath) || "[]");
562
+ } catch {
563
+ // no-op
564
+ }
565
+ if (!Array.isArray(pending)) pending = [];
566
+
567
+ pending.push({
568
+ date: today(),
569
+ timestamp: now(),
570
+ sessionFile: `conversations/${filename}`,
571
+ turns: turnCount,
572
+ processed: false,
573
+ });
574
+ if (pending.length > 50) pending = pending.slice(-50);
575
+ fs.writeFileSync(pendingPath, JSON.stringify(pending, null, 2), "utf-8");
576
+
577
+ // Clean up session tracking
578
+ initializedSessions.delete(sid);
579
+
580
+ logger?.info(`brain: session ended — ${turnCount} turns queued for extraction`);
581
+ } catch (err) {
582
+ logger?.warn(`brain: before_reset error: ${err}`);
583
+ }
584
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ChannelPlugin definition for platform.
3
+ *
4
+ * Per the OpenClaw SDK spec, only `id` and `setup` are required.
5
+ * Additional adapters (security, pairing, threading, outbound) are
6
+ * opt-in and not needed for a transparent relay channel.
7
+ */
8
+
9
+ import type { ChannelGatewayContext, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk";
10
+ import { resolveAccount, inspectAccount, type PlatformAccount } from "./config.js";
11
+ import { startAccount } from "./monitor.js";
12
+
13
+ export const PlatformChannel: ChannelPlugin = {
14
+ id: "platform",
15
+
16
+ meta: {
17
+ id: "platform",
18
+ label: "platform",
19
+ selectionLabel: "platform",
20
+ docsPath: "/channels/platform",
21
+ blurb: "Connect OpenClaw to character platform.",
22
+ aliases: ["platform"],
23
+ },
24
+
25
+ capabilities: {
26
+ chatTypes: ["direct"] as const,
27
+ },
28
+
29
+ threading: {
30
+ resolveReplyToMode: () => "all",
31
+ },
32
+
33
+ config: {
34
+ listAccountIds(cfg: OpenClawConfig): string[] {
35
+ const platform = cfg?.channels?.platform;
36
+ if (!platform?.enabled) return [];
37
+ return ["platform"];
38
+ },
39
+
40
+ resolveAccount(cfg: OpenClawConfig, accountId?: string | null): PlatformAccount {
41
+ return resolveAccount(cfg, accountId ?? undefined);
42
+ },
43
+
44
+ inspectAccount(cfg: OpenClawConfig, accountId?: string | null) {
45
+ return inspectAccount(cfg, accountId);
46
+ },
47
+ },
48
+
49
+ gateway: {
50
+ async startAccount(ctx: ChannelGatewayContext<PlatformAccount>): Promise<void> {
51
+ await startAccount(ctx);
52
+ },
53
+ },
54
+ };