From 55e0e323b95bab51eb40724451e07f38cf43add2 Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:29:55 -0400 Subject: [PATCH] feat: universalize observation feed emojis (#1100) * feat: universalize observation feed emojis with config-driven system Replace hardcoded AGENT_EMOJI_MAP with a three-tier approach: 1. User-pinned emojis via observationFeed.emojis.agents config 2. Deterministic auto-assign from pool using agentId hash 3. Configurable fallbacks for primary, Claude Code, and default emojis Claude Code sessions now display "Claude Code Session" instead of the working directory name. All emoji settings are exposed in the plugin configSchema so the onboarding wizard AI can discover and configure them. Co-Authored-By: Claude Opus 4.6 * fix(feed): keep Claude Code project id in source labels --------- Co-authored-by: Claude Opus 4.6 --- openclaw/openclaw.plugin.json | 32 +++++++++++ openclaw/src/index.test.ts | 71 +++++++++++++++++++++++ openclaw/src/index.ts | 102 ++++++++++++++++++++++------------ 3 files changed, 169 insertions(+), 36 deletions(-) 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 ); },