feat(openclaw): enable observation feed for OpenClaw agent sessions

Three fixes to make OpenClaw agent observations work end-to-end:

1. Session init in before_agent_start — the worker's privacy check
   requires a stored user prompt; without calling /api/sessions/init,
   all observations were skipped as "private"

2. Race condition fix in agent_end — await summarize before sending
   complete, preventing session deletion before in-flight observation
   POSTs arrive

3. OAuth token pass-through in buildIsolatedEnv — spawned Claude CLI
   processes now receive CLAUDE_CODE_OAUTH_TOKEN from the worker's
   env when no explicit API key is configured

Also adds agent-specific emoji mapping and dynamic project naming
for the Telegram observation feed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Glucksberg
2026-02-11 02:23:08 +00:00
parent 06d9ef24f1
commit 809175612c
7 changed files with 358 additions and 288 deletions

View File

@@ -2,7 +2,7 @@
"id": "claude-mem", "id": "claude-mem",
"name": "Claude-Mem (Persistent Memory)", "name": "Claude-Mem (Persistent Memory)",
"description": "Official OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.", "description": "Official OpenClaw plugin for Claude-Mem. Records observations from embedded runner sessions and streams them to messaging channels.",
"kind": "memory", "kind": "integration",
"version": "1.0.0", "version": "1.0.0",
"author": "thedotmack", "author": "thedotmack",
"homepage": "https://claude-mem.com", "homepage": "https://claude-mem.com",

View File

@@ -1,5 +1,5 @@
{ {
"name": "@claude-mem/openclaw-plugin", "name": "@openclaw/claude-mem",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -11,5 +11,10 @@
"devDependencies": { "devDependencies": {
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"typescript": "^5.3.0" "typescript": "^5.3.0"
},
"openclaw": {
"extensions": [
"./dist/index.js"
]
} }
} }

View File

@@ -346,7 +346,7 @@ describe("Observation I/O event handlers", () => {
assert.equal(initRequests.length, 1, "should re-init after compaction"); assert.equal(initRequests.length, 1, "should re-init after compaction");
}); });
it("before_agent_start does not call init", async () => { it("before_agent_start calls init for session privacy check", async () => {
const { api, fireEvent } = createMockApi({ workerPort }); const { api, fireEvent } = createMockApi({ workerPort });
claudeMemPlugin(api); claudeMemPlugin(api);
@@ -354,7 +354,7 @@ describe("Observation I/O event handlers", () => {
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init"); const initRequests = receivedRequests.filter((r) => r.url === "/api/sessions/init");
assert.equal(initRequests.length, 0, "before_agent_start should not init"); assert.equal(initRequests.length, 1, "before_agent_start should init session");
}); });
it("tool_result_persist sends observation to worker", async () => { it("tool_result_persist sends observation to worker", async () => {

View File

@@ -160,6 +160,44 @@ const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
const DEFAULT_WORKER_PORT = 37777; const DEFAULT_WORKER_PORT = 37777;
const TOOL_RESULT_MAX_LENGTH = 1000; const TOOL_RESULT_MAX_LENGTH = 1000;
// Agent emoji map for observation feed messages.
// When creating a new OpenClaw agent, add its agentId and emoji here.
const AGENT_EMOJI_MAP: Record<string, string> = {
"main": "🦞",
"openclaw": "🦞",
"devops": "🔧",
"architect": "📐",
"researcher": "🔍",
"code-reviewer": "🔎",
"coder": "💻",
"tester": "🧪",
"debugger": "🐛",
"opsec": "🛡️",
"cloudfarm": "☁️",
"extractor": "📦",
};
// Project prefixes that indicate Claude Code sessions (not OpenClaw agents)
const CLAUDE_CODE_EMOJI = "⌨️";
const OPENCLAW_DEFAULT_EMOJI = "🦀";
function getSourceLabel(project: string): string {
if (!project) return OPENCLAW_DEFAULT_EMOJI;
// OpenClaw agent projects are formatted as "openclaw-<agentId>"
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}`;
}
// ============================================================================ // ============================================================================
// Worker HTTP Client // Worker HTTP Client
// ============================================================================ // ============================================================================
@@ -233,7 +271,8 @@ async function workerGetText(
function formatObservationMessage(observation: ObservationSSEPayload): string { function formatObservationMessage(observation: ObservationSSEPayload): string {
const title = observation.title || "Untitled"; const title = observation.title || "Untitled";
let message = `🧠 Claude-Mem Observation\n**${title}**`; const source = getSourceLabel(observation.project);
let message = `${source}\n**${title}**`;
if (observation.subtitle) { if (observation.subtitle) {
message += `\n${observation.subtitle}`; message += `\n${observation.subtitle}`;
} }
@@ -387,7 +426,14 @@ async function connectToSSEStream(
export default function claudeMemPlugin(api: OpenClawPluginApi): void { 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 projectName = userConfig.project || "openclaw"; const baseProjectName = userConfig.project || "openclaw";
function getProjectName(ctx: EventContext): string {
if (ctx.agentId) {
return `openclaw-${ctx.agentId}`;
}
return baseProjectName;
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Session tracking for observation I/O // Session tracking for observation I/O
@@ -407,7 +453,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
async function syncMemoryToWorkspace(workspaceDir: string): Promise<void> { async function syncMemoryToWorkspace(workspaceDir: string): Promise<void> {
const contextText = await workerGetText( const contextText = await workerGetText(
workerPort, workerPort,
`/api/context/inject?projects=${encodeURIComponent(projectName)}`, `/api/context/inject?projects=${encodeURIComponent(baseProjectName)}`,
api.logger api.logger
); );
if (contextText && contextText.trim().length > 0) { if (contextText && contextText.trim().length > 0) {
@@ -429,7 +475,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
await workerPost(workerPort, "/api/sessions/init", { await workerPost(workerPort, "/api/sessions/init", {
contentSessionId, contentSessionId,
project: projectName, project: getProjectName(ctx),
prompt: "", prompt: "",
}, api.logger); }, api.logger);
@@ -444,7 +490,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
await workerPost(workerPort, "/api/sessions/init", { await workerPost(workerPort, "/api/sessions/init", {
contentSessionId, contentSessionId,
project: projectName, project: getProjectName(ctx),
prompt: "", prompt: "",
}, api.logger); }, api.logger);
@@ -452,7 +498,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
}); });
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Event: before_agent_start — sync MEMORY.md + track workspace // Event: before_agent_start — init session + sync MEMORY.md + track workspace
// ------------------------------------------------------------------ // ------------------------------------------------------------------
api.on("before_agent_start", async (_event, ctx) => { api.on("before_agent_start", async (_event, ctx) => {
// Track workspace dir so tool_result_persist can sync MEMORY.md later // Track workspace dir so tool_result_persist can sync MEMORY.md later
@@ -460,6 +506,15 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
workspaceDirsBySessionKey.set(ctx.sessionKey || "default", ctx.workspaceDir); workspaceDirsBySessionKey.set(ctx.sessionKey || "default", ctx.workspaceDir);
} }
// Initialize session in the worker so observations are not skipped
// (the privacy check requires a stored user prompt to exist)
const contentSessionId = getContentSessionId(ctx.sessionKey);
await workerPost(workerPort, "/api/sessions/init", {
contentSessionId,
project: getProjectName(ctx),
prompt: ctx.sessionKey || "agent run",
}, api.logger);
// Sync MEMORY.md before agent runs (provides context to agent) // Sync MEMORY.md before agent runs (provides context to agent)
if (syncMemoryFile && ctx.workspaceDir) { if (syncMemoryFile && ctx.workspaceDir) {
await syncMemoryToWorkspace(ctx.workspaceDir); await syncMemoryToWorkspace(ctx.workspaceDir);
@@ -470,6 +525,7 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
// Event: tool_result_persist — record tool observations + sync MEMORY.md // Event: tool_result_persist — record tool observations + sync MEMORY.md
// ------------------------------------------------------------------ // ------------------------------------------------------------------
api.on("tool_result_persist", (event, ctx) => { api.on("tool_result_persist", (event, ctx) => {
api.logger.info(`[claude-mem] tool_result_persist fired: tool=${event.toolName ?? "unknown"} agent=${ctx.agentId ?? "none"} session=${ctx.sessionKey ?? "none"}`);
const toolName = event.toolName; const toolName = event.toolName;
if (!toolName || toolName.startsWith("memory_")) return; if (!toolName || toolName.startsWith("memory_")) return;
@@ -527,7 +583,10 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
} }
} }
workerPostFireAndForget(workerPort, "/api/sessions/summarize", { // Await summarize so the worker receives it before complete.
// This also gives in-flight tool_result_persist observations time to arrive
// (they use fire-and-forget and may still be in transit).
await workerPost(workerPort, "/api/sessions/summarize", {
contentSessionId, contentSessionId,
last_assistant_message: lastAssistantMessage, last_assistant_message: lastAssistantMessage,
}, api.logger); }, api.logger);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -217,6 +217,14 @@ export function buildIsolatedEnv(includeCredentials: boolean = true): Record<str
if (credentials.OPENROUTER_API_KEY) { if (credentials.OPENROUTER_API_KEY) {
isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY; isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY;
} }
// 4. Pass through Claude CLI's OAuth token if available (fallback for CLI subscription billing)
// When no ANTHROPIC_API_KEY is configured, the spawned CLI uses subscription billing
// which requires either ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN.
// The worker inherits this token from the Claude Code session that started it.
if (!isolatedEnv.ANTHROPIC_API_KEY && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
isolatedEnv.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
}
} }
return isolatedEnv; return isolatedEnv;
@@ -257,5 +265,8 @@ export function getAuthMethodDescription(): string {
if (hasAnthropicApiKey()) { if (hasAnthropicApiKey()) {
return 'API key (from ~/.claude-mem/.env)'; return 'API key (from ~/.claude-mem/.env)';
} }
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return 'Claude Code OAuth token (from parent process)';
}
return 'Claude Code CLI (subscription billing)'; return 'Claude Code CLI (subscription billing)';
} }