diff --git a/openclaw/openclaw.plugin.json b/openclaw/openclaw.plugin.json index a304b670..046df4d9 100644 --- a/openclaw/openclaw.plugin.json +++ b/openclaw/openclaw.plugin.json @@ -45,6 +45,38 @@ "botToken": { "type": "string", "description": "Optional dedicated Telegram bot token for the feed (bypasses gateway channel)" + }, + "emojis": { + "type": "object", + "description": "Emoji personalization for the observation feed. Each agent gets a unique emoji automatically โ€” customize here to override.", + "properties": { + "primary": { + "type": "string", + "default": "๐Ÿฆž", + "description": "Emoji for the main OpenClaw gateway (project='openclaw')" + }, + "claudeCode": { + "type": "string", + "default": "โŒจ๏ธ", + "description": "Emoji for Claude Code sessions (non-OpenClaw)" + }, + "claudeCodeLabel": { + "type": "string", + "default": "Claude Code Session", + "description": "Display label prefix for Claude Code sessions in the feed (project identifier is appended automatically)" + }, + "default": { + "type": "string", + "default": "๐Ÿฆ€", + "description": "Fallback emoji when no match is found" + }, + "agents": { + "type": "object", + "default": {}, + "description": "Pin specific emojis to agent IDs (e.g. {\"devops\": \"๐Ÿ”ง\"}). Agents not listed here get auto-assigned emojis.", + "additionalProperties": { "type": "string" } + } + } } } } diff --git a/openclaw/src/index.test.ts b/openclaw/src/index.test.ts index 0c6bee6f..03a551a0 100644 --- a/openclaw/src/index.test.ts +++ b/openclaw/src/index.test.ts @@ -841,6 +841,77 @@ describe("SSE stream integration", () => { await getService().stop({}); }); + it("includes Claude Code project identifier in source label", async () => { + const { api, sentMessages, getService } = createMockApi({ + workerPort: serverPort, + observationFeed: { enabled: true, channel: "telegram", to: "12345" }, + }); + claudeMemPlugin(api); + + await getService().start({}); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const observation = { + type: "new_observation", + observation: { + id: 11, + title: "Project Label", + subtitle: "Check source label", + project: "workspace-alpha", + }, + timestamp: Date.now(), + }; + + for (const res of serverResponses) { + res.write(`data: ${JSON.stringify(observation)}\n\n`); + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.equal(sentMessages.length, 1); + assert.ok(sentMessages[0].text.includes("Claude Code Session (workspace-alpha)")); + + await getService().stop({}); + }); + + it("uses custom Claude Code label prefix while preserving project identifier", async () => { + const { api, sentMessages, getService } = createMockApi({ + workerPort: serverPort, + observationFeed: { + enabled: true, + channel: "telegram", + to: "12345", + emojis: { claudeCodeLabel: "Coding Session" }, + }, + }); + claudeMemPlugin(api); + + await getService().start({}); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const observation = { + type: "new_observation", + observation: { + id: 12, + title: "Custom Label", + subtitle: "Custom prefix", + project: "workspace-beta", + }, + timestamp: Date.now(), + }; + + for (const res of serverResponses) { + res.write(`data: ${JSON.stringify(observation)}\n\n`); + } + + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.equal(sentMessages.length, 1); + assert.ok(sentMessages[0].text.includes("Coding Session (workspace-beta)")); + + await getService().stop({}); + }); + it("filters out non-observation events", async () => { const { api, sentMessages, getService } = createMockApi({ workerPort: serverPort, diff --git a/openclaw/src/index.ts b/openclaw/src/index.ts index 809f229a..e2ab8b95 100644 --- a/openclaw/src/index.ts +++ b/openclaw/src/index.ts @@ -156,6 +156,14 @@ type ConnectionState = "disconnected" | "connected" | "reconnecting"; // Plugin Configuration // ============================================================================ +interface FeedEmojiConfig { + primary?: string; + claudeCode?: string; + claudeCodeLabel?: string; + default?: string; + agents?: Record; +} + interface ClaudeMemPluginConfig { syncMemoryFile?: boolean; project?: string; @@ -165,6 +173,7 @@ interface ClaudeMemPluginConfig { channel?: string; to?: string; botToken?: string; + emojis?: FeedEmojiConfig; }; } @@ -175,42 +184,57 @@ interface ClaudeMemPluginConfig { const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB const DEFAULT_WORKER_PORT = 37777; -// Agent emoji map for observation feed messages. -// When creating a new OpenClaw agent, add its agentId and emoji here. -const AGENT_EMOJI_MAP: Record = { - "main": "๐Ÿฆž", - "openclaw": "๐Ÿฆž", - "devops": "๐Ÿ”ง", - "architect": "๐Ÿ“", - "researcher": "๐Ÿ”", - "code-reviewer": "๐Ÿ”Ž", - "coder": "๐Ÿ’ป", - "tester": "๐Ÿงช", - "debugger": "๐Ÿ›", - "opsec": "๐Ÿ›ก๏ธ", - "cloudfarm": "โ˜๏ธ", - "extractor": "๐Ÿ“ฆ", -}; +// Emoji pool for deterministic auto-assignment to unknown agents. +// Uses a hash of the agentId to pick a consistent emoji โ€” no persistent state needed. +const EMOJI_POOL = [ + "๐Ÿ”ง","๐Ÿ“","๐Ÿ”","๐Ÿ’ป","๐Ÿงช","๐Ÿ›","๐Ÿ›ก๏ธ","โ˜๏ธ","๐Ÿ“ฆ","๐ŸŽฏ", + "๐Ÿ”ฎ","โšก","๐ŸŒŠ","๐ŸŽจ","๐Ÿ“Š","๐Ÿš€","๐Ÿ”ฌ","๐Ÿ—๏ธ","๐Ÿ“","๐ŸŽญ", +]; -// Project prefixes that indicate Claude Code sessions (not OpenClaw agents) -const CLAUDE_CODE_EMOJI = "โŒจ๏ธ"; -const OPENCLAW_DEFAULT_EMOJI = "๐Ÿฆ€"; +function poolEmojiForAgent(agentId: string): string { + let hash = 0; + for (let i = 0; i < agentId.length; i++) { + hash = ((hash << 5) - hash + agentId.charCodeAt(i)) | 0; + } + return EMOJI_POOL[Math.abs(hash) % EMOJI_POOL.length]; +} -function getSourceLabel(project: string | null | undefined): string { - if (!project) return OPENCLAW_DEFAULT_EMOJI; - // OpenClaw agent projects are formatted as "openclaw-" - if (project.startsWith("openclaw-")) { - const agentId = project.slice("openclaw-".length); - const emoji = AGENT_EMOJI_MAP[agentId] || OPENCLAW_DEFAULT_EMOJI; - return `${emoji} ${agentId}`; - } - // OpenClaw project without agent suffix - if (project === "openclaw") { - return `๐Ÿฆž openclaw`; - } - // Everything else is from Claude Code (project = working directory name) - const emoji = CLAUDE_CODE_EMOJI; - return `${emoji} ${project}`; +// Default emoji values โ€” overridden by user config via observationFeed.emojis +const DEFAULT_PRIMARY_EMOJI = "๐Ÿฆž"; +const DEFAULT_CLAUDE_CODE_EMOJI = "โŒจ๏ธ"; +const DEFAULT_CLAUDE_CODE_LABEL = "Claude Code Session"; +const DEFAULT_FALLBACK_EMOJI = "๐Ÿฆ€"; + +function buildGetSourceLabel( + emojiConfig: FeedEmojiConfig | undefined +): (project: string | null | undefined) => string { + const primary = emojiConfig?.primary ?? DEFAULT_PRIMARY_EMOJI; + const claudeCode = emojiConfig?.claudeCode ?? DEFAULT_CLAUDE_CODE_EMOJI; + const claudeCodeLabel = emojiConfig?.claudeCodeLabel ?? DEFAULT_CLAUDE_CODE_LABEL; + const fallback = emojiConfig?.default ?? DEFAULT_FALLBACK_EMOJI; + const pinnedAgents = emojiConfig?.agents ?? {}; + + return function getSourceLabel(project: string | null | undefined): string { + if (!project) return fallback; + // OpenClaw agent projects are formatted as "openclaw-" + if (project.startsWith("openclaw-")) { + const agentId = project.slice("openclaw-".length); + if (!agentId) return `${primary} openclaw`; + const emoji = pinnedAgents[agentId] || poolEmojiForAgent(agentId); + return `${emoji} ${agentId}`; + } + // OpenClaw project without agent suffix + if (project === "openclaw") { + return `${primary} openclaw`; + } + // Everything else is a Claude Code session. Keep the project identifier + // visible so concurrent sessions can be distinguished in the feed. + const trimmedLabel = claudeCodeLabel.trim(); + if (!trimmedLabel) { + return `${claudeCode} ${project}`; + } + return `${claudeCode} ${trimmedLabel} (${project})`; + }; } // ============================================================================ @@ -284,7 +308,10 @@ async function workerGetText( // SSE Observation Feed // ============================================================================ -function formatObservationMessage(observation: ObservationSSEPayload): string { +function formatObservationMessage( + observation: ObservationSSEPayload, + getSourceLabel: (project: string | null | undefined) => string, +): string { const title = observation.title || "Untitled"; const source = getSourceLabel(observation.project); let message = `${source}\n**${title}**`; @@ -380,6 +407,7 @@ async function connectToSSEStream( to: string, abortController: AbortController, setConnectionState: (state: ConnectionState) => void, + getSourceLabel: (project: string | null | undefined) => string, botToken?: string ): Promise { let backoffMs = 1000; @@ -440,7 +468,7 @@ async function connectToSSEStream( const parsed = JSON.parse(jsonStr); if (parsed.type === "new_observation" && parsed.observation) { const event = parsed as SSENewObservationEvent; - const message = formatObservationMessage(event.observation); + const message = formatObservationMessage(event.observation, getSourceLabel); await sendToChannel(api, channel, to, message, botToken); } } catch (parseError: unknown) { @@ -475,6 +503,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig; const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT; const baseProjectName = userConfig.project || "openclaw"; + const getSourceLabel = buildGetSourceLabel(userConfig.observationFeed?.emojis); function getProjectName(ctx: EventContext): string { if (ctx.agentId) { @@ -720,6 +749,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void { feedConfig.to, sseAbortController, (state) => { connectionState = state; }, + getSourceLabel, feedConfig.botToken ); },