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 <noreply@anthropic.com> * fix(feed): keep Claude Code project id in source labels --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,38 @@
|
|||||||
"botToken": {
|
"botToken": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional dedicated Telegram bot token for the feed (bypasses gateway channel)"
|
"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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,6 +841,77 @@ describe("SSE stream integration", () => {
|
|||||||
await getService().stop({});
|
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 () => {
|
it("filters out non-observation events", async () => {
|
||||||
const { api, sentMessages, getService } = createMockApi({
|
const { api, sentMessages, getService } = createMockApi({
|
||||||
workerPort: serverPort,
|
workerPort: serverPort,
|
||||||
|
|||||||
+58
-28
@@ -156,6 +156,14 @@ type ConnectionState = "disconnected" | "connected" | "reconnecting";
|
|||||||
// Plugin Configuration
|
// Plugin Configuration
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FeedEmojiConfig {
|
||||||
|
primary?: string;
|
||||||
|
claudeCode?: string;
|
||||||
|
claudeCodeLabel?: string;
|
||||||
|
default?: string;
|
||||||
|
agents?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface ClaudeMemPluginConfig {
|
interface ClaudeMemPluginConfig {
|
||||||
syncMemoryFile?: boolean;
|
syncMemoryFile?: boolean;
|
||||||
project?: string;
|
project?: string;
|
||||||
@@ -165,6 +173,7 @@ interface ClaudeMemPluginConfig {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
botToken?: string;
|
botToken?: string;
|
||||||
|
emojis?: FeedEmojiConfig;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,42 +184,57 @@ interface ClaudeMemPluginConfig {
|
|||||||
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
|
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
|
||||||
const DEFAULT_WORKER_PORT = 37777;
|
const DEFAULT_WORKER_PORT = 37777;
|
||||||
|
|
||||||
// Agent emoji map for observation feed messages.
|
// Emoji pool for deterministic auto-assignment to unknown agents.
|
||||||
// When creating a new OpenClaw agent, add its agentId and emoji here.
|
// Uses a hash of the agentId to pick a consistent emoji — no persistent state needed.
|
||||||
const AGENT_EMOJI_MAP: Record<string, string> = {
|
const EMOJI_POOL = [
|
||||||
"main": "🦞",
|
"🔧","📐","🔍","💻","🧪","🐛","🛡️","☁️","📦","🎯",
|
||||||
"openclaw": "🦞",
|
"🔮","⚡","🌊","🎨","📊","🚀","🔬","🏗️","📝","🎭",
|
||||||
"devops": "🔧",
|
];
|
||||||
"architect": "📐",
|
|
||||||
"researcher": "🔍",
|
|
||||||
"code-reviewer": "🔎",
|
|
||||||
"coder": "💻",
|
|
||||||
"tester": "🧪",
|
|
||||||
"debugger": "🐛",
|
|
||||||
"opsec": "🛡️",
|
|
||||||
"cloudfarm": "☁️",
|
|
||||||
"extractor": "📦",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Project prefixes that indicate Claude Code sessions (not OpenClaw agents)
|
function poolEmojiForAgent(agentId: string): string {
|
||||||
const CLAUDE_CODE_EMOJI = "⌨️";
|
let hash = 0;
|
||||||
const OPENCLAW_DEFAULT_EMOJI = "🦀";
|
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 {
|
// Default emoji values — overridden by user config via observationFeed.emojis
|
||||||
if (!project) return OPENCLAW_DEFAULT_EMOJI;
|
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-<agentId>"
|
// OpenClaw agent projects are formatted as "openclaw-<agentId>"
|
||||||
if (project.startsWith("openclaw-")) {
|
if (project.startsWith("openclaw-")) {
|
||||||
const agentId = project.slice("openclaw-".length);
|
const agentId = project.slice("openclaw-".length);
|
||||||
const emoji = AGENT_EMOJI_MAP[agentId] || OPENCLAW_DEFAULT_EMOJI;
|
if (!agentId) return `${primary} openclaw`;
|
||||||
|
const emoji = pinnedAgents[agentId] || poolEmojiForAgent(agentId);
|
||||||
return `${emoji} ${agentId}`;
|
return `${emoji} ${agentId}`;
|
||||||
}
|
}
|
||||||
// OpenClaw project without agent suffix
|
// OpenClaw project without agent suffix
|
||||||
if (project === "openclaw") {
|
if (project === "openclaw") {
|
||||||
return `🦞 openclaw`;
|
return `${primary} openclaw`;
|
||||||
}
|
}
|
||||||
// Everything else is from Claude Code (project = working directory name)
|
// Everything else is a Claude Code session. Keep the project identifier
|
||||||
const emoji = CLAUDE_CODE_EMOJI;
|
// visible so concurrent sessions can be distinguished in the feed.
|
||||||
return `${emoji} ${project}`;
|
const trimmedLabel = claudeCodeLabel.trim();
|
||||||
|
if (!trimmedLabel) {
|
||||||
|
return `${claudeCode} ${project}`;
|
||||||
|
}
|
||||||
|
return `${claudeCode} ${trimmedLabel} (${project})`;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -284,7 +308,10 @@ async function workerGetText(
|
|||||||
// SSE Observation Feed
|
// 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 title = observation.title || "Untitled";
|
||||||
const source = getSourceLabel(observation.project);
|
const source = getSourceLabel(observation.project);
|
||||||
let message = `${source}\n**${title}**`;
|
let message = `${source}\n**${title}**`;
|
||||||
@@ -380,6 +407,7 @@ async function connectToSSEStream(
|
|||||||
to: string,
|
to: string,
|
||||||
abortController: AbortController,
|
abortController: AbortController,
|
||||||
setConnectionState: (state: ConnectionState) => void,
|
setConnectionState: (state: ConnectionState) => void,
|
||||||
|
getSourceLabel: (project: string | null | undefined) => string,
|
||||||
botToken?: string
|
botToken?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let backoffMs = 1000;
|
let backoffMs = 1000;
|
||||||
@@ -440,7 +468,7 @@ async function connectToSSEStream(
|
|||||||
const parsed = JSON.parse(jsonStr);
|
const parsed = JSON.parse(jsonStr);
|
||||||
if (parsed.type === "new_observation" && parsed.observation) {
|
if (parsed.type === "new_observation" && parsed.observation) {
|
||||||
const event = parsed as SSENewObservationEvent;
|
const event = parsed as SSENewObservationEvent;
|
||||||
const message = formatObservationMessage(event.observation);
|
const message = formatObservationMessage(event.observation, getSourceLabel);
|
||||||
await sendToChannel(api, channel, to, message, botToken);
|
await sendToChannel(api, channel, to, message, botToken);
|
||||||
}
|
}
|
||||||
} catch (parseError: unknown) {
|
} catch (parseError: unknown) {
|
||||||
@@ -475,6 +503,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
|||||||
const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig;
|
const userConfig = (api.pluginConfig || {}) as ClaudeMemPluginConfig;
|
||||||
const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT;
|
const workerPort = userConfig.workerPort || DEFAULT_WORKER_PORT;
|
||||||
const baseProjectName = userConfig.project || "openclaw";
|
const baseProjectName = userConfig.project || "openclaw";
|
||||||
|
const getSourceLabel = buildGetSourceLabel(userConfig.observationFeed?.emojis);
|
||||||
|
|
||||||
function getProjectName(ctx: EventContext): string {
|
function getProjectName(ctx: EventContext): string {
|
||||||
if (ctx.agentId) {
|
if (ctx.agentId) {
|
||||||
@@ -720,6 +749,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
|||||||
feedConfig.to,
|
feedConfig.to,
|
||||||
sseAbortController,
|
sseAbortController,
|
||||||
(state) => { connectionState = state; },
|
(state) => { connectionState = state; },
|
||||||
|
getSourceLabel,
|
||||||
feedConfig.botToken
|
feedConfig.botToken
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user