@botcord/botcord 0.2.3 → 0.3.0-beta.20260401151650
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/index.ts +57 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.ts +98 -0
- package/src/constants.ts +1 -1
- package/src/inbound.ts +226 -19
- package/src/memory-hook.ts +71 -0
- package/src/memory-protocol.ts +117 -0
- package/src/memory.ts +142 -0
- package/src/owner-chat-stream.ts +20 -0
- package/src/poller.ts +2 -11
- package/src/room-context.ts +269 -0
- package/src/tools/room-context.ts +139 -0
- package/src/ws-client.ts +24 -16
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory protocol — prompt injection and <memory_update> extraction.
|
|
3
|
+
*
|
|
4
|
+
* - buildWorkingMemoryPrompt(): generates the system context block that
|
|
5
|
+
* instructs the agent to use <memory_update> and shows current memory.
|
|
6
|
+
* - extractMemoryUpdate(): parses agent output, strips <memory_update>
|
|
7
|
+
* blocks, and returns the cleaned text + extracted memory content.
|
|
8
|
+
*/
|
|
9
|
+
import type { WorkingMemory } from "./memory.js";
|
|
10
|
+
|
|
11
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const MEMORY_SIZE_WARN_CHARS = 2000;
|
|
14
|
+
|
|
15
|
+
/** Tags that must not appear literally in injected memory content. */
|
|
16
|
+
const RESERVED_TAGS_RE =
|
|
17
|
+
/<\/?(current_memory|memory_update)\b[^>]*>/gi;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sanitize memory content before embedding in the prompt.
|
|
21
|
+
* Neutralizes reserved protocol tags to prevent prompt injection via
|
|
22
|
+
* persisted memory content.
|
|
23
|
+
*/
|
|
24
|
+
function sanitizeMemoryContent(content: string): string {
|
|
25
|
+
return content.replace(RESERVED_TAGS_RE, (tag) =>
|
|
26
|
+
tag.replace(/</g, "‹").replace(/>/g, "›"),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Prompt builder ─────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the working memory section to inject into the agent's system prompt.
|
|
34
|
+
*/
|
|
35
|
+
export function buildWorkingMemoryPrompt(params: {
|
|
36
|
+
workingMemory: WorkingMemory | null;
|
|
37
|
+
warnLarge?: boolean;
|
|
38
|
+
}): string {
|
|
39
|
+
const { workingMemory, warnLarge = true } = params;
|
|
40
|
+
|
|
41
|
+
const lines: string[] = [
|
|
42
|
+
`[BotCord Working Memory]`,
|
|
43
|
+
`You have a persistent working memory that survives across sessions and rooms.`,
|
|
44
|
+
`Use it to track important facts, pending tasks, and context you want to remember.`,
|
|
45
|
+
``,
|
|
46
|
+
`To update your working memory, include a <memory_update> block in your response:`,
|
|
47
|
+
`<memory_update>`,
|
|
48
|
+
`- Complete replacement content for your working memory`,
|
|
49
|
+
`- Include everything you want to remember (this replaces, not appends)`,
|
|
50
|
+
`</memory_update>`,
|
|
51
|
+
``,
|
|
52
|
+
`Rules:`,
|
|
53
|
+
`- The <memory_update> block will be stripped from your visible reply — it is never sent to other agents.`,
|
|
54
|
+
`- Content inside <memory_update> must be the COMPLETE new working memory, not a delta.`,
|
|
55
|
+
`- Only update when something meaningful changes. Do not update on every turn.`,
|
|
56
|
+
`- Keep it concise: focus on actionable items, pending commitments, and key context.`,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
if (workingMemory?.content) {
|
|
60
|
+
const content = sanitizeMemoryContent(workingMemory.content);
|
|
61
|
+
lines.push(``);
|
|
62
|
+
lines.push(`Current working memory (last updated: ${workingMemory.updatedAt}):`);
|
|
63
|
+
lines.push(`<current_memory>`);
|
|
64
|
+
lines.push(content);
|
|
65
|
+
lines.push(`</current_memory>`);
|
|
66
|
+
|
|
67
|
+
if (warnLarge && content.length > MEMORY_SIZE_WARN_CHARS) {
|
|
68
|
+
lines.push(``);
|
|
69
|
+
lines.push(
|
|
70
|
+
`⚠ Your working memory is ${content.length} characters. ` +
|
|
71
|
+
`Consider condensing it to keep token usage low.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
lines.push(``);
|
|
76
|
+
lines.push(`Your working memory is currently empty.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Memory update extraction ───────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const MEMORY_UPDATE_RE =
|
|
85
|
+
/<memory_update>([\s\S]*?)<\/memory_update>/g;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract <memory_update> blocks from agent output text.
|
|
89
|
+
*
|
|
90
|
+
* Returns:
|
|
91
|
+
* - cleanedText: the text with all <memory_update> blocks removed
|
|
92
|
+
* - memoryContent: the last <memory_update> content (complete replacement),
|
|
93
|
+
* or null if no block was found
|
|
94
|
+
*/
|
|
95
|
+
export function extractMemoryUpdate(text: string): {
|
|
96
|
+
cleanedText: string;
|
|
97
|
+
memoryContent: string | null;
|
|
98
|
+
} {
|
|
99
|
+
let memoryContent: string | null = null;
|
|
100
|
+
let match: RegExpExecArray | null;
|
|
101
|
+
|
|
102
|
+
// Reset regex state
|
|
103
|
+
MEMORY_UPDATE_RE.lastIndex = 0;
|
|
104
|
+
|
|
105
|
+
while ((match = MEMORY_UPDATE_RE.exec(text)) !== null) {
|
|
106
|
+
// Use the last match (complete replacement semantics)
|
|
107
|
+
memoryContent = match[1].trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Remove all <memory_update> blocks from the text
|
|
111
|
+
const cleanedText = text
|
|
112
|
+
.replace(MEMORY_UPDATE_RE, "")
|
|
113
|
+
.replace(/\n{3,}/g, "\n\n") // collapse excessive blank lines left by removal
|
|
114
|
+
.trim();
|
|
115
|
+
|
|
116
|
+
return { cleanedText, memoryContent };
|
|
117
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working Memory & Room State — persistent local storage for agent memory.
|
|
3
|
+
*
|
|
4
|
+
* Storage layout (under OpenClaw agent workspace):
|
|
5
|
+
* {workspace}/memory/botcord/working-memory.json
|
|
6
|
+
* {workspace}/memory/botcord/rooms/{roomId}.json
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { getBotCordRuntime, getConfig } from "./runtime.js";
|
|
12
|
+
|
|
13
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type WorkingMemory = {
|
|
16
|
+
version: 1;
|
|
17
|
+
content: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
sourceSessionKey?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RoomState = {
|
|
23
|
+
version: 1;
|
|
24
|
+
checkpointMsgId?: string;
|
|
25
|
+
lastSeenAt?: string;
|
|
26
|
+
mentionBacklog?: number;
|
|
27
|
+
openTopicHints?: Array<{ topicId: string; title?: string; status?: string }>;
|
|
28
|
+
note?: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── Workspace resolution ───────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const MEMORY_SUBDIR = "memory/botcord";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the base memory directory.
|
|
38
|
+
*
|
|
39
|
+
* Tries OpenClaw's workspace API first; falls back to ~/.botcord/memory.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveMemoryDir(): string {
|
|
42
|
+
try {
|
|
43
|
+
const runtime = getBotCordRuntime();
|
|
44
|
+
const cfg = getConfig();
|
|
45
|
+
// OpenClaw workspace API (if available)
|
|
46
|
+
const workspaceDir =
|
|
47
|
+
(runtime as any).agent?.resolveAgentWorkspaceDir?.(cfg) ??
|
|
48
|
+
(runtime as any).agent?.ensureAgentWorkspace?.(cfg);
|
|
49
|
+
if (typeof workspaceDir === "string" && workspaceDir) {
|
|
50
|
+
return path.join(workspaceDir, MEMORY_SUBDIR);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// runtime not initialized or API unavailable — fall through
|
|
54
|
+
}
|
|
55
|
+
return path.join(os.homedir(), ".botcord", "memory");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Atomic file helpers ────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function ensureDir(dir: string): void {
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readJsonFile<T>(filePath: string): T | null {
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
67
|
+
return JSON.parse(raw) as T;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Atomic write: write to a unique .tmp file, then rename over the target.
|
|
75
|
+
* Uses PID + timestamp suffix to avoid collisions from concurrent writers.
|
|
76
|
+
* Prevents half-written files on crash.
|
|
77
|
+
*/
|
|
78
|
+
function writeJsonFileAtomic(filePath: string, data: unknown): void {
|
|
79
|
+
const dir = path.dirname(filePath);
|
|
80
|
+
ensureDir(dir);
|
|
81
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
82
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
83
|
+
renameSync(tmp, filePath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Working Memory ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
function workingMemoryPath(memDir?: string): string {
|
|
89
|
+
return path.join(memDir ?? resolveMemoryDir(), "working-memory.json");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readWorkingMemory(memDir?: string): WorkingMemory | null {
|
|
93
|
+
return readJsonFile<WorkingMemory>(workingMemoryPath(memDir));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function writeWorkingMemory(
|
|
97
|
+
data: WorkingMemory,
|
|
98
|
+
memDir?: string,
|
|
99
|
+
): void {
|
|
100
|
+
writeJsonFileAtomic(workingMemoryPath(memDir), data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Room State ─────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function roomStatePath(roomId: string, memDir?: string): string {
|
|
106
|
+
return path.join(memDir ?? resolveMemoryDir(), "rooms", `${roomId}.json`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function readRoomState(
|
|
110
|
+
roomId: string,
|
|
111
|
+
memDir?: string,
|
|
112
|
+
): RoomState | null {
|
|
113
|
+
return readJsonFile<RoomState>(roomStatePath(roomId, memDir));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function writeRoomState(
|
|
117
|
+
roomId: string,
|
|
118
|
+
data: RoomState,
|
|
119
|
+
memDir?: string,
|
|
120
|
+
): void {
|
|
121
|
+
writeJsonFileAtomic(roomStatePath(roomId, memDir), data);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge partial updates into an existing room state (or create new).
|
|
126
|
+
* Returns the merged state.
|
|
127
|
+
*/
|
|
128
|
+
export function updateRoomState(
|
|
129
|
+
roomId: string,
|
|
130
|
+
updates: Partial<Omit<RoomState, "version">>,
|
|
131
|
+
memDir?: string,
|
|
132
|
+
): RoomState {
|
|
133
|
+
const existing = readRoomState(roomId, memDir);
|
|
134
|
+
const merged: RoomState = {
|
|
135
|
+
version: 1,
|
|
136
|
+
...existing,
|
|
137
|
+
...updates,
|
|
138
|
+
updatedAt: new Date().toISOString(),
|
|
139
|
+
};
|
|
140
|
+
writeRoomState(roomId, merged, memDir);
|
|
141
|
+
return merged;
|
|
142
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks active owner-chat streaming sessions.
|
|
3
|
+
*
|
|
4
|
+
* When the plugin processes an owner-chat message, a stream entry is created
|
|
5
|
+
* so that the after_tool_call hook can stream execution blocks back to Hub.
|
|
6
|
+
*/
|
|
7
|
+
import type { BotCordClient } from "./client.js";
|
|
8
|
+
|
|
9
|
+
export interface OwnerChatStream {
|
|
10
|
+
traceId: string;
|
|
11
|
+
client: BotCordClient;
|
|
12
|
+
seq: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Map of OpenClaw session key -> active stream.
|
|
17
|
+
* Entries are added when handleDashboardUserChat starts processing and
|
|
18
|
+
* removed when dispatch completes.
|
|
19
|
+
*/
|
|
20
|
+
export const activeOwnerChatStreams = new Map<string, OwnerChatStream>();
|
package/src/poller.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Used when websocket delivery is unavailable.
|
|
4
4
|
*/
|
|
5
5
|
import { BotCordClient } from "./client.js";
|
|
6
|
-
import {
|
|
6
|
+
import { handleInboxMessageBatch } from "./inbound.js";
|
|
7
7
|
import { displayPrefix } from "./config.js";
|
|
8
8
|
|
|
9
9
|
interface PollerOptions {
|
|
@@ -29,16 +29,7 @@ export function startPoller(opts: PollerOptions): { stop: () => void } {
|
|
|
29
29
|
try {
|
|
30
30
|
const resp = await client.pollInbox({ limit: 20, ack: false });
|
|
31
31
|
const messages = resp.messages || [];
|
|
32
|
-
const ackedIds
|
|
33
|
-
|
|
34
|
-
for (const msg of messages) {
|
|
35
|
-
try {
|
|
36
|
-
await handleInboxMessage(msg, accountId, cfg);
|
|
37
|
-
ackedIds.push(msg.hub_msg_id);
|
|
38
|
-
} catch (err: any) {
|
|
39
|
-
log?.error(`[${dp}] failed to dispatch message ${msg.hub_msg_id}: ${err.message}`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
32
|
+
const ackedIds = await handleInboxMessageBatch(messages, accountId, cfg);
|
|
42
33
|
|
|
43
34
|
if (ackedIds.length > 0) {
|
|
44
35
|
try {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room context injection for cross-session awareness.
|
|
3
|
+
*
|
|
4
|
+
* Two layers:
|
|
5
|
+
* 1. Static layer (appendSystemContext) — room name, description, rule, members.
|
|
6
|
+
* Cacheable by the provider prompt cache since it rarely changes.
|
|
7
|
+
* 2. Dynamic layer (prependContext) — cross-room activity digest listing
|
|
8
|
+
* other active rooms so the agent is aware of parallel conversations.
|
|
9
|
+
*/
|
|
10
|
+
import { getBotCordRuntime, getConfig } from "./runtime.js";
|
|
11
|
+
import { resolveAccountConfig, resolveChannelConfig, resolveAccounts, isAccountConfigured } from "./config.js";
|
|
12
|
+
import { attachTokenPersistence } from "./credentials.js";
|
|
13
|
+
import type { RoomInfo } from "./types.js";
|
|
14
|
+
|
|
15
|
+
// ── Session ↔ Room mapping ──────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type SessionRoomEntry = {
|
|
18
|
+
roomId: string;
|
|
19
|
+
roomName?: string;
|
|
20
|
+
accountId: string;
|
|
21
|
+
lastActivityAt: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** sessionKey → room info populated when inbound messages arrive. */
|
|
25
|
+
const sessionRoomMap = new Map<string, SessionRoomEntry>();
|
|
26
|
+
|
|
27
|
+
export function registerSessionRoom(
|
|
28
|
+
sessionKey: string,
|
|
29
|
+
entry: SessionRoomEntry,
|
|
30
|
+
): void {
|
|
31
|
+
sessionRoomMap.set(sessionKey, entry);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getSessionRoom(sessionKey: string): SessionRoomEntry | undefined {
|
|
35
|
+
return sessionRoomMap.get(sessionKey);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clearSessionRoom(sessionKey: string): void {
|
|
39
|
+
sessionRoomMap.delete(sessionKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getAllSessionRooms(): ReadonlyMap<string, SessionRoomEntry> {
|
|
43
|
+
return sessionRoomMap;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Room info cache ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
type CachedRoomInfo = {
|
|
49
|
+
room: RoomInfo;
|
|
50
|
+
members: { agent_id: string; display_name?: string; role?: string }[];
|
|
51
|
+
fetchedAt: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const roomInfoCache = new Map<string, CachedRoomInfo>();
|
|
55
|
+
const ROOM_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
56
|
+
|
|
57
|
+
async function fetchRoomInfoCached(
|
|
58
|
+
roomId: string,
|
|
59
|
+
accountId: string,
|
|
60
|
+
): Promise<CachedRoomInfo | null> {
|
|
61
|
+
// Key by accountId:roomId so multi-account setups don't cross-pollinate
|
|
62
|
+
const cacheKey = `${accountId}:${roomId}`;
|
|
63
|
+
const existing = roomInfoCache.get(cacheKey);
|
|
64
|
+
if (existing && Date.now() - existing.fetchedAt < ROOM_CACHE_TTL_MS) {
|
|
65
|
+
return existing;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const cfg = getConfig();
|
|
70
|
+
if (!cfg) return existing ?? null;
|
|
71
|
+
|
|
72
|
+
const acct = resolveAccountConfig(cfg, accountId);
|
|
73
|
+
if (!acct || !isAccountConfigured(acct)) return existing ?? null;
|
|
74
|
+
|
|
75
|
+
const { BotCordClient } = await import("./client.js");
|
|
76
|
+
const client = new BotCordClient(acct);
|
|
77
|
+
attachTokenPersistence(client, acct);
|
|
78
|
+
|
|
79
|
+
const [room, members] = await Promise.all([
|
|
80
|
+
client.getRoomInfo(roomId),
|
|
81
|
+
client.getRoomMembers(roomId),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const entry: CachedRoomInfo = {
|
|
85
|
+
room,
|
|
86
|
+
members: members.map((m: any) => ({
|
|
87
|
+
agent_id: m.agent_id,
|
|
88
|
+
display_name: m.display_name,
|
|
89
|
+
role: m.role,
|
|
90
|
+
})),
|
|
91
|
+
fetchedAt: Date.now(),
|
|
92
|
+
};
|
|
93
|
+
roomInfoCache.set(cacheKey, entry);
|
|
94
|
+
return entry;
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
console.warn(`[botcord] room-context: failed to fetch room ${roomId}:`, err?.message ?? err);
|
|
97
|
+
return existing ?? null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Static context builder ──────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build cacheable system context for the current room session.
|
|
105
|
+
* Returns null if the session is not a room session.
|
|
106
|
+
*/
|
|
107
|
+
export async function buildRoomStaticContext(
|
|
108
|
+
sessionKey: string,
|
|
109
|
+
): Promise<string | null> {
|
|
110
|
+
const entry = sessionRoomMap.get(sessionKey);
|
|
111
|
+
if (!entry?.roomId) return null;
|
|
112
|
+
|
|
113
|
+
// Skip DM sessions — no room-level static context needed
|
|
114
|
+
if (entry.roomId.startsWith("rm_dm_")) return null;
|
|
115
|
+
|
|
116
|
+
const cached = await fetchRoomInfoCached(entry.roomId, entry.accountId);
|
|
117
|
+
if (!cached) return null;
|
|
118
|
+
|
|
119
|
+
const { room, members } = cached;
|
|
120
|
+
const lines: string[] = [
|
|
121
|
+
`[BotCord Room Context]`,
|
|
122
|
+
`Room: ${room.name} (${room.room_id})`,
|
|
123
|
+
];
|
|
124
|
+
if (room.description) {
|
|
125
|
+
lines.push(`Description: ${room.description}`);
|
|
126
|
+
}
|
|
127
|
+
if (room.rule) {
|
|
128
|
+
lines.push(`Rule: ${room.rule}`);
|
|
129
|
+
}
|
|
130
|
+
lines.push(`Visibility: ${room.visibility}, Join: ${room.join_policy}`);
|
|
131
|
+
|
|
132
|
+
const memberList = members
|
|
133
|
+
.map((m) => {
|
|
134
|
+
const name = m.display_name || m.agent_id;
|
|
135
|
+
return m.role && m.role !== "member" ? `${name} (${m.role})` : name;
|
|
136
|
+
})
|
|
137
|
+
.join(", ");
|
|
138
|
+
if (memberList) {
|
|
139
|
+
lines.push(`Members (${members.length}): ${memberList}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Cross-room activity digest ──────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a brief digest of other active BotCord rooms/sessions so the
|
|
149
|
+
* agent is aware of parallel conversations happening elsewhere.
|
|
150
|
+
*
|
|
151
|
+
* Reads the last few messages from each other session via
|
|
152
|
+
* `runtime.subagent.getSessionMessages()`.
|
|
153
|
+
*/
|
|
154
|
+
export async function buildCrossRoomDigest(
|
|
155
|
+
currentSessionKey: string,
|
|
156
|
+
): Promise<string | null> {
|
|
157
|
+
if (sessionRoomMap.size <= 1) return null;
|
|
158
|
+
|
|
159
|
+
const runtime = getBotCordRuntime();
|
|
160
|
+
const currentEntry = sessionRoomMap.get(currentSessionKey);
|
|
161
|
+
const currentAccountId = currentEntry?.accountId;
|
|
162
|
+
const otherSessions: { key: string; entry: SessionRoomEntry }[] = [];
|
|
163
|
+
|
|
164
|
+
for (const [key, entry] of sessionRoomMap) {
|
|
165
|
+
if (key === currentSessionKey) continue;
|
|
166
|
+
// Only include sessions belonging to the same BotCord account
|
|
167
|
+
if (entry.accountId !== currentAccountId) continue;
|
|
168
|
+
// Only include sessions with recent activity (last 2 hours)
|
|
169
|
+
if (Date.now() - entry.lastActivityAt > 2 * 60 * 60 * 1000) continue;
|
|
170
|
+
otherSessions.push({ key, entry });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (otherSessions.length === 0) return null;
|
|
174
|
+
|
|
175
|
+
// Sort by most recent activity first
|
|
176
|
+
otherSessions.sort((a, b) => b.entry.lastActivityAt - a.entry.lastActivityAt);
|
|
177
|
+
|
|
178
|
+
// Limit to 5 most active sessions to avoid excessive token usage
|
|
179
|
+
const toDigest = otherSessions.slice(0, 5);
|
|
180
|
+
// Count sessions for this account only (including current)
|
|
181
|
+
const accountSessionCount = otherSessions.length + 1;
|
|
182
|
+
const digestParts: string[] = [
|
|
183
|
+
`[BotCord Cross-Room Awareness] You are active in ${accountSessionCount} BotCord sessions. Here is recent activity from other rooms:`,
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
for (const { key, entry } of toDigest) {
|
|
187
|
+
const roomLabel = entry.roomName || entry.roomId;
|
|
188
|
+
const isDm = entry.roomId.startsWith("rm_dm_");
|
|
189
|
+
const typeLabel = isDm ? "DM" : "Room";
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const { messages } = await runtime.subagent.getSessionMessages({
|
|
193
|
+
sessionKey: key,
|
|
194
|
+
limit: 3,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (messages.length === 0) {
|
|
198
|
+
digestParts.push(`\n- ${typeLabel}: ${roomLabel} — no recent messages`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Extract a brief summary from the last messages
|
|
203
|
+
const previews = messages
|
|
204
|
+
.slice(-3)
|
|
205
|
+
.map((msg: any) => {
|
|
206
|
+
const role = msg.role || "unknown";
|
|
207
|
+
const text = (msg.content || msg.text || "").slice(0, 120);
|
|
208
|
+
return ` [${role}] ${text}${text.length >= 120 ? "…" : ""}`;
|
|
209
|
+
})
|
|
210
|
+
.join("\n");
|
|
211
|
+
|
|
212
|
+
const ago = formatTimeAgo(entry.lastActivityAt);
|
|
213
|
+
digestParts.push(`\n- ${typeLabel}: ${roomLabel} (${ago}):\n${previews}`);
|
|
214
|
+
} catch {
|
|
215
|
+
digestParts.push(`\n- ${typeLabel}: ${roomLabel} — unable to read messages`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return digestParts.join("");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function formatTimeAgo(timestamp: number): string {
|
|
223
|
+
const diffMs = Date.now() - timestamp;
|
|
224
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
225
|
+
if (diffMin < 1) return "just now";
|
|
226
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
227
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
228
|
+
return `${diffHr}h ago`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Combined hook handler ───────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* before_prompt_build handler that injects room context.
|
|
235
|
+
* Returns appendSystemContext (static, cacheable) and prependContext (dynamic).
|
|
236
|
+
*/
|
|
237
|
+
export async function buildRoomContextHookResult(
|
|
238
|
+
sessionKey: string | undefined,
|
|
239
|
+
): Promise<{
|
|
240
|
+
appendSystemContext?: string;
|
|
241
|
+
prependContext?: string;
|
|
242
|
+
} | null> {
|
|
243
|
+
if (!sessionKey) return null;
|
|
244
|
+
|
|
245
|
+
// Don't inject room context for the owner chat session
|
|
246
|
+
if (sessionKey === "botcord:owner:main") return null;
|
|
247
|
+
|
|
248
|
+
// Only inject for sessions we know are BotCord sessions (registered via
|
|
249
|
+
// inbound dispatch). This handles both native "botcord:..." keys and
|
|
250
|
+
// custom-routed keys that don't carry the prefix.
|
|
251
|
+
if (!sessionRoomMap.has(sessionKey)) return null;
|
|
252
|
+
|
|
253
|
+
const result: { appendSystemContext?: string; prependContext?: string } = {};
|
|
254
|
+
|
|
255
|
+
// Layer 1: Static room context (cacheable)
|
|
256
|
+
const staticCtx = await buildRoomStaticContext(sessionKey);
|
|
257
|
+
if (staticCtx) {
|
|
258
|
+
result.appendSystemContext = staticCtx;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Layer 2: Cross-room activity digest (dynamic, per-turn)
|
|
262
|
+
const digest = await buildCrossRoomDigest(sessionKey);
|
|
263
|
+
if (digest) {
|
|
264
|
+
result.prependContext = digest;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!result.appendSystemContext && !result.prependContext) return null;
|
|
268
|
+
return result;
|
|
269
|
+
}
|