From 28cb11d480a307edca699a48c0047b5a52784a09 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Apr 2026 17:43:45 -0700 Subject: [PATCH 01/19] feat: multi-peer group chat support with review fixes Incorporates the multi-peer mapping approach from #31 (spooktheducks) with all three review fixes requested by @ajspig: 1. Concurrency protection: ensureInitialized() now uses a promise-based init lock to prevent races when two concurrent hooks enter init simultaneously. Errors propagate to all waiters (not swallowed). 2. Parallel peer resolution: capture.ts uses Promise.all() instead of sequential for...of + await for resolving human peers, avoiding latency bottleneck in group chats with many unique senders. 3. Naming clarity: session metadata keys renamed from humanPeerId/ humanPeerIds to humanSenderId/humanSenderIds to distinguish raw channel sender IDs from resolved Honcho peer IDs. Includes backward- compatible fallback to legacy key names. Closes #50. Supersedes #31. Co-authored-by: spooktheducks Co-authored-by: Minh Nguyen --- README.md | 39 +++++++- commands/cli.ts | 214 ++++++++++------------------------------ config.ts | 15 +++ helpers.ts | 67 ++++++++++--- hooks/capture.ts | 116 +++++++++++++++++++--- hooks/context.ts | 5 +- openclaw.plugin.json | 26 +++-- state.ts | 112 ++++++++++++++++++++- tools/ask.ts | 4 +- tools/context.ts | 10 +- tools/message-search.ts | 6 +- tools/search.ts | 8 +- tools/session.ts | 5 +- 13 files changed, 416 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index 7de86d7..0a57298 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Run `openclaw honcho setup` to configure interactively, or set values directly i | `noisePatterns` | `string[]` | built-in defaults | Patterns to skip messages. User-provided patterns are merged with built-in defaults (unless `disableDefaultNoisePatterns` is set). | | `disableDefaultNoisePatterns` | `boolean` | `false` | When `true`, built-in noise patterns are not applied — only `noisePatterns` entries are used. | | `ownerObserveOthers` | `boolean` | `false` | Whether the owner peer observes agent messages in Honcho's social model. | +| `peerMappings` | `object` | `{}` | Maps channel peer IDs (e.g., Slack user IDs) to Honcho peer IDs. Unmapped senders are auto-created. | +| `agentPeerMappings` | `object` | `{}` | Maps OpenClaw agent IDs to Honcho peer IDs. Default: agent `foo` gets peer ID `agent-foo`. | ### Self-Hosted / Local Honcho @@ -109,13 +111,48 @@ Honcho's `observeOthers` controls whether a peer forms representations of other Set `ownerObserveOthers: true` to let the owner peer also observe agent messages. This gives Honcho perspective-aware memory: the owner stores conclusions about the agent based only on what it witnessed, enabling the user's representation to reflect the full conversational context rather than just their own side of it. +### Peer Mappings + +In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform ID from each inbound message and maps it to a Honcho peer. This gives each human participant their own memory and representation in Honcho, rather than attributing all user messages to a single generic peer. + +Configure `peerMappings` to map known channel peer IDs to specific Honcho peer IDs: + +```json +{ + "plugins": { + "entries": { + "openclaw-honcho": { + "config": { + "peerMappings": { + "U01ZB5DG019": "owner" + }, + "agentPeerMappings": { + "agent-slack": "demerzel" + } + } + } + } + } +} +``` + +**How it works:** +- The plugin reads the `sender_id` field from OpenClaw's "Conversation info (untrusted metadata):" block, which is injected into group chat messages. +- If the sender ID has a `peerMappings` entry, the mapped Honcho peer ID is used. +- If no mapping exists, a Honcho peer is auto-created using the channel peer ID directly (e.g., `U07KX7DG002` becomes the Honcho peer ID). +- In DMs, no sender metadata is present, so the default `owner` peer is used (existing behavior preserved). +- `agentPeerMappings` works the same way for agent peers — by default, an agent with ID `slack` gets Honcho peer ID `agent-slack`, but you can override it (e.g., to `demerzel`). +- All tools (`honcho_context`, `honcho_ask`, etc.) automatically resolve the correct peer for the current session. + +Auto-created peer mappings are persisted in workspace metadata so they survive gateway restarts. + ## How it works Once installed, the plugin works automatically: - **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models. Message capture starts when the plugin is active for a session, and preserves original timestamps for captured messages. Messages are also flushed before session compaction and `/new`/`/reset`, so no conversation data is lost. - **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, and `honcho_ask` to retrieve relevant context about the user. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. -- **Dual Peer Model** — Honcho maintains separate representations: one for the user (preferences, facts, communication style) and one for the agent (personality, learned behaviors). Each OpenClaw agent gets its own Honcho peer (`agent-{id}`), so multi-agent workspaces maintain isolated memory. +- **Multi-Peer Model** — Honcho maintains separate representations for each participant. In group chats, each human sender gets their own peer (mapped via `peerMappings` or auto-created from their platform ID). Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, overridable via `agentPeerMappings`). In DMs, the default `owner` peer is used. This gives every participant isolated, personalized memory. - **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted. Noise messages (heartbeat acks, cron boilerplate, startup commands) are dropped entirely via configurable pattern filters. Honcho handles all reasoning and synthesis in the cloud. diff --git a/commands/cli.ts b/commands/cli.ts index 443473e..d42766f 100644 --- a/commands/cli.ts +++ b/commands/cli.ts @@ -1,4 +1,3 @@ -import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -9,31 +8,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginState } from "../state.js"; import { OWNER_ID } from "../state.js"; -/* ── Upload manifest ─────────────────────────────────────────────────── */ - -type ManifestEntry = { sha256: string; uploadedAt: string; baseUrl: string; workspaceId: string }; -type UploadManifest = Record; - -const MANIFEST_PATH = () => path.join(os.homedir(), ".openclaw", ".upload-manifest.json"); - -function loadManifest(): UploadManifest { - try { - return JSON.parse(fs.readFileSync(MANIFEST_PATH(), "utf-8")); - } catch { - return {}; - } -} - -function saveManifest(manifest: UploadManifest): void { - const dir = path.dirname(MANIFEST_PATH()); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(MANIFEST_PATH(), JSON.stringify(manifest, null, 2)); -} - -function contentHash(content: Buffer): string { - return crypto.createHash("sha256").update(content).digest("hex"); -} - export function registerCli(api: OpenClawPluginApi, state: PluginState): void { api.registerCli( ({ program, workspaceDir }) => { @@ -42,83 +16,52 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { cmd .command("setup") .description("Configure Honcho API key and upload memory files to Honcho") - .option("--reconfigure", "Force re-entry of all configuration values") - .action(async (options: { reconfigure?: boolean }) => { + .action(async () => { const configDir = path.join(os.homedir(), ".openclaw"); const configPath = path.join(configDir, "openclaw.json"); - // Load existing config to use as defaults - let config: Record = {}; - if (fs.existsSync(configPath)) { - try { config = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { /* use empty */ } - } - const existingPluginCfg = ( - ((config.plugins as Record) - ?.entries as Record) - ?.["openclaw-honcho"] as Record - )?.config as Record | undefined; - - const savedApiKey = (existingPluginCfg?.apiKey as string) ?? ""; - const savedBaseUrl = (existingPluginCfg?.baseUrl as string) || "https://api.honcho.dev"; - const savedWorkspaceId = (existingPluginCfg?.workspaceId as string) || "openclaw"; - const hasExistingConfig = !!existingPluginCfg && !!savedApiKey; - console.log("\nHoncho Setup\n"); console.log("Get your API key from: https://app.honcho.dev\n"); + console.log('Press Enter to use the default shown in [brackets].\n'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); try { - let resolvedApiKey: string; - let resolvedBaseUrl: string; - let resolvedWorkspaceId: string; - - if (hasExistingConfig && !options.reconfigure) { - const maskedKey = savedApiKey.length > 8 - ? savedApiKey.slice(0, 4) + "..." + savedApiKey.slice(-4) - : "****"; - console.log("Existing configuration found:"); - console.log(` API key: ${maskedKey}`); - console.log(` Base URL: ${savedBaseUrl}`); - console.log(` Workspace ID: ${savedWorkspaceId}`); - console.log('\nPress Enter to keep existing values, or use --reconfigure to change.\n'); - - resolvedApiKey = savedApiKey; - resolvedBaseUrl = savedBaseUrl; - resolvedWorkspaceId = savedWorkspaceId; - console.log("✓ Using existing configuration\n"); - } else { - console.log('Press Enter to use the default shown in [brackets].\n'); - - const apiKeyDefault = savedApiKey ? ` [${savedApiKey.slice(0, 4)}...${savedApiKey.slice(-4)}]` : ""; - const apiKeyInput = await ask(`Honcho API key${apiKeyDefault || " (press Enter for self-hosted mode)"}: `); - const baseUrlInput = await ask(`Base URL [${savedBaseUrl}]: `); - const workspaceIdInput = await ask(`Workspace ID [${savedWorkspaceId}]: `); - - resolvedApiKey = apiKeyInput.trim() || savedApiKey; - resolvedBaseUrl = baseUrlInput.trim() || savedBaseUrl; - resolvedWorkspaceId = workspaceIdInput.trim() || savedWorkspaceId; - - // Write config - if (!config.plugins) config.plugins = {}; - const pluginsSection = config.plugins as Record; - if (!pluginsSection.entries) pluginsSection.entries = {}; - const entriesSection = pluginsSection.entries as Record; - const existingEntry = (entriesSection["openclaw-honcho"] as Record) ?? {}; - const pluginCfg: Record = { - ...(existingEntry.config as Record ?? {}), - }; - if (resolvedApiKey) pluginCfg.apiKey = resolvedApiKey; - else delete pluginCfg.apiKey; - pluginCfg.baseUrl = resolvedBaseUrl; - pluginCfg.workspaceId = resolvedWorkspaceId; - entriesSection["openclaw-honcho"] = { ...existingEntry, config: pluginCfg }; - - if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - console.log("\n✓ Configuration saved to ~/.openclaw/openclaw.json"); + const apiKeyInput = await ask("Honcho API key (press Enter for self-hosted mode): "); + const baseUrlInput = await ask("Base URL [https://api.honcho.dev]: "); + const workspaceIdInput = await ask("Workspace ID [openclaw]: "); + + const resolvedBaseUrl = baseUrlInput.trim() || "https://api.honcho.dev"; + const resolvedWorkspaceId = workspaceIdInput.trim() || "openclaw"; + + // Write config + let config: Record = {}; + if (fs.existsSync(configPath)) { + try { config = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { /* use empty */ } } + if (!config.plugins) config.plugins = {}; + const pluginsSection = config.plugins as Record; + if (!pluginsSection.entries) pluginsSection.entries = {}; + const entriesSection = pluginsSection.entries as Record; + const existingEntry = (entriesSection["openclaw-honcho"] as Record) ?? {}; + const pluginCfg: Record = { + ...(existingEntry.config as Record ?? {}), + }; + const trimmedApiKey = apiKeyInput.trim(); + if (trimmedApiKey) pluginCfg.apiKey = trimmedApiKey; + else delete pluginCfg.apiKey; + const trimmedBaseUrl = baseUrlInput.trim(); + if (trimmedBaseUrl) pluginCfg.baseUrl = trimmedBaseUrl; + else delete pluginCfg.baseUrl; + const trimmedWorkspaceId = workspaceIdInput.trim(); + if (trimmedWorkspaceId) pluginCfg.workspaceId = trimmedWorkspaceId; + else delete pluginCfg.workspaceId; + entriesSection["openclaw-honcho"] = { ...existingEntry, config: pluginCfg }; + + if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log("\n✓ Configuration saved to ~/.openclaw/openclaw.json"); // Resolve default agent and its workspace from config let savedConfig: Record = {}; @@ -246,7 +189,7 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { // Upload files to Honcho const setupHoncho = new Honcho({ - apiKey: resolvedApiKey || undefined, + apiKey: apiKeyInput.trim() || undefined, baseURL: resolvedBaseUrl, workspaceId: resolvedWorkspaceId, }); @@ -261,86 +204,31 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { [agentPeerSetup, { observeMe: true, observeOthers: true }], ]); - // Cooldown after setup calls — the hosted platform (groudon) enforces - // 5 req/sec per tenant; the 6 calls above consume most of that budget. - await new Promise((r) => setTimeout(r, 1500)); - const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5MB safety cap - const UPLOAD_DELAY_MS = 400; // stay under 5 req/sec platform limit - const manifest = loadManifest(); let uploadCount = 0; - let unchangedCount = 0; - const skipped: string[] = []; - const failed: { filePath: string; error: string }[] = []; - const total = detected.length; - - for (let i = 0; i < detected.length; i++) { - const { filePath, peer } = detected[i]; - const progress = `[${i + 1}/${total}]`; - + for (const { filePath, peer } of detected) { const stat = await fs.promises.stat(filePath).catch(() => null); if (!stat?.isFile()) continue; if (stat.size > MAX_UPLOAD_BYTES) { - console.log(` ${progress} ! Skipping (larger than 5MB): ${filePath}`); - skipped.push(filePath); + console.log(` ! Skipping (too large): ${filePath}`); continue; } const filename = path.basename(filePath); const ext = path.extname(filename).toLowerCase(); const content_type = ext === ".json" ? "application/json" : ext === ".md" ? "text/markdown" : null; if (!content_type) { - console.log(` ${progress} ! Skipping unsupported type: ${filePath}`); - skipped.push(filePath); + console.log(` ! Skipping unsupported file type: ${filePath}`); continue; } - + const content = await fs.promises.readFile(filePath); const targetPeer = peer === "owner" ? ownerPeerSetup : agentPeerSetup; - try { - const content = await fs.promises.readFile(filePath); - const hash = contentHash(content); - - // Skip files already uploaded with identical content to the same destination - const prev = manifest[filePath]; - if (prev && prev.sha256 === hash && prev.baseUrl === resolvedBaseUrl && prev.workspaceId === resolvedWorkspaceId) { - console.log(` ${progress} ~ Unchanged: ${filePath}`); - unchangedCount++; - continue; - } - - await new Promise((r) => setTimeout(r, UPLOAD_DELAY_MS)); - await migrationSession.uploadFile({ filename, content, content_type }, targetPeer, {}); - console.log(` ${progress} ✓ Uploaded: ${filePath}`); - uploadCount++; - - // Record success - manifest[filePath] = { sha256: hash, uploadedAt: new Date().toISOString(), baseUrl: resolvedBaseUrl, workspaceId: resolvedWorkspaceId }; - saveManifest(manifest); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.log(` ${progress} ✗ Failed: ${filePath}`); - failed.push({ filePath, error: msg }); - } - } - - // Clean stale manifest entries - for (const key of Object.keys(manifest)) { - if (!fs.existsSync(key)) delete manifest[key]; - } - saveManifest(manifest); - - // Summary - console.log(`\nUpload summary:`); - console.log(` Uploaded: ${uploadCount}/${total}`); - if (unchangedCount > 0) console.log(` Unchanged: ${unchangedCount}`); - if (skipped.length > 0) console.log(` Skipped: ${skipped.length}`); - if (failed.length > 0) { - console.log(` Failed: ${failed.length}`); - for (const f of failed) { - console.log(` ! ${f.filePath} — ${f.error}`); - } - console.log(`\nRun \`openclaw honcho setup\` again to retry failed files.`); + await new Promise((r) => setTimeout(r, 250)); // stay under 5 req/sec limit + await migrationSession.uploadFile({ filename, content, content_type }, targetPeer, {}); + console.log(` ✓ Uploaded: ${filePath}`); + uploadCount++; } + console.log(`\n✓ Uploaded ${uploadCount} file(s) to Honcho`); console.log("\n✓ Setup complete. Run `openclaw gateway --force` to activate.\n"); } finally { @@ -369,11 +257,13 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { .command("ask ") .description("Ask Honcho about the user") .option("-a, --agent ", "Agent ID to query as (default: primary agent)") - .action(async (question: string, options: { agent?: string }) => { + .option("-p, --peer ", "Channel peer ID or Honcho peer ID to target (default: owner)") + .action(async (question: string, options: { agent?: string; peer?: string }) => { try { await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(options.agent ?? state.resolveDefaultAgentId()); - const answer = await agentPeer.chat(question, { target: state.ownerPeer! }); + const humanPeer = await state.getHumanPeer(options.peer); + const answer = await agentPeer.chat(question, { target: humanPeer }); console.log(answer ?? "No information available."); } catch (error) { console.error(`Failed to query: ${error}`); @@ -385,10 +275,12 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { .description("Semantic search over Honcho memory") .option("-k, --top-k ", "Number of results to return", "10") .option("-d, --max-distance ", "Maximum semantic distance (0-1)", "0.5") - .action(async (query: string, options: { topK: string; maxDistance: string }) => { + .option("-p, --peer ", "Channel peer ID or Honcho peer ID to target (default: owner)") + .action(async (query: string, options: { topK: string; maxDistance: string; peer?: string }) => { try { await state.ensureInitialized(); - const representation = await state.ownerPeer!.representation({ + const humanPeer = await state.getHumanPeer(options.peer); + const representation = await humanPeer.representation({ searchQuery: query, searchTopK: parseInt(options.topK, 10), searchMaxDistance: parseFloat(options.maxDistance), diff --git a/config.ts b/config.ts index 43e4f64..a8a33be 100644 --- a/config.ts +++ b/config.ts @@ -16,6 +16,8 @@ export type HonchoConfig = { noisePatterns: string[]; disableDefaultNoisePatterns: boolean; ownerObserveOthers: boolean; + peerMappings: Record; + agentPeerMappings: Record; }; /** @@ -32,6 +34,17 @@ function resolveEnvVars(value: string): string { }); } +function parseStringRecord(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const result: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof k === "string" && typeof v === "string" && k.length > 0 && v.length > 0) { + result[k] = v; + } + } + return result; +} + export const honchoConfigSchema = { parse(value: unknown): HonchoConfig { const cfg = (value ?? {}) as Record; @@ -68,6 +81,8 @@ export const honchoConfigSchema = { noisePatterns, disableDefaultNoisePatterns, ownerObserveOthers: typeof cfg.ownerObserveOthers === "boolean" ? cfg.ownerObserveOthers : false, + peerMappings: parseStringRecord(cfg.peerMappings), + agentPeerMappings: parseStringRecord(cfg.agentPeerMappings), }; }, }; diff --git a/helpers.ts b/helpers.ts index 0f4c200..ad4978e 100644 --- a/helpers.ts +++ b/helpers.ts @@ -142,6 +142,43 @@ export function cleanMessageContent(content: string): string { return cleaned.trim(); } +const CONVERSATION_INFO_SENTINEL = "Conversation info (untrusted metadata):"; + +/** + * Extract the sender_id from a raw message's "Conversation info (untrusted metadata):" + * metadata block. Must be called BEFORE cleanMessageContent() which strips these blocks. + * Returns undefined for DMs (no metadata block) or on parse failure. + */ +export function extractSenderId(content: string): string | undefined { + if (!content || !content.includes(CONVERSATION_INFO_SENTINEL)) return undefined; + + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() !== CONVERSATION_INFO_SENTINEL) continue; + if (lines[i + 1]?.trim() !== "```json") continue; + + // Collect JSON lines between ```json and ``` + const jsonLines: string[] = []; + for (let j = i + 2; j < lines.length; j++) { + if (lines[j].trim() === "```") break; + jsonLines.push(lines[j]); + } + + try { + const parsed = JSON.parse(jsonLines.join("\n")); + // Try sender_id first, fall back to sender + const id = parsed.sender_id ?? parsed.sender; + if (typeof id === "string" && id.length > 0) { + return id; + } + } catch { + // Malformed JSON — return undefined + } + return undefined; + } + return undefined; +} + /** * Returns true if the message should be dropped entirely. * Patterns starting with "/" are treated as anchored regexes (e.g. "/^HEARTBEAT/i"). @@ -167,9 +204,10 @@ export function shouldSkipMessage(content: string, noisePatterns: string[]): boo export function extractMessages( rawMessages: unknown[], - ownerPeer: Peer, + defaultHumanPeer: Peer, agentPeer: Peer, - noisePatterns: string[] = [] + noisePatterns: string[] = [], + resolvePeer?: (senderId: string) => Peer | undefined, ): MessageInput[] { const result: MessageInput[] = []; @@ -180,11 +218,12 @@ export function extractMessages( if (role !== "user" && role !== "assistant") continue; - let content = ""; + // Extract raw content before cleaning + let rawContent = ""; if (typeof m.content === "string") { - content = m.content; + rawContent = m.content; } else if (Array.isArray(m.content)) { - content = m.content + rawContent = m.content .filter( (block: unknown) => typeof block === "object" && @@ -196,17 +235,23 @@ export function extractMessages( .join("\n"); } - content = cleanMessageContent(content); + // For user messages, extract sender ID before cleaning strips metadata + let peer: Peer; + if (role === "user") { + const senderId = extractSenderId(rawContent); + peer = (senderId && resolvePeer?.(senderId)) || defaultHumanPeer; + } else { + peer = agentPeer; + } + + let content = cleanMessageContent(rawContent); content = content.trim(); if (!content) continue; if (shouldSkipMessage(content, noisePatterns)) continue; - if (content) { - const peer = role === "user" ? ownerPeer : agentPeer; - const ts = typeof m.timestamp === "number" ? new Date(m.timestamp) : undefined; - result.push(peer.message(content, ts ? { createdAt: ts } : undefined)); - } + const ts = typeof m.timestamp === "number" ? new Date(m.timestamp) : undefined; + result.push(peer.message(content, ts ? { createdAt: ts } : undefined)); } return result; diff --git a/hooks/capture.ts b/hooks/capture.ts index f080d7c..d42b280 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -6,9 +6,32 @@ import { buildSessionKey, isSubagentSession, extractMessages, + extractSenderId, } from "../helpers.js"; import { subagentParentMap } from "./subagent.js"; +/** + * Extract raw text content from a message object (before cleaning). + */ +function getRawContent(msg: unknown): string { + if (!msg || typeof msg !== "object") return ""; + const m = msg as Record; + if (typeof m.content === "string") return m.content; + if (Array.isArray(m.content)) { + return m.content + .filter( + (block: unknown) => + typeof block === "object" && + block !== null && + (block as Record).type === "text" + ) + .map((block: unknown) => (block as Record).text) + .filter((t): t is string => typeof t === "string") + .join("\n"); + } + return ""; +} + /** * Core message capture logic shared by agent_end, before_compaction, and before_reset. * Returns the number of new messages saved (or 0 if none). @@ -55,30 +78,99 @@ async function flushMessages( const lastSavedIndex = Math.min(Math.max(rawLastSavedIndex, 0), messages.length); const startIndex = Math.max(turnStartIndex, lastSavedIndex); - const peerConfigs: Array<[string, { observeMe: boolean; observeOthers: boolean }]> = [ - [OWNER_ID, { observeMe: true, observeOthers: state.cfg.ownerObserveOthers }], - [agentPeer.id, { observeMe: true, observeOthers: true }], - ]; + if (messages.length <= startIndex) { + return 0; + } + + const newRawMessages = messages.slice(startIndex); + + // Pre-resolve human peers for all unique sender IDs in this batch + const senderIds = new Set(); + let lastSenderId: string | undefined; + let userMsgCount = 0; + for (const msg of newRawMessages) { + if (!msg || typeof msg !== "object") continue; + const m = msg as Record; + if (m.role !== "user") continue; + userMsgCount++; + const rawContent = getRawContent(msg); + const senderId = extractSenderId(rawContent); + if (senderId) { + senderIds.add(senderId); + lastSenderId = senderId; + } else { + const hasConvInfo = rawContent.includes("Conversation info (untrusted metadata):"); + api.logger.debug?.(`[honcho] User message without sender_id (hasConvInfo=${hasConvInfo}, contentLen=${rawContent.length})`); + } + } + if (senderIds.size > 0) { + api.logger.debug?.(`[honcho] Resolved ${senderIds.size} unique sender(s) from ${userMsgCount} user message(s): ${[...senderIds].join(", ")}`); + } + + // Parallel peer resolution — avoids sequential await bottleneck in group chats. + const resolvedPeers = new Map>>(); + const senderIdArray = [...senderIds]; + const peers = await Promise.all(senderIdArray.map((id) => state.getHumanPeer(id))); + for (let i = 0; i < senderIdArray.length; i++) { + resolvedPeers.set(senderIdArray[i], peers[i]); + } + + const defaultHumanPeer = await state.getHumanPeer(); + + // Build peer configs: default owner + all resolved human peers + agent + parent + const peerConfigMap = new Map(); + peerConfigMap.set(OWNER_ID, { observeMe: true, observeOthers: state.cfg.ownerObserveOthers }); + for (const [, peer] of resolvedPeers) { + if (peer.id !== OWNER_ID) { + peerConfigMap.set(peer.id, { observeMe: true, observeOthers: state.cfg.ownerObserveOthers }); + } + } + peerConfigMap.set(agentPeer.id, { observeMe: true, observeOthers: true }); if (parentPeer) { - peerConfigs.push([parentPeer.id, { observeMe: false, observeOthers: true }]); + peerConfigMap.set(parentPeer.id, { observeMe: false, observeOthers: true }); } + const peerConfigs = Array.from(peerConfigMap.entries()) as Array< + [string, { observeMe: boolean; observeOthers: boolean }] + >; await session.addPeers(peerConfigs); - if (messages.length <= startIndex) { - return 0; - } + const extracted = extractMessages( + newRawMessages, + defaultHumanPeer, + agentPeer, + state.cfg.noisePatterns, + (senderId) => resolvedPeers.get(senderId), + ); - const newRawMessages = messages.slice(startIndex); - const extracted = extractMessages(newRawMessages, state.ownerPeer!, agentPeer, state.cfg.noisePatterns); + // Store sender IDs in session metadata for tool resolution. + // humanSenderId = last active sender (default for tools). + // humanSenderIds = all known senders in this session (for future multi-target tools). + // Named "sender" (not "peer") to distinguish raw channel IDs from resolved Honcho peer IDs. + const previousSenderIds: string[] = Array.isArray(existingMeta.humanSenderIds) + ? (existingMeta.humanSenderIds as string[]) + : []; + const allSenderIds = [...new Set([...previousSenderIds, ...senderIds])]; + + const updatedMeta: Record = { + ...existingMeta, + ...sessionMeta, + lastSavedIndex: messages.length, + }; + if (lastSenderId) { + updatedMeta.humanSenderId = lastSenderId; + } + if (allSenderIds.length > 0) { + updatedMeta.humanSenderIds = allSenderIds; + } if (extracted.length === 0) { - await session.setMetadata({ ...existingMeta, ...sessionMeta, lastSavedIndex: messages.length }); + await session.setMetadata(updatedMeta); return 0; } await session.addMessages(extracted); - await session.setMetadata({ ...existingMeta, ...sessionMeta, lastSavedIndex: messages.length }); + await session.setMetadata(updatedMeta); return extracted.length; } diff --git a/hooks/context.ts b/hooks/context.ts index c76dd50..b3c2859 100644 --- a/hooks/context.ts +++ b/hooks/context.ts @@ -16,12 +16,13 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState): try { await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(agentId); + const humanPeer = await state.resolveSessionHumanPeer(sessionKey); const sections: string[] = []; if (isSubagent) { try { - const peerCtx = await agentPeer.context({ target: state.ownerPeer! }); + const peerCtx = await agentPeer.context({ target: humanPeer }); if (peerCtx.peerCard?.length) { sections.push(`Key facts:\n${peerCtx.peerCard.map((f: string) => `• ${f}`).join("\n")}`); } @@ -43,7 +44,7 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState): context = await session.context({ summary: true, tokens: 2000, - peerTarget: state.ownerPeer!, + peerTarget: humanPeer, peerPerspective: agentPeer, }); } catch (e: unknown) { diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 9119403..3bcf74b 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -27,10 +27,17 @@ "default": false, "description": "Whether the owner peer observes agent messages. When true, Honcho models the user's awareness of assistant responses." }, - "disableDefaultNoisePatterns": { - "type": "boolean", - "default": false, - "description": "When true, built-in noise patterns are not applied — only noisePatterns entries are used." + "peerMappings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Maps channel peer IDs (e.g., Discord user IDs) to Honcho peer IDs. Unmapped senders are auto-created using their channel peer ID." + }, + "agentPeerMappings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Maps OpenClaw agent IDs to Honcho peer IDs. By default, agent 'foo' gets peer ID 'agent-foo'." } } }, @@ -61,10 +68,15 @@ "advanced": true, "help": "When enabled, Honcho models the user as aware of the agent's responses. Default: off." }, - "disableDefaultNoisePatterns": { - "label": "Disable Default Noise Patterns", + "peerMappings": { + "label": "Peer Mappings", + "advanced": true, + "help": "Maps channel peer IDs (e.g., Discord user IDs) to Honcho peer IDs. Unmapped senders are auto-created." + }, + "agentPeerMappings": { + "label": "Agent Peer Mappings", "advanced": true, - "help": "When enabled, only custom noisePatterns entries are used — built-in defaults are skipped." + "help": "Maps OpenClaw agent IDs to custom Honcho peer IDs. Default: agent 'foo' → peer 'agent-foo'." } } } diff --git a/state.ts b/state.ts index 4713c8d..7d2e2fe 100644 --- a/state.ts +++ b/state.ts @@ -15,7 +15,10 @@ export const LEGACY_PEER_ID = "openclaw"; export type PluginState = { honcho: Honcho; cfg: HonchoConfig; - ownerPeer: Peer | null; + /** Cache of resolved human peers, keyed by channel peer ID (or OWNER_ID for default). */ + humanPeers: Map; + /** Persistent mapping of channel peer ID → honcho peer ID, stored in workspace metadata. */ + humanPeerMap: Record; agentPeers: Map; agentPeerMap: Record; /** Message count recorded at before_prompt_build time, keyed by Honcho session key. @@ -26,6 +29,13 @@ export type PluginState = { api: OpenClawPluginApi; ensureInitialized: () => Promise; getAgentPeer: (agentId?: string) => Promise; + /** Resolve a human peer by channel peer ID. Returns default "owner" peer if no ID given. */ + getHumanPeer: (channelPeerId?: string) => Promise; + /** Resolve the human peer for a session by reading humanSenderId from session metadata. + * Falls back to default "owner" peer if no metadata found. */ + resolveSessionHumanPeer: (sessionKey: string) => Promise; + /** Returns true if the given honcho peer ID belongs to a known human peer. */ + isHumanPeerId: (peerId: string) => boolean; resolveDefaultAgentId: () => string; }; @@ -44,10 +54,16 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { workspaceId: cfg.workspaceId, }); + // Promise-based init lock to prevent concurrent ensureInitialized() races. + // Without this, two concurrent hooks entering init simultaneously can corrupt + // workspace metadata. Errors propagate to all waiters. + let initPromise: Promise | null = null; + const state: PluginState = { honcho, cfg, - ownerPeer: null, + humanPeers: new Map(), + humanPeerMap: {}, agentPeers: new Map(), agentPeerMap: {}, turnStartIndex: new Map(), @@ -55,6 +71,9 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { api, ensureInitialized, getAgentPeer, + getHumanPeer, + resolveSessionHumanPeer, + isHumanPeerId, resolveDefaultAgentId, }; @@ -67,23 +86,106 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { async function ensureInitialized(): Promise { if (state.initialized) return; + if (initPromise) return initPromise; + initPromise = doInit(); + try { + await initPromise; + } catch (err) { + // Reset so next caller retries instead of getting a stale rejection. + initPromise = null; + throw err; + } + } + + async function doInit(): Promise { const wsMeta = await honcho.getMetadata(); state.agentPeerMap = (wsMeta.agentPeerMap as Record) ?? {}; + state.humanPeerMap = (wsMeta.humanPeerMap as Record) ?? {}; + + // Config mappings take precedence over workspace metadata + for (const [channelId, honchoId] of Object.entries(cfg.peerMappings)) { + state.humanPeerMap[channelId] = honchoId; + } + for (const [agentId, honchoId] of Object.entries(cfg.agentPeerMappings)) { + state.agentPeerMap[agentId] = honchoId; + } const defaultId = resolveDefaultAgentId(); if (Object.keys(state.agentPeerMap).length === 0) { state.agentPeerMap[defaultId] = `agent-${defaultId}`; - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, humanPeerMap: state.humanPeerMap }); } else if (Object.values(state.agentPeerMap).includes(LEGACY_PEER_ID) && !state.agentPeerMap[defaultId]) { state.agentPeerMap[defaultId] = LEGACY_PEER_ID; - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, humanPeerMap: state.humanPeerMap }); } - state.ownerPeer = await honcho.peer(OWNER_ID, { metadata: {} }); + // Create default "owner" peer + const defaultPeer = await honcho.peer(OWNER_ID, { metadata: {} }); + state.humanPeers.set(OWNER_ID, defaultPeer); + state.initialized = true; } + async function getHumanPeer(channelPeerId?: string): Promise { + if (!channelPeerId) { + // Return default owner peer + let peer = state.humanPeers.get(OWNER_ID); + if (!peer) { + peer = await honcho.peer(OWNER_ID, { metadata: {} }); + state.humanPeers.set(OWNER_ID, peer); + } + return peer; + } + + // Check cache + let peer = state.humanPeers.get(channelPeerId); + if (peer) return peer; + + // Resolve honcho peer ID from mapping or use channel peer ID directly + let honchoId = state.humanPeerMap[channelPeerId]; + if (!honchoId) { + honchoId = channelPeerId; + // Persist auto-created mapping + state.humanPeerMap[channelPeerId] = honchoId; + const wsMeta = await honcho.getMetadata(); + await honcho.setMetadata({ ...wsMeta, humanPeerMap: state.humanPeerMap }); + api.logger.info(`[honcho] Auto-created human peer mapping: "${channelPeerId}" → "${honchoId}"`); + } + + peer = await honcho.peer(honchoId, { metadata: { channelPeerId } }); + state.humanPeers.set(channelPeerId, peer); + return peer; + } + + async function resolveSessionHumanPeer(sessionKey: string): Promise { + try { + const session = await honcho.session(sessionKey); + const meta = await session.getMetadata(); + if (meta && typeof meta === "object") { + // Check humanSenderId (preferred) with fallback to legacy humanPeerId. + const senderId = (meta as Record).humanSenderId + ?? (meta as Record).humanPeerId; + if (typeof senderId === "string" && senderId.length > 0) { + return await getHumanPeer(senderId); + } + } + } catch { + // Fall through to default + } + return await getHumanPeer(); + } + + function isHumanPeerId(peerId: string): boolean { + if (peerId === OWNER_ID) return true; + // Check if this peer ID is a known human peer (either as a channel ID key or honcho ID value) + for (const [, peer] of state.humanPeers) { + if (peer.id === peerId) return true; + } + // Also check the mapping values + return Object.values(state.humanPeerMap).includes(peerId); + } + async function getAgentPeer(agentId?: string): Promise { const id = (agentId || resolveDefaultAgentId()).toLowerCase().trim() || "main"; diff --git a/tools/ask.ts b/tools/ask.ts index e86de5c..49aa046 100644 --- a/tools/ask.ts +++ b/tools/ask.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; // @ts-ignore - resolved by openclaw runtime import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginState } from "../state.js"; +import { buildSessionKey } from "../helpers.js"; export function registerAskTool(api: OpenClawPluginApi, state: PluginState): void { api.registerTool( @@ -33,10 +34,11 @@ export function registerAskTool(api: OpenClawPluginApi, state: PluginState): voi await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(toolCtx.agentId); + const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); const reasoningLevel = depth === "thorough" ? "high" : "low"; const answer = await agentPeer.chat(query, { - target: state.ownerPeer!, + target: humanPeer, reasoningLevel, }); diff --git a/tools/context.ts b/tools/context.ts index 779380f..3c164a4 100644 --- a/tools/context.ts +++ b/tools/context.ts @@ -2,10 +2,11 @@ import { Type } from "@sinclair/typebox"; // @ts-ignore - resolved by openclaw runtime import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginState } from "../state.js"; +import { buildSessionKey } from "../helpers.js"; export function registerContextTool(api: OpenClawPluginApi, state: PluginState): void { api.registerTool( - { + (toolCtx) => ({ name: "honcho_context", label: "Get User Context", description: @@ -26,9 +27,10 @@ export function registerContextTool(api: OpenClawPluginApi, state: PluginState): const { detail = "card" } = params as { detail?: "card" | "full" }; await state.ensureInitialized(); + const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); if (detail === "card") { - const card = await state.ownerPeer!.card().catch((err) => { + const card = await humanPeer.card().catch((err) => { // Only treat NotFoundError as empty; re-throw others or log if (err?.name === "NotFoundError") return null; // Optionally log unexpected errors for debugging @@ -60,7 +62,7 @@ export function registerContextTool(api: OpenClawPluginApi, state: PluginState): } // detail === "full" - const representation = await state.ownerPeer!.representation({ + const representation = await humanPeer.representation({ includeMostFrequent: true, }); @@ -81,7 +83,7 @@ export function registerContextTool(api: OpenClawPluginApi, state: PluginState): details: { detail, representationLength: representation.length }, }; }, - }, + }), { name: "honcho_context" } ); } diff --git a/tools/message-search.ts b/tools/message-search.ts index 1f84ccc..6d7a9c5 100644 --- a/tools/message-search.ts +++ b/tools/message-search.ts @@ -3,6 +3,7 @@ import { Type } from "@sinclair/typebox"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { Message } from "@honcho-ai/sdk"; import type { PluginState } from "../state.js"; +import { buildSessionKey } from "../helpers.js"; export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginState): void { api.registerTool( @@ -90,7 +91,8 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS // Route to the appropriate search method based on `from` let messages: Message[]; if (from === "user") { - messages = await state.ownerPeer!.search(query, searchOpts); + const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); + messages = await humanPeer.search(query, searchOpts); } else if (from === "agent") { const agentPeer = await state.getAgentPeer(toolCtx.agentId); messages = await agentPeer.search(query, searchOpts); @@ -111,7 +113,7 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS } const results = messages.map((msg) => { - const speaker = msg.peerId === state.ownerPeer!.id ? "User" : "Agent"; + const speaker = state.isHumanPeerId(msg.peerId) ? "User" : "Agent"; return { id: msg.id, content: msg.content, diff --git a/tools/search.ts b/tools/search.ts index 59292cf..a12c8c6 100644 --- a/tools/search.ts +++ b/tools/search.ts @@ -2,10 +2,11 @@ import { Type } from "@sinclair/typebox"; // @ts-ignore - resolved by openclaw runtime import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginState } from "../state.js"; +import { buildSessionKey } from "../helpers.js"; export function registerSearchTool(api: OpenClawPluginApi, state: PluginState): void { api.registerTool( - { + (toolCtx) => ({ name: "honcho_search_conclusions", label: "Search Honcho conclusions", description: @@ -40,8 +41,9 @@ export function registerSearchTool(api: OpenClawPluginApi, state: PluginState): }; await state.ensureInitialized(); + const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); - const representation = await state.ownerPeer!.representation({ + const representation = await humanPeer.representation({ searchQuery: query, searchTopK: topK ?? 10, searchMaxDistance: maxDistance ?? 0.5, @@ -64,7 +66,7 @@ export function registerSearchTool(api: OpenClawPluginApi, state: PluginState): details: { query, resultCount: representation.split("\n").filter(Boolean).length }, }; }, - }, + }), { name: "honcho_search_conclusions" } ); } diff --git a/tools/session.ts b/tools/session.ts index b6c1dc2..4f9e72d 100644 --- a/tools/session.ts +++ b/tools/session.ts @@ -54,6 +54,7 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(toolCtx.agentId); const sessionKey = buildSessionKey(toolCtx); + const humanPeer = await state.resolveSessionHumanPeer(sessionKey); try { const session = await state.honcho.session(sessionKey); @@ -61,7 +62,7 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): const context = await session.context({ summary: includeSummary, tokens: messageLimit, - peerTarget: state.ownerPeer!, + peerTarget: humanPeer, peerPerspective: agentPeer, searchQuery: searchQuery, }); @@ -88,7 +89,7 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): if (includeMessages && context.messages.length > 0) { const messageLines = context.messages.map((msg) => { - const speaker = msg.peerId === state.ownerPeer!.id ? "User" : "OpenClaw"; + const speaker = state.isHumanPeerId(msg.peerId) ? "User" : "OpenClaw"; const timestamp = msg.createdAt ? new Date(msg.createdAt).toLocaleString() : ""; From fdecfd2996dc45618c6252fd9bb161c4ee0348a1 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Apr 2026 23:27:51 -0700 Subject: [PATCH 02/19] fix: address CodeRabbit review feedback - openclaw.plugin.json: restore disableDefaultNoisePatterns schema entry (config.ts parses it but JSON schema was missing, blocking validation) - helpers.ts: anchor extractSenderId() to first sentinel occurrence only, preventing user-pasted metadata blocks from poisoning attribution - hooks/capture.ts: remove raw sender IDs from debug logs (PII concern) - state.ts: serialize workspace metadata writes in getHumanPeer() and getAgentPeer() with a promise-based lock to prevent concurrent read-modify-write races - workspace_md/AGENTS.md: restored to main (had stale tool names from pre-#49 branch) Co-authored-by: Minh Nguyen --- helpers.ts | 6 ++++++ hooks/capture.ts | 2 +- openclaw.plugin.json | 10 ++++++++++ state.ts | 22 +++++++++++++++++----- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/helpers.ts b/helpers.ts index ad4978e..b665374 100644 --- a/helpers.ts +++ b/helpers.ts @@ -148,13 +148,19 @@ const CONVERSATION_INFO_SENTINEL = "Conversation info (untrusted metadata):"; * Extract the sender_id from a raw message's "Conversation info (untrusted metadata):" * metadata block. Must be called BEFORE cleanMessageContent() which strips these blocks. * Returns undefined for DMs (no metadata block) or on parse failure. + * + * Only considers the FIRST occurrence of the sentinel to prevent user-pasted or quoted + * metadata blocks from poisoning sender attribution. */ export function extractSenderId(content: string): string | undefined { if (!content || !content.includes(CONVERSATION_INFO_SENTINEL)) return undefined; const lines = content.split("\n"); + let found = false; for (let i = 0; i < lines.length; i++) { if (lines[i].trim() !== CONVERSATION_INFO_SENTINEL) continue; + if (found) return undefined; // Ignore duplicate sentinels (likely user-pasted content) + found = true; if (lines[i + 1]?.trim() !== "```json") continue; // Collect JSON lines between ```json and ``` diff --git a/hooks/capture.ts b/hooks/capture.ts index d42b280..32a8c26 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -104,7 +104,7 @@ async function flushMessages( } } if (senderIds.size > 0) { - api.logger.debug?.(`[honcho] Resolved ${senderIds.size} unique sender(s) from ${userMsgCount} user message(s): ${[...senderIds].join(", ")}`); + api.logger.debug?.(`[honcho] Resolved ${senderIds.size} unique sender(s) from ${userMsgCount} user message(s)`); } // Parallel peer resolution — avoids sequential await bottleneck in group chats. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 3bcf74b..2c90420 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -27,6 +27,11 @@ "default": false, "description": "Whether the owner peer observes agent messages. When true, Honcho models the user's awareness of assistant responses." }, + "disableDefaultNoisePatterns": { + "type": "boolean", + "default": false, + "description": "When true, built-in noise patterns are not applied — only noisePatterns entries are used." + }, "peerMappings": { "type": "object", "additionalProperties": { "type": "string" }, @@ -68,6 +73,11 @@ "advanced": true, "help": "When enabled, Honcho models the user as aware of the agent's responses. Default: off." }, + "disableDefaultNoisePatterns": { + "label": "Disable Default Noise Patterns", + "advanced": true, + "help": "When enabled, only custom noisePatterns entries are used — built-in defaults are skipped." + }, "peerMappings": { "label": "Peer Mappings", "advanced": true, diff --git a/state.ts b/state.ts index 7d2e2fe..dff678c 100644 --- a/state.ts +++ b/state.ts @@ -59,6 +59,10 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { // workspace metadata. Errors propagate to all waiters. let initPromise: Promise | null = null; + // Serialize workspace metadata writes to prevent concurrent read-modify-write + // races between getHumanPeer() and getAgentPeer(). + let metadataWriteLock: Promise = Promise.resolve(); + const state: PluginState = { honcho, cfg, @@ -146,10 +150,14 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { let honchoId = state.humanPeerMap[channelPeerId]; if (!honchoId) { honchoId = channelPeerId; - // Persist auto-created mapping + // Persist auto-created mapping (serialized to prevent concurrent write races) state.humanPeerMap[channelPeerId] = honchoId; - const wsMeta = await honcho.getMetadata(); - await honcho.setMetadata({ ...wsMeta, humanPeerMap: state.humanPeerMap }); + const prev = metadataWriteLock; + metadataWriteLock = prev.then(async () => { + const wsMeta = await honcho.getMetadata(); + await honcho.setMetadata({ ...wsMeta, humanPeerMap: state.humanPeerMap }); + }).catch(() => { /* errors logged elsewhere */ }); + await metadataWriteLock; api.logger.info(`[honcho] Auto-created human peer mapping: "${channelPeerId}" → "${honchoId}"`); } @@ -213,8 +221,12 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { if (state.agentPeerMap[id] !== peerId) { state.agentPeerMap[id] = peerId; - const wsMeta = await honcho.getMetadata(); - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); + const prev = metadataWriteLock; + metadataWriteLock = prev.then(async () => { + const wsMeta = await honcho.getMetadata(); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); + }).catch(() => { /* errors logged elsewhere */ }); + await metadataWriteLock; } peer = await honcho.peer(peerId); From e798223557d774dc76190748371f083ff30bb3c4 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 12 Apr 2026 00:59:59 -0700 Subject: [PATCH 03/19] fix: restore cli.ts to upstream main, keep only -p/--peer flag additions Per review feedback from @ajspig: the CLI setup simplification was out of scope for the multi-peer PR. Restored commands/cli.ts to match current main, retaining only the -p/--peer flag additions to 'honcho ask' and 'honcho search' subcommands. Co-authored-by: Minh Nguyen --- commands/cli.ts | 359 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 272 insertions(+), 87 deletions(-) diff --git a/commands/cli.ts b/commands/cli.ts index d42766f..fb46cda 100644 --- a/commands/cli.ts +++ b/commands/cli.ts @@ -1,3 +1,4 @@ +import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -8,6 +9,31 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginState } from "../state.js"; import { OWNER_ID } from "../state.js"; +/* ── Upload manifest ─────────────────────────────────────────────────── */ + +type ManifestEntry = { sha256: string; uploadedAt: string; baseUrl: string; workspaceId: string }; +type UploadManifest = Record; + +const MANIFEST_PATH = () => path.join(os.homedir(), ".openclaw", ".upload-manifest.json"); + +function loadManifest(): UploadManifest { + try { + return JSON.parse(fs.readFileSync(MANIFEST_PATH(), "utf-8")); + } catch { + return {}; + } +} + +function saveManifest(manifest: UploadManifest): void { + const dir = path.dirname(MANIFEST_PATH()); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(MANIFEST_PATH(), JSON.stringify(manifest, null, 2)); +} + +function contentHash(content: Buffer): string { + return crypto.createHash("sha256").update(content).digest("hex"); +} + export function registerCli(api: OpenClawPluginApi, state: PluginState): void { api.registerCli( ({ program, workspaceDir }) => { @@ -16,61 +42,111 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { cmd .command("setup") .description("Configure Honcho API key and upload memory files to Honcho") - .action(async () => { + .option("--reconfigure", "Force re-entry of all configuration values") + .action(async (options: { reconfigure?: boolean }) => { const configDir = path.join(os.homedir(), ".openclaw"); const configPath = path.join(configDir, "openclaw.json"); + // Load existing config to use as defaults + let config: Record = {}; + if (fs.existsSync(configPath)) { + try { config = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { /* use empty */ } + } + const existingPluginCfg = ( + ((config.plugins as Record) + ?.entries as Record) + ?.["openclaw-honcho"] as Record + )?.config as Record | undefined; + + const savedApiKey = (existingPluginCfg?.apiKey as string) ?? ""; + const savedBaseUrl = (existingPluginCfg?.baseUrl as string) || "https://api.honcho.dev"; + const savedWorkspaceId = (existingPluginCfg?.workspaceId as string) || "openclaw"; + const hasExistingConfig = !!existingPluginCfg && !!savedApiKey; + console.log("\nHoncho Setup\n"); console.log("Get your API key from: https://app.honcho.dev\n"); - console.log('Press Enter to use the default shown in [brackets].\n'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (q: string): Promise => new Promise((resolve) => rl.question(q, resolve)); try { - const apiKeyInput = await ask("Honcho API key (press Enter for self-hosted mode): "); - const baseUrlInput = await ask("Base URL [https://api.honcho.dev]: "); - const workspaceIdInput = await ask("Workspace ID [openclaw]: "); - - const resolvedBaseUrl = baseUrlInput.trim() || "https://api.honcho.dev"; - const resolvedWorkspaceId = workspaceIdInput.trim() || "openclaw"; - - // Write config - let config: Record = {}; - if (fs.existsSync(configPath)) { - try { config = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { /* use empty */ } + let resolvedApiKey: string; + let resolvedBaseUrl: string; + let resolvedWorkspaceId: string; + + if (hasExistingConfig && !options.reconfigure) { + const maskedKey = savedApiKey.length > 8 + ? savedApiKey.slice(0, 4) + "..." + savedApiKey.slice(-4) + : "****"; + console.log("Existing configuration found:"); + console.log(` API key: ${maskedKey}`); + console.log(` Base URL: ${savedBaseUrl}`); + console.log(` Workspace ID: ${savedWorkspaceId}`); + console.log('\nPress Enter to keep existing values, or use --reconfigure to change.\n'); + + resolvedApiKey = savedApiKey; + resolvedBaseUrl = savedBaseUrl; + resolvedWorkspaceId = savedWorkspaceId; + console.log("✓ Using existing configuration\n"); + } else { + console.log('Press Enter to use the default shown in [brackets].\n'); + + const apiKeyDefault = savedApiKey ? ` [${savedApiKey.slice(0, 4)}...${savedApiKey.slice(-4)}]` : ""; + const apiKeyInput = await ask(`Honcho API key${apiKeyDefault || " (press Enter for self-hosted mode)"}: `); + const baseUrlInput = await ask(`Base URL [${savedBaseUrl}]: `); + const workspaceIdInput = await ask(`Workspace ID [${savedWorkspaceId}]: `); + + resolvedApiKey = apiKeyInput.trim() || savedApiKey; + resolvedBaseUrl = baseUrlInput.trim() || savedBaseUrl; + resolvedWorkspaceId = workspaceIdInput.trim() || savedWorkspaceId; + + // Write config + if (!config.plugins) config.plugins = {}; + const pluginsSection = config.plugins as Record; + if (!pluginsSection.entries) pluginsSection.entries = {}; + const entriesSection = pluginsSection.entries as Record; + const existingEntry = (entriesSection["openclaw-honcho"] as Record) ?? {}; + const pluginCfg: Record = { + ...(existingEntry.config as Record ?? {}), + }; + if (resolvedApiKey) pluginCfg.apiKey = resolvedApiKey; + else delete pluginCfg.apiKey; + pluginCfg.baseUrl = resolvedBaseUrl; + pluginCfg.workspaceId = resolvedWorkspaceId; + entriesSection["openclaw-honcho"] = { ...existingEntry, config: pluginCfg }; + + if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log("\n✓ Configuration saved to ~/.openclaw/openclaw.json"); } - if (!config.plugins) config.plugins = {}; - const pluginsSection = config.plugins as Record; - if (!pluginsSection.entries) pluginsSection.entries = {}; - const entriesSection = pluginsSection.entries as Record; - const existingEntry = (entriesSection["openclaw-honcho"] as Record) ?? {}; - const pluginCfg: Record = { - ...(existingEntry.config as Record ?? {}), - }; - const trimmedApiKey = apiKeyInput.trim(); - if (trimmedApiKey) pluginCfg.apiKey = trimmedApiKey; - else delete pluginCfg.apiKey; - const trimmedBaseUrl = baseUrlInput.trim(); - if (trimmedBaseUrl) pluginCfg.baseUrl = trimmedBaseUrl; - else delete pluginCfg.baseUrl; - const trimmedWorkspaceId = workspaceIdInput.trim(); - if (trimmedWorkspaceId) pluginCfg.workspaceId = trimmedWorkspaceId; - else delete pluginCfg.workspaceId; - entriesSection["openclaw-honcho"] = { ...existingEntry, config: pluginCfg }; - - if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - console.log("\n✓ Configuration saved to ~/.openclaw/openclaw.json"); - - // Resolve default agent and its workspace from config + + // Resolve configured agents and their workspaces from config let savedConfig: Record = {}; try { savedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { /* use empty */ } const agentsList = Array.isArray((savedConfig?.agents as Record)?.list) ? ((savedConfig.agents as Record).list as Array>) : []; - const defaultAgent = agentsList.find((a) => a?.default) ?? agentsList[0] ?? null; + const hasExplicitDefault = agentsList.some((a) => a?.default === true); + const normalizedAgents = (agentsList.length > 0 ? agentsList : [{ id: "main", default: true }]) + .map((agent, index) => { + const agentId = ((agent?.id as string) ?? (index === 0 ? "main" : `a${index + 1}`)).toLowerCase().trim() || "main"; + return { + id: agentId, + workspace: agent?.workspace as string | undefined, + workspaceDir: agent?.workspaceDir as string | undefined, + isDefault: agent?.default === true || (index === 0 && !hasExplicitDefault), + }; + }) + .filter((agent, index, all) => { + const firstIndex = all.findIndex((candidate) => candidate.id === agent.id); + if (firstIndex !== index) { + console.log(` ! Duplicate normalized agent ID "${agent.id}" — skipping later entry during migration setup`); + return false; + } + return true; + }); + const defaultAgent = normalizedAgents.find((a) => a.isDefault) ?? normalizedAgents[0]; const defaultAgentId = ((defaultAgent?.id as string) ?? "main").toLowerCase().trim() || "main"; const defaultAgentPeerId = `agent-${defaultAgentId}`; @@ -78,66 +154,98 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { const AGENT_FILES = ["SOUL.md", "IDENTITY.md", "AGENTS.md", "TOOLS.md", "BOOTSTRAP.md"]; const OWNER_DIRS = ["memory", "canvas"]; - type FileEntry = { filePath: string; peer: "owner" | "agent" }; + type FileEntry = { filePath: string; peer: "owner" | "agent"; peerId: string; agentId?: string }; const detected: FileEntry[] = []; - function collectDir(dirPath: string, peerType: "owner" | "agent"): void { + function hasDetected(filePath: string, peerId: string): boolean { + return detected.some((entry) => entry.filePath === filePath && entry.peerId === peerId); + } + + function collectDir(dirPath: string, peerType: "owner" | "agent", agentId?: string): void { if (!fs.existsSync(dirPath)) return; const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true }); + const peerId = peerType === "owner" ? OWNER_ID : `agent-${agentId ?? defaultAgentId}`; for (const e of dirEntries) { const full = path.join(dirPath, e.name); - if (e.isDirectory()) collectDir(full, peerType); - else detected.push({ filePath: full, peer: peerType }); + if (e.isDirectory()) collectDir(full, peerType, agentId); + else if (!hasDetected(full, peerId)) detected.push({ filePath: full, peer: peerType, peerId, agentId }); } } - function scanWorkspace(wsDir: string): void { + function scanWorkspace(wsDir: string, agentId?: string): void { for (const file of OWNER_FILES) { const p = path.join(wsDir, file); - if (fs.existsSync(p) && !detected.find((d) => d.filePath === p)) - detected.push({ filePath: p, peer: "owner" }); + if (fs.existsSync(p) && !hasDetected(p, OWNER_ID)) + detected.push({ filePath: p, peer: "owner", peerId: OWNER_ID }); } - for (const file of AGENT_FILES) { - const p = path.join(wsDir, file); - if (fs.existsSync(p) && !detected.find((d) => d.filePath === p)) - detected.push({ filePath: p, peer: "agent" }); + if (agentId) { + const peerId = `agent-${agentId}`; + for (const file of AGENT_FILES) { + const p = path.join(wsDir, file); + if (fs.existsSync(p) && !hasDetected(p, peerId)) + detected.push({ filePath: p, peer: "agent", peerId, agentId }); + } } for (const dir of OWNER_DIRS) { collectDir(path.join(wsDir, dir), "owner"); } } - // Build ordered candidate workspace paths, deduplicated by real path. const ocHome = path.join(os.homedir(), ".openclaw"); + const defaultWorkspace = ((savedConfig?.agents as Record)?.defaults as Record)?.workspace as string | undefined; + + function uniqueWorkspacePaths(paths: Array): string[] { + const seen = new Set(); + return paths.filter((p): p is string => typeof p === "string" && p.length > 0).filter((p) => { + const real = fs.existsSync(p) ? fs.realpathSync(p) : p; + if (seen.has(real)) return false; + seen.add(real); + return true; + }); + } - const candidateWsPaths: string[] = [ + const ownerCandidateWsPaths = uniqueWorkspacePaths([ workspaceDir as string, defaultAgent?.workspace as string, defaultAgent?.workspaceDir as string, - ((savedConfig?.agents as Record)?.defaults as Record)?.workspace as string, - path.join(ocHome, "agents", defaultAgentId, "workspace"), + defaultWorkspace, path.join(ocHome, "workspace"), path.join(os.homedir(), ".clawdbot", "workspace"), - ].filter(Boolean); - - // Deduplicate by resolved real path so symlinks / duplicate entries don't double-scan - const seen = new Set(); - const uniqueCandidates = candidateWsPaths.filter((p) => { - const real = fs.existsSync(p) ? fs.realpathSync(p) : p; - if (seen.has(real)) return false; - seen.add(real); - return true; - }); + ]); - for (const candidate of uniqueCandidates) { + const agentWorkspaceCandidates = normalizedAgents.map((agent) => ({ + agentId: agent.id, + peerId: `agent-${agent.id}`, + workspacePaths: uniqueWorkspacePaths([ + agent.workspace, + agent.workspaceDir, + agent.isDefault ? (workspaceDir as string) : undefined, + agent.isDefault ? defaultWorkspace : undefined, + path.join(ocHome, "agents", agent.id, "workspace"), + agent.isDefault ? path.join(ocHome, "workspace") : undefined, + agent.isDefault ? path.join(os.homedir(), ".clawdbot", "workspace") : undefined, + ]), + })); + + // Scan shared/default workspace roots only for owner files. Agent files + // must come from an agent-specific workspace path so they can be + // assigned to the correct `agent-{id}` peer. + for (const candidate of ownerCandidateWsPaths) { scanWorkspace(candidate); - if (detected.length > 0) break; + } + for (const agent of agentWorkspaceCandidates) { + for (const candidate of agent.workspacePaths) { + scanWorkspace(candidate, agent.agentId); + } } // Still nothing — prompt user to enter additional paths manually if (detected.length === 0) { console.log("\nNo memory files found. Searched:"); - for (const c of uniqueCandidates) console.log(` ${c}`); + for (const c of ownerCandidateWsPaths) console.log(` ${c}`); + for (const agent of agentWorkspaceCandidates) { + for (const c of agent.workspacePaths) console.log(` ${c} (agent: ${agent.agentId})`); + } console.log('\nEnter file or directory paths to upload (one per line, empty line to finish):'); console.log('Format: /path/to/file-or-dir [owner|agent] (peer defaults to "owner" if omitted)\n'); while (true) { @@ -156,10 +264,15 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { continue; } if (fs.statSync(inputPath).isDirectory()) { - collectDir(inputPath, peerType); + collectDir(inputPath, peerType, peerType === "agent" ? defaultAgentId : undefined); console.log(` + ${inputPath}/ (directory) → ${peerType === "owner" ? OWNER_ID : defaultAgentPeerId}`); } else { - detected.push({ filePath: inputPath, peer: peerType }); + detected.push({ + filePath: inputPath, + peer: peerType, + peerId: peerType === "owner" ? OWNER_ID : defaultAgentPeerId, + agentId: peerType === "agent" ? defaultAgentId : undefined, + }); console.log(` + ${inputPath} → ${peerType === "owner" ? OWNER_ID : defaultAgentPeerId}`); } } @@ -173,10 +286,12 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { console.log(`\nFound ${detected.length} memory file(s):`); console.log(`Default agent: ${defaultAgentId} (peer: ${defaultAgentPeerId})`); - for (const { filePath, peer } of detected) { + if (normalizedAgents.length > 1) { + console.log(`Configured agents: ${normalizedAgents.map((agent) => `${agent.id} (peer: agent-${agent.id})`).join(", ")}`); + } + for (const { filePath, peerId } of detected) { const size = fs.statSync(filePath).size; - const peerLabel = peer === "owner" ? OWNER_ID : defaultAgentPeerId; - console.log(` ${filePath} (${(size / 1024).toFixed(1)} KB) → ${peerLabel}`); + console.log(` ${filePath} (${(size / 1024).toFixed(1)} KB) → ${peerId}`); } console.log(`\nData destination: ${resolvedBaseUrl}`); @@ -189,7 +304,7 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { // Upload files to Honcho const setupHoncho = new Honcho({ - apiKey: apiKeyInput.trim() || undefined, + apiKey: resolvedApiKey || undefined, baseURL: resolvedBaseUrl, workspaceId: resolvedWorkspaceId, }); @@ -197,38 +312,108 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { const existingMeta = await setupHoncho.getMetadata(); await setupHoncho.setMetadata({ ...existingMeta }); const ownerPeerSetup = await setupHoncho.peer(OWNER_ID, { metadata: {} }); - const agentPeerSetup = await setupHoncho.peer(defaultAgentPeerId, { metadata: { agentId: defaultAgentId } }); + const agentPeerSetupMap = new Map>>(); + for (const agent of normalizedAgents) { + const peerId = `agent-${agent.id}`; + const peer = await setupHoncho.peer(peerId, { metadata: { agentId: agent.id } }); + agentPeerSetupMap.set(agent.id, peer); + } const migrationSession = await setupHoncho.session("migration-setup", { metadata: {} }); - await migrationSession.addPeers([ - [ownerPeerSetup, { observeMe: true, observeOthers: false }], - [agentPeerSetup, { observeMe: true, observeOthers: true }], - ]); + await migrationSession.addPeers([ownerPeerSetup, { observeMe: true, observeOthers: false }]); + for (const agent of normalizedAgents) { + await migrationSession.addPeers([ + agentPeerSetupMap.get(agent.id)!, + { observeMe: true, observeOthers: true }, + ]); + } + + // Cooldown after setup calls — the hosted platform (groudon) enforces + // 5 req/sec per tenant; the 6 calls above consume most of that budget. + await new Promise((r) => setTimeout(r, 1500)); const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5MB safety cap + const UPLOAD_DELAY_MS = 400; // stay under 5 req/sec platform limit + const manifest = loadManifest(); let uploadCount = 0; - for (const { filePath, peer } of detected) { + let unchangedCount = 0; + const skipped: string[] = []; + const failed: { filePath: string; error: string }[] = []; + const total = detected.length; + + for (let i = 0; i < detected.length; i++) { + const { filePath, peer, agentId } = detected[i]; + const progress = `[${i + 1}/${total}]`; + const stat = await fs.promises.stat(filePath).catch(() => null); if (!stat?.isFile()) continue; if (stat.size > MAX_UPLOAD_BYTES) { - console.log(` ! Skipping (too large): ${filePath}`); + console.log(` ${progress} ! Skipping (larger than 5MB): ${filePath}`); + skipped.push(filePath); continue; } const filename = path.basename(filePath); const ext = path.extname(filename).toLowerCase(); const content_type = ext === ".json" ? "application/json" : ext === ".md" ? "text/markdown" : null; if (!content_type) { - console.log(` ! Skipping unsupported file type: ${filePath}`); + console.log(` ${progress} ! Skipping unsupported type: ${filePath}`); + skipped.push(filePath); continue; } - const content = await fs.promises.readFile(filePath); - const targetPeer = peer === "owner" ? ownerPeerSetup : agentPeerSetup; - await new Promise((r) => setTimeout(r, 250)); // stay under 5 req/sec limit - await migrationSession.uploadFile({ filename, content, content_type }, targetPeer, {}); - console.log(` ✓ Uploaded: ${filePath}`); - uploadCount++; + + const targetPeer = peer === "owner" + ? ownerPeerSetup + : agentPeerSetupMap.get(agentId ?? defaultAgentId); + if (!targetPeer) { + console.log(` ${progress} ✗ Failed: ${filePath}`); + failed.push({ filePath, error: `Missing Honcho peer for agent ${agentId ?? defaultAgentId}` }); + continue; + } + try { + const content = await fs.promises.readFile(filePath); + const hash = contentHash(content); + + // Skip files already uploaded with identical content to the same destination + const prev = manifest[filePath]; + if (prev && prev.sha256 === hash && prev.baseUrl === resolvedBaseUrl && prev.workspaceId === resolvedWorkspaceId) { + console.log(` ${progress} ~ Unchanged: ${filePath}`); + unchangedCount++; + continue; + } + + await new Promise((r) => setTimeout(r, UPLOAD_DELAY_MS)); + await migrationSession.uploadFile({ filename, content, content_type }, targetPeer, {}); + console.log(` ${progress} ✓ Uploaded: ${filePath}`); + uploadCount++; + + // Record success + manifest[filePath] = { sha256: hash, uploadedAt: new Date().toISOString(), baseUrl: resolvedBaseUrl, workspaceId: resolvedWorkspaceId }; + saveManifest(manifest); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` ${progress} ✗ Failed: ${filePath}`); + failed.push({ filePath, error: msg }); + } + } + + // Clean stale manifest entries + for (const key of Object.keys(manifest)) { + if (!fs.existsSync(key)) delete manifest[key]; + } + saveManifest(manifest); + + // Summary + console.log(`\nUpload summary:`); + console.log(` Uploaded: ${uploadCount}/${total}`); + if (unchangedCount > 0) console.log(` Unchanged: ${unchangedCount}`); + if (skipped.length > 0) console.log(` Skipped: ${skipped.length}`); + if (failed.length > 0) { + console.log(` Failed: ${failed.length}`); + for (const f of failed) { + console.log(` ! ${f.filePath} — ${f.error}`); + } + console.log(`\nRun \`openclaw honcho setup\` again to retry failed files.`); } - console.log(`\n✓ Uploaded ${uploadCount} file(s) to Honcho`); console.log("\n✓ Setup complete. Run `openclaw gateway --force` to activate.\n"); } finally { From 01222c2213848b6b62945241012ed2d6d2faed70 Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 15 Apr 2026 16:07:31 -0400 Subject: [PATCH 04/19] =?UTF-8?q?fix:=20rename=20humanPeer=20=E2=86=92=20p?= =?UTF-8?q?articipantPeer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes terminology: "participant" covers humans AND any non-agent bots sharing a group chat, not just humans. Renames touch state.ts, hooks, tools, CLI commands, and helpers; public helper signatures change accordingly. Adtl Behavioral changes: - state.ts: metadata write lock now propagates errors to the caller (previously swallowed), with logger.error on failure. - runtime.ts: buildSessionTranscript and the memory search manager now resolve the session's participant peer instead of always using ownerPeer, and transcript speaker labels use isParticipantPeerId to distinguish User() from generic Peer(). - hooks/capture.ts: writes participantSenderId/participantSenderIds. - helpers.test.ts: new tests covering extractMessages participant resolution; runtime.test.ts updated for the new label logic. - README: participant-model wording, and a note that DMs on platforms that emit sender metadata (Telegram) attribute to the sender peer --- README.md | 10 ++-- commands/cli.ts | 8 +-- helpers.test.ts | 82 ++++++++++++++++++++++++++++++ helpers.ts | 4 +- hooks/capture.ts | 24 ++++----- hooks/context.ts | 6 +-- openclaw.plugin.json | 10 ++++ runtime.test.ts | 60 +++++++++++++++------- runtime.ts | 24 ++++----- state.ts | 110 ++++++++++++++++++++++++---------------- tools/ask.ts | 4 +- tools/context.ts | 6 +-- tools/message-search.ts | 6 +-- tools/search.ts | 4 +- tools/session.ts | 6 +-- 15 files changed, 249 insertions(+), 115 deletions(-) create mode 100644 helpers.test.ts diff --git a/README.md b/README.md index c75117a..823095b 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Set `ownerObserveOthers: true` to let the owner peer also observe agent messages ### Peer Mappings -In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform ID from each inbound message and maps it to a Honcho peer. This gives each human participant their own memory and representation in Honcho, rather than attributing all user messages to a single generic peer. +In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform ID from each inbound message and maps it to a Honcho peer. This gives every participant — humans and any other bots in the room — their own memory and representation in Honcho, rather than attributing all non-agent messages to a single generic peer. Configure `peerMappings` to map known channel peer IDs to specific Honcho peer IDs: @@ -138,22 +138,24 @@ Configure `peerMappings` to map known channel peer IDs to specific Honcho peer I ``` **How it works:** -- The plugin reads the `sender_id` field from OpenClaw's "Conversation info (untrusted metadata):" block, which is injected into group chat messages. +- The plugin reads the `sender_id` field from OpenClaw's "Conversation info (untrusted metadata):" block, which OpenClaw injects on every inbound message that has a known sender — including 1-on-1 DMs on platforms like Telegram, not just group chats. - If the sender ID has a `peerMappings` entry, the mapped Honcho peer ID is used. - If no mapping exists, a Honcho peer is auto-created using the channel peer ID directly (e.g., `U07KX7DG002` becomes the Honcho peer ID). -- In DMs, no sender metadata is present, so the default `owner` peer is used (existing behavior preserved). +- The default `owner` peer is only used as a fallback when a message has no sender metadata at all (e.g., synthetic/system messages, or channel integrations that don't emit a `Conversation info` block). On platforms like Telegram, even DMs are attributed to the sender's own peer, not `owner`. - `agentPeerMappings` works the same way for agent peers — by default, an agent with ID `slack` gets Honcho peer ID `agent-slack`, but you can override it (e.g., to `demerzel`). - All tools (`honcho_context`, `honcho_ask`, etc.) automatically resolve the correct peer for the current session. Auto-created peer mappings are persisted in workspace metadata so they survive gateway restarts. +**One-turn warmup for context injection in brand-new sessions.** Message *attribution* (capture) works correctly from the first turn — `extractSenderId` reads `sender_id` from each message's metadata block and routes to the right peer regardless of session state. *Context injection*, however, runs at `before_prompt_build` and looks up the session's primary participant peer via `resolveSessionParticipantPeer`, which reads `participantSenderId` from session metadata. On a brand-new session there is no session metadata yet, so the first turn's prompt context is built for the default `owner` peer. From the second turn onward (after capture writes `participantSenderId`), context is built for the resolved sender. Sessions whose channel never emits sender metadata (no `Conversation info` block) stay attributed to `owner`. + ## How it works Once installed, the plugin works automatically: - **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models. Message capture starts when the plugin is active for a session, and preserves original timestamps for captured messages. Messages are also flushed before session compaction and `/new`/`/reset`, so no conversation data is lost. - **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, and `honcho_ask` to retrieve relevant context about the user. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. -- **Multi-Peer Model** — Honcho maintains separate representations for each participant. In group chats, each human sender gets their own peer (mapped via `peerMappings` or auto-created from their platform ID). Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, overridable via `agentPeerMappings`). In DMs, the default `owner` peer is used. This gives every participant isolated, personalized memory. +- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer — mapped via `peerMappings` or auto-created from their platform ID. Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, overridable via `agentPeerMappings`). The default `owner` peer is only used as a fallback when a channel emits no sender metadata. This gives every participant isolated, personalized memory. - **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted. Noise messages (heartbeat acks, cron boilerplate, startup commands) are dropped entirely via configurable pattern filters. Honcho handles all reasoning and synthesis in the cloud. diff --git a/commands/cli.ts b/commands/cli.ts index fb46cda..fcb8803 100644 --- a/commands/cli.ts +++ b/commands/cli.ts @@ -447,8 +447,8 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { try { await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(options.agent ?? state.resolveDefaultAgentId()); - const humanPeer = await state.getHumanPeer(options.peer); - const answer = await agentPeer.chat(question, { target: humanPeer }); + const participantPeer = await state.getParticipantPeer(options.peer); + const answer = await agentPeer.chat(question, { target: participantPeer }); console.log(answer ?? "No information available."); } catch (error) { console.error(`Failed to query: ${error}`); @@ -464,8 +464,8 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void { .action(async (query: string, options: { topK: string; maxDistance: string; peer?: string }) => { try { await state.ensureInitialized(); - const humanPeer = await state.getHumanPeer(options.peer); - const representation = await humanPeer.representation({ + const participantPeer = await state.getParticipantPeer(options.peer); + const representation = await participantPeer.representation({ searchQuery: query, searchTopK: parseInt(options.topK, 10), searchMaxDistance: parseFloat(options.maxDistance), diff --git a/helpers.test.ts b/helpers.test.ts new file mode 100644 index 0000000..1d10663 --- /dev/null +++ b/helpers.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { extractSenderId } from "./helpers.js"; + +const SENTINEL = "Conversation info (untrusted metadata):"; + +function metadataBlock(payload: Record): string { + return [ + SENTINEL, + "```json", + JSON.stringify(payload, null, 2), + "```", + ].join("\n"); +} + +describe("extractSenderId", () => { + it("reads sender_id from a leading metadata block", () => { + const content = [ + metadataBlock({ sender_id: "U01ZB5DG019", channel: "C-foo" }), + "", + "hello there", + ].join("\n"); + + expect(extractSenderId(content)).toBe("U01ZB5DG019"); + }); + + it("trusts only the first sentinel and never considers later quoted blocks", () => { + // First sentinel resolves — second block (user-pasted) must be ignored. + const trusted = [ + metadataBlock({ sender_id: "U-trusted" }), + "", + "look at this thing they quoted at me:", + "", + metadataBlock({ sender_id: "U-spoofed" }), + ].join("\n"); + + expect(extractSenderId(trusted)).toBe("U-trusted"); + + // First sentinel is malformed (no fenced json) — the duplicate-sentinel + // guard then refuses to trust the later block. + const poisoned = [ + SENTINEL, + "(not a fenced json block)", + "", + metadataBlock({ sender_id: "U-spoofed" }), + ].join("\n"); + + expect(extractSenderId(poisoned)).toBeUndefined(); + }); + + it("returns undefined on malformed JSON inside the metadata block", () => { + const content = [ + SENTINEL, + "```json", + "{ this is : not, valid json", + "```", + "", + "body", + ].join("\n"); + + expect(extractSenderId(content)).toBeUndefined(); + }); + + it("prefers sender_id when both sender_id and sender are present", () => { + const content = metadataBlock({ + sender_id: "U-primary", + sender: "U-legacy", + }); + + expect(extractSenderId(content)).toBe("U-primary"); + }); + + it("falls back to sender when sender_id is absent", () => { + const content = metadataBlock({ sender: "U-legacy" }); + + expect(extractSenderId(content)).toBe("U-legacy"); + }); + + it("returns undefined when the content has no metadata block", () => { + expect(extractSenderId("just a normal DM")).toBeUndefined(); + expect(extractSenderId("")).toBeUndefined(); + }); +}); diff --git a/helpers.ts b/helpers.ts index b665374..1f8b378 100644 --- a/helpers.ts +++ b/helpers.ts @@ -210,7 +210,7 @@ export function shouldSkipMessage(content: string, noisePatterns: string[]): boo export function extractMessages( rawMessages: unknown[], - defaultHumanPeer: Peer, + defaultParticipantPeer: Peer, agentPeer: Peer, noisePatterns: string[] = [], resolvePeer?: (senderId: string) => Peer | undefined, @@ -245,7 +245,7 @@ export function extractMessages( let peer: Peer; if (role === "user") { const senderId = extractSenderId(rawContent); - peer = (senderId && resolvePeer?.(senderId)) || defaultHumanPeer; + peer = (senderId && resolvePeer?.(senderId)) || defaultParticipantPeer; } else { peer = agentPeer; } diff --git a/hooks/capture.ts b/hooks/capture.ts index 32a8c26..7419ed0 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -84,7 +84,7 @@ async function flushMessages( const newRawMessages = messages.slice(startIndex); - // Pre-resolve human peers for all unique sender IDs in this batch + // Pre-resolve participant peers for all unique sender IDs in this batch const senderIds = new Set(); let lastSenderId: string | undefined; let userMsgCount = 0; @@ -108,16 +108,16 @@ async function flushMessages( } // Parallel peer resolution — avoids sequential await bottleneck in group chats. - const resolvedPeers = new Map>>(); + const resolvedPeers = new Map>>(); const senderIdArray = [...senderIds]; - const peers = await Promise.all(senderIdArray.map((id) => state.getHumanPeer(id))); + const peers = await Promise.all(senderIdArray.map((id) => state.getParticipantPeer(id))); for (let i = 0; i < senderIdArray.length; i++) { resolvedPeers.set(senderIdArray[i], peers[i]); } - const defaultHumanPeer = await state.getHumanPeer(); + const defaultParticipantPeer = await state.getParticipantPeer(); - // Build peer configs: default owner + all resolved human peers + agent + parent + // Build peer configs: default owner + all resolved participant peers + agent + parent const peerConfigMap = new Map(); peerConfigMap.set(OWNER_ID, { observeMe: true, observeOthers: state.cfg.ownerObserveOthers }); for (const [, peer] of resolvedPeers) { @@ -137,18 +137,18 @@ async function flushMessages( const extracted = extractMessages( newRawMessages, - defaultHumanPeer, + defaultParticipantPeer, agentPeer, state.cfg.noisePatterns, (senderId) => resolvedPeers.get(senderId), ); // Store sender IDs in session metadata for tool resolution. - // humanSenderId = last active sender (default for tools). - // humanSenderIds = all known senders in this session (for future multi-target tools). + // participantSenderId = last active sender (default for tools). + // participantSenderIds = all known senders in this session (for future multi-target tools). // Named "sender" (not "peer") to distinguish raw channel IDs from resolved Honcho peer IDs. - const previousSenderIds: string[] = Array.isArray(existingMeta.humanSenderIds) - ? (existingMeta.humanSenderIds as string[]) + const previousSenderIds: string[] = Array.isArray(existingMeta.participantSenderIds) + ? (existingMeta.participantSenderIds as string[]) : []; const allSenderIds = [...new Set([...previousSenderIds, ...senderIds])]; @@ -158,10 +158,10 @@ async function flushMessages( lastSavedIndex: messages.length, }; if (lastSenderId) { - updatedMeta.humanSenderId = lastSenderId; + updatedMeta.participantSenderId = lastSenderId; } if (allSenderIds.length > 0) { - updatedMeta.humanSenderIds = allSenderIds; + updatedMeta.participantSenderIds = allSenderIds; } if (extracted.length === 0) { diff --git a/hooks/context.ts b/hooks/context.ts index b3c2859..28083a7 100644 --- a/hooks/context.ts +++ b/hooks/context.ts @@ -16,13 +16,13 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState): try { await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(agentId); - const humanPeer = await state.resolveSessionHumanPeer(sessionKey); + const participantPeer = await state.resolveSessionParticipantPeer(sessionKey); const sections: string[] = []; if (isSubagent) { try { - const peerCtx = await agentPeer.context({ target: humanPeer }); + const peerCtx = await agentPeer.context({ target: participantPeer }); if (peerCtx.peerCard?.length) { sections.push(`Key facts:\n${peerCtx.peerCard.map((f: string) => `• ${f}`).join("\n")}`); } @@ -44,7 +44,7 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState): context = await session.context({ summary: true, tokens: 2000, - peerTarget: humanPeer, + peerTarget: participantPeer, peerPerspective: agentPeer, }); } catch (e: unknown) { diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 542d8a8..33f9a87 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -48,6 +48,11 @@ "additionalProperties": { "type": "string" }, "default": {}, "description": "Maps OpenClaw agent IDs to Honcho peer IDs. By default, agent 'foo' gets peer ID 'agent-foo'." + }, + "crossSessionSearch": { + "type": "boolean", + "default": true, + "description": "When true (default), memory_search/memory_get can return results from any session in the workspace. When false, results are scoped to the active session and its child sessions only." } } }, @@ -98,6 +103,11 @@ "label": "Agent Peer Mappings", "advanced": true, "help": "Maps OpenClaw agent IDs to custom Honcho peer IDs. Default: agent 'foo' → peer 'agent-foo'." + }, + "crossSessionSearch": { + "label": "Cross-Session Search", + "advanced": true, + "help": "When enabled (default), memory tools can return results from any session in the workspace. Disable to scope results to the active session only." } } } diff --git a/runtime.test.ts b/runtime.test.ts index 7fd58c5..2890dce 100644 --- a/runtime.test.ts +++ b/runtime.test.ts @@ -2,7 +2,15 @@ import { describe, expect, it, vi } from "vitest"; import { getHonchoMemorySearchManager, resolveHonchoMemoryBackendConfig } from "./runtime.js"; import type { PluginState } from "./state.js"; -function createState(baseUrl = "https://api.honcho.dev", { crossSessionSearch = true }: { crossSessionSearch?: boolean } = {}): PluginState { +type TestState = PluginState & { + participantPeer: { + id: string; + search: ReturnType; + sessions: ReturnType; + } | null; +}; + +function createState(baseUrl = "https://api.honcho.dev", { crossSessionSearch = true }: { crossSessionSearch?: boolean } = {}): TestState { const contexts = new Map> }>([ [ "session-1", @@ -94,7 +102,21 @@ function createState(baseUrl = "https://api.honcho.dev", { crossSessionSearch = const childSession = createSession("session-1-child"); - return { + const participantPeer = { + id: "owner", + search: vi.fn(async () => [ + { sessionId: "session-1", content: "Need to remember this" }, + { sessionId: "session-1-child", content: "Child transcript hit" }, + { sessionId: "other-session", content: "Other result" }, + ]), + sessions: vi.fn(async () => ({ + async *[Symbol.asyncIterator]() { + yield childSession; + }, + })), + }; + + const state = { cfg: { workspaceId: "openclaw", baseUrl, @@ -106,19 +128,9 @@ function createState(baseUrl = "https://api.honcho.dev", { crossSessionSearch = honcho: { session: vi.fn(async (sessionId: string) => createSession(sessionId)), } as never, - ownerPeer: { - id: "owner", - search: vi.fn(async () => [ - { sessionId: "session-1", content: "Need to remember this" }, - { sessionId: "session-1-child", content: "Child transcript hit" }, - { sessionId: "other-session", content: "Other result" }, - ]), - sessions: vi.fn(async () => ({ - async *[Symbol.asyncIterator]() { - yield childSession; - }, - })), - } as never, + participantPeer, + participantPeers: new Map(), + participantPeerMap: {}, agentPeers: new Map(), agentPeerMap: {}, turnStartIndex: new Map(), @@ -126,8 +138,18 @@ function createState(baseUrl = "https://api.honcho.dev", { crossSessionSearch = api: {} as never, ensureInitialized: vi.fn(async () => {}), getAgentPeer: vi.fn(async (agentId = "main") => ({ id: `agent-${agentId}` })), + getParticipantPeer: vi.fn(async () => { + if (!participantPeer) throw new Error("Honcho owner peer not initialized"); + return participantPeer; + }), + resolveSessionParticipantPeer: vi.fn(async () => { + if (!state.participantPeer) throw new Error("Honcho owner peer not initialized"); + return state.participantPeer; + }), + isParticipantPeerId: vi.fn((peerId: string) => peerId === "owner"), resolveDefaultAgentId: vi.fn(() => "main"), - } as unknown as PluginState; + } as unknown as TestState; + return state; } describe("Honcho memory runtime", () => { @@ -151,7 +173,7 @@ describe("Honcho memory runtime", () => { expect(results[0]?.snippet).toBe("Need to remember this"); expect(results[0]?.startLine).toBeGreaterThan(0); expect(results[0]?.endLine).toBeGreaterThanOrEqual(results[0]?.startLine ?? 0); - expect((state.ownerPeer.search as unknown as ReturnType)).not.toHaveBeenCalled(); + expect(state.participantPeer?.search as ReturnType).not.toHaveBeenCalled(); const implicitScopeResults = await manager.search("remember", { maxResults: 10, @@ -236,9 +258,9 @@ describe("Honcho memory runtime", () => { expect(result?.endLine).toBe(9); }); - it("fails cleanly when ownerPeer is unavailable after initialization", async () => { + it("fails cleanly when the participant peer is unavailable after initialization", async () => { const state = createState(); - state.ownerPeer = null; + state.participantPeer = null; const { manager } = await getHonchoMemorySearchManager(state, { agentId: "main", diff --git a/runtime.ts b/runtime.ts index 55c97e0..9dd35b4 100644 --- a/runtime.ts +++ b/runtime.ts @@ -35,17 +35,14 @@ async function buildSessionTranscript( sessionId: string ): Promise { await state.ensureInitialized(); - const ownerPeer = state.ownerPeer; - if (!ownerPeer) { - throw new Error("Honcho owner peer not initialized"); - } + const participantPeer = await state.resolveSessionParticipantPeer(sessionId); const agentPeer = await state.getAgentPeer(agentId); const session = await state.honcho.session(sessionId, { metadata: { agentId } }); const context = await session.context({ summary: true, tokens: 20000, - peerTarget: ownerPeer, + peerTarget: participantPeer, peerPerspective: agentPeer, }); @@ -57,11 +54,13 @@ async function buildSessionTranscript( for (const msg of context.messages ?? []) { const speaker = - msg.peerId === ownerPeer.id + msg.peerId === participantPeer.id ? "User" : msg.peerId === agentPeer.id ? `Agent(${agentId})` - : `Peer(${msg.peerId})`; + : state.isParticipantPeerId(msg.peerId) + ? `User(${msg.peerId})` + : `Peer(${msg.peerId})`; const ts = msg.createdAt ? ` ${msg.createdAt}` : ""; lines.push(`## ${speaker}${ts}`, msg.content ?? "", ""); } @@ -126,10 +125,9 @@ export async function getHonchoMemorySearchManager( manager: { async search(query: string, opts: { maxResults?: number; sessionKey?: string } = {}) { await state.ensureInitialized(); - const ownerPeer = state.ownerPeer; - if (!ownerPeer) { - throw new Error("Honcho owner peer not initialized"); - } + const participantPeer = activeSessionKey + ? await state.resolveSessionParticipantPeer(activeSessionKey) + : await state.getParticipantPeer(); const requested = Number.isFinite(opts.maxResults) ? Number(opts.maxResults) : DEFAULT_SEARCH_RESULTS; @@ -175,7 +173,7 @@ export async function getHonchoMemorySearchManager( collect(await exactSession.search(query, { limit })); if (filtered.length < limit) { - const sessions = await ownerPeer.sessions(); + const sessions = await participantPeer.sessions(); for await (const session of sessions) { if (filtered.length >= limit) break; if ( @@ -189,7 +187,7 @@ export async function getHonchoMemorySearchManager( } } } else { - collect(await ownerPeer.search(query, { limit })); + collect(await participantPeer.search(query, { limit })); } return Promise.all( diff --git a/state.ts b/state.ts index 9ea6a42..27d191e 100644 --- a/state.ts +++ b/state.ts @@ -29,10 +29,12 @@ export function isLocalHonchoBaseUrl(baseUrl?: string): boolean { export type PluginState = { honcho: Honcho; cfg: HonchoConfig; - /** Cache of resolved human peers, keyed by channel peer ID (or OWNER_ID for default). */ - humanPeers: Map; + /** Cache of resolved participant peers, keyed by channel peer ID (or OWNER_ID for default). + * "Participant" intentionally generalizes over humans AND non-agent bots/agents in group + * chats — anyone in the conversation who isn't the local OpenClaw agent peer. */ + participantPeers: Map; /** Persistent mapping of channel peer ID → honcho peer ID, stored in workspace metadata. */ - humanPeerMap: Record; + participantPeerMap: Record; agentPeers: Map; agentPeerMap: Record; /** Message count recorded at before_prompt_build time, keyed by Honcho session key. @@ -43,13 +45,13 @@ export type PluginState = { api: OpenClawPluginApi; ensureInitialized: () => Promise; getAgentPeer: (agentId?: string) => Promise; - /** Resolve a human peer by channel peer ID. Returns default "owner" peer if no ID given. */ - getHumanPeer: (channelPeerId?: string) => Promise; - /** Resolve the human peer for a session by reading humanSenderId from session metadata. + /** Resolve a participant peer by channel peer ID. Returns default "owner" peer if no ID given. */ + getParticipantPeer: (channelPeerId?: string) => Promise; + /** Resolve the participant peer for a session by reading participantSenderId from session metadata. * Falls back to default "owner" peer if no metadata found. */ - resolveSessionHumanPeer: (sessionKey: string) => Promise; - /** Returns true if the given honcho peer ID belongs to a known human peer. */ - isHumanPeerId: (peerId: string) => boolean; + resolveSessionParticipantPeer: (sessionKey: string) => Promise; + /** Returns true if the given honcho peer ID belongs to a known participant peer. */ + isParticipantPeerId: (peerId: string) => boolean; resolveDefaultAgentId: () => string; }; @@ -77,14 +79,14 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { let initPromise: Promise | null = null; // Serialize workspace metadata writes to prevent concurrent read-modify-write - // races between getHumanPeer() and getAgentPeer(). + // races between getParticipantPeer() and getAgentPeer(). let metadataWriteLock: Promise = Promise.resolve(); const state: PluginState = { honcho, cfg, - humanPeers: new Map(), - humanPeerMap: {}, + participantPeers: new Map(), + participantPeerMap: {}, agentPeers: new Map(), agentPeerMap: {}, turnStartIndex: new Map(), @@ -92,9 +94,9 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { api, ensureInitialized, getAgentPeer, - getHumanPeer, - resolveSessionHumanPeer, - isHumanPeerId, + getParticipantPeer, + resolveSessionParticipantPeer, + isParticipantPeerId, resolveDefaultAgentId, }; @@ -122,11 +124,11 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { const wsMeta = await honcho.getMetadata(); state.agentPeerMap = (wsMeta.agentPeerMap as Record) ?? {}; - state.humanPeerMap = (wsMeta.humanPeerMap as Record) ?? {}; + state.participantPeerMap = (wsMeta.participantPeerMap as Record) ?? {}; // Config mappings take precedence over workspace metadata for (const [channelId, honchoId] of Object.entries(cfg.peerMappings)) { - state.humanPeerMap[channelId] = honchoId; + state.participantPeerMap[channelId] = honchoId; } for (const [agentId, honchoId] of Object.entries(cfg.agentPeerMappings)) { state.agentPeerMap[agentId] = honchoId; @@ -135,80 +137,88 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { const defaultId = resolveDefaultAgentId(); if (Object.keys(state.agentPeerMap).length === 0) { state.agentPeerMap[defaultId] = `agent-${defaultId}`; - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, humanPeerMap: state.humanPeerMap }); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, participantPeerMap: state.participantPeerMap }); } else if (Object.values(state.agentPeerMap).includes(LEGACY_PEER_ID) && !state.agentPeerMap[defaultId]) { state.agentPeerMap[defaultId] = LEGACY_PEER_ID; - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, humanPeerMap: state.humanPeerMap }); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, participantPeerMap: state.participantPeerMap }); } // Create default "owner" peer const defaultPeer = await honcho.peer(OWNER_ID, { metadata: {} }); - state.humanPeers.set(OWNER_ID, defaultPeer); + state.participantPeers.set(OWNER_ID, defaultPeer); state.initialized = true; } - async function getHumanPeer(channelPeerId?: string): Promise { + async function getParticipantPeer(channelPeerId?: string): Promise { if (!channelPeerId) { // Return default owner peer - let peer = state.humanPeers.get(OWNER_ID); + let peer = state.participantPeers.get(OWNER_ID); if (!peer) { peer = await honcho.peer(OWNER_ID, { metadata: {} }); - state.humanPeers.set(OWNER_ID, peer); + state.participantPeers.set(OWNER_ID, peer); } return peer; } // Check cache - let peer = state.humanPeers.get(channelPeerId); + let peer = state.participantPeers.get(channelPeerId); if (peer) return peer; // Resolve honcho peer ID from mapping or use channel peer ID directly - let honchoId = state.humanPeerMap[channelPeerId]; + let honchoId = state.participantPeerMap[channelPeerId]; if (!honchoId) { honchoId = channelPeerId; // Persist auto-created mapping (serialized to prevent concurrent write races) - state.humanPeerMap[channelPeerId] = honchoId; + state.participantPeerMap[channelPeerId] = honchoId; + // Chain off `prev.catch(...)` so a failed prior write doesn't poison the + // next one, but re-expose this write's own errors via the awaited lock. const prev = metadataWriteLock; - metadataWriteLock = prev.then(async () => { + const current = prev.catch(() => undefined).then(async () => { const wsMeta = await honcho.getMetadata(); - await honcho.setMetadata({ ...wsMeta, humanPeerMap: state.humanPeerMap }); - }).catch(() => { /* errors logged elsewhere */ }); - await metadataWriteLock; - api.logger.info(`[honcho] Auto-created human peer mapping: "${channelPeerId}" → "${honchoId}"`); + await honcho.setMetadata({ ...wsMeta, participantPeerMap: state.participantPeerMap }); + }); + metadataWriteLock = current; + try { + await current; + } catch (err) { + api.logger.error( + `[honcho] Failed to persist participantPeerMap for "${channelPeerId}": ${err instanceof Error ? err.message : String(err)}` + ); + throw err; + } + api.logger.info(`[honcho] Auto-created participant peer mapping: "${channelPeerId}" → "${honchoId}"`); } peer = await honcho.peer(honchoId, { metadata: { channelPeerId } }); - state.humanPeers.set(channelPeerId, peer); + state.participantPeers.set(channelPeerId, peer); return peer; } - async function resolveSessionHumanPeer(sessionKey: string): Promise { + async function resolveSessionParticipantPeer(sessionKey: string): Promise { try { const session = await honcho.session(sessionKey); const meta = await session.getMetadata(); if (meta && typeof meta === "object") { - // Check humanSenderId (preferred) with fallback to legacy humanPeerId. - const senderId = (meta as Record).humanSenderId - ?? (meta as Record).humanPeerId; + const senderId = (meta as Record).participantSenderId; if (typeof senderId === "string" && senderId.length > 0) { - return await getHumanPeer(senderId); + return await getParticipantPeer(senderId); } } } catch { // Fall through to default } - return await getHumanPeer(); + return await getParticipantPeer(); } - function isHumanPeerId(peerId: string): boolean { + function isParticipantPeerId(peerId: string): boolean { if (peerId === OWNER_ID) return true; - // Check if this peer ID is a known human peer (either as a channel ID key or honcho ID value) - for (const [, peer] of state.humanPeers) { + // Check if this peer ID is a known participant peer (either as a channel ID key or honcho ID value) + for (const [, peer] of state.participantPeers) { if (peer.id === peerId) return true; } // Also check the mapping values - return Object.values(state.humanPeerMap).includes(peerId); + return Object.values(state.participantPeerMap).includes(peerId); } async function getAgentPeer(agentId?: string): Promise { @@ -238,12 +248,22 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { if (state.agentPeerMap[id] !== peerId) { state.agentPeerMap[id] = peerId; + // Chain off `prev.catch(...)` so a failed prior write doesn't poison the + // next one, but re-expose this write's own errors via the awaited lock. const prev = metadataWriteLock; - metadataWriteLock = prev.then(async () => { + const current = prev.catch(() => undefined).then(async () => { const wsMeta = await honcho.getMetadata(); await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); - }).catch(() => { /* errors logged elsewhere */ }); - await metadataWriteLock; + }); + metadataWriteLock = current; + try { + await current; + } catch (err) { + api.logger.error( + `[honcho] Failed to persist agentPeerMap for "${id}": ${err instanceof Error ? err.message : String(err)}` + ); + throw err; + } } peer = await honcho.peer(peerId); diff --git a/tools/ask.ts b/tools/ask.ts index 49aa046..f390df7 100644 --- a/tools/ask.ts +++ b/tools/ask.ts @@ -34,11 +34,11 @@ export function registerAskTool(api: OpenClawPluginApi, state: PluginState): voi await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(toolCtx.agentId); - const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); + const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); const reasoningLevel = depth === "thorough" ? "high" : "low"; const answer = await agentPeer.chat(query, { - target: humanPeer, + target: participantPeer, reasoningLevel, }); diff --git a/tools/context.ts b/tools/context.ts index 3c164a4..66928cd 100644 --- a/tools/context.ts +++ b/tools/context.ts @@ -27,10 +27,10 @@ export function registerContextTool(api: OpenClawPluginApi, state: PluginState): const { detail = "card" } = params as { detail?: "card" | "full" }; await state.ensureInitialized(); - const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); + const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); if (detail === "card") { - const card = await humanPeer.card().catch((err) => { + const card = await participantPeer.card().catch((err) => { // Only treat NotFoundError as empty; re-throw others or log if (err?.name === "NotFoundError") return null; // Optionally log unexpected errors for debugging @@ -62,7 +62,7 @@ export function registerContextTool(api: OpenClawPluginApi, state: PluginState): } // detail === "full" - const representation = await humanPeer.representation({ + const representation = await participantPeer.representation({ includeMostFrequent: true, }); diff --git a/tools/message-search.ts b/tools/message-search.ts index 6d7a9c5..2314789 100644 --- a/tools/message-search.ts +++ b/tools/message-search.ts @@ -91,8 +91,8 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS // Route to the appropriate search method based on `from` let messages: Message[]; if (from === "user") { - const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); - messages = await humanPeer.search(query, searchOpts); + const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); + messages = await participantPeer.search(query, searchOpts); } else if (from === "agent") { const agentPeer = await state.getAgentPeer(toolCtx.agentId); messages = await agentPeer.search(query, searchOpts); @@ -113,7 +113,7 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS } const results = messages.map((msg) => { - const speaker = state.isHumanPeerId(msg.peerId) ? "User" : "Agent"; + const speaker = state.isParticipantPeerId(msg.peerId) ? "User" : "Agent"; return { id: msg.id, content: msg.content, diff --git a/tools/search.ts b/tools/search.ts index a12c8c6..5376b48 100644 --- a/tools/search.ts +++ b/tools/search.ts @@ -41,9 +41,9 @@ export function registerSearchTool(api: OpenClawPluginApi, state: PluginState): }; await state.ensureInitialized(); - const humanPeer = await state.resolveSessionHumanPeer(buildSessionKey(toolCtx)); + const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); - const representation = await humanPeer.representation({ + const representation = await participantPeer.representation({ searchQuery: query, searchTopK: topK ?? 10, searchMaxDistance: maxDistance ?? 0.5, diff --git a/tools/session.ts b/tools/session.ts index 4f9e72d..c6ab13c 100644 --- a/tools/session.ts +++ b/tools/session.ts @@ -54,7 +54,7 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(toolCtx.agentId); const sessionKey = buildSessionKey(toolCtx); - const humanPeer = await state.resolveSessionHumanPeer(sessionKey); + const participantPeer = await state.resolveSessionParticipantPeer(sessionKey); try { const session = await state.honcho.session(sessionKey); @@ -62,7 +62,7 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): const context = await session.context({ summary: includeSummary, tokens: messageLimit, - peerTarget: humanPeer, + peerTarget: participantPeer, peerPerspective: agentPeer, searchQuery: searchQuery, }); @@ -89,7 +89,7 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): if (includeMessages && context.messages.length > 0) { const messageLines = context.messages.map((msg) => { - const speaker = state.isHumanPeerId(msg.peerId) ? "User" : "OpenClaw"; + const speaker = state.isParticipantPeerId(msg.peerId) ? "User" : "OpenClaw"; const timestamp = msg.createdAt ? new Date(msg.createdAt).toLocaleString() : ""; From 2e17e68a9818aa60cb8a52437cc54de1b64426b7 Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 15 Apr 2026 16:22:54 -0400 Subject: [PATCH 05/19] fix: removing peer mapping in favor of a more comprehensive solution --- README.md | 36 +++------------------- config.ts | 15 --------- openclaw.plugin.json | 22 -------------- runtime.test.ts | 1 - state.ts | 72 ++++++-------------------------------------- 5 files changed, 14 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 823095b..3b304a1 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,6 @@ Run `openclaw honcho setup` to configure interactively, or set values directly i | `disableDefaultNoisePatterns` | `boolean` | `false` | When `true`, built-in noise patterns are not applied — only `noisePatterns` entries are used. | | `crossSessionSearch` | `boolean` | `true` | Allow `memory_search` and `memory_get` to access any session. Set to `false` to restrict to the active session and its children. | | `ownerObserveOthers` | `boolean` | `false` | Whether the owner peer observes agent messages in Honcho's social model. | -| `peerMappings` | `object` | `{}` | Maps channel peer IDs (e.g., Slack user IDs) to Honcho peer IDs. Unmapped senders are auto-created. | -| `agentPeerMappings` | `object` | `{}` | Maps OpenClaw agent IDs to Honcho peer IDs. Default: agent `foo` gets peer ID `agent-foo`. | ### Self-Hosted / Local Honcho @@ -112,41 +110,17 @@ Honcho's `observeOthers` controls whether a peer forms representations of other Set `ownerObserveOthers: true` to let the owner peer also observe agent messages. This gives Honcho perspective-aware memory: the owner stores conclusions about the agent based only on what it witnessed, enabling the user's representation to reflect the full conversational context rather than just their own side of it. -### Peer Mappings +### Multi-Peer Participants -In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform ID from each inbound message and maps it to a Honcho peer. This gives every participant — humans and any other bots in the room — their own memory and representation in Honcho, rather than attributing all non-agent messages to a single generic peer. - -Configure `peerMappings` to map known channel peer IDs to specific Honcho peer IDs: - -```json -{ - "plugins": { - "entries": { - "openclaw-honcho": { - "config": { - "peerMappings": { - "U01ZB5DG019": "owner" - }, - "agentPeerMappings": { - "agent-slack": "demerzel" - } - } - } - } - } -} -``` +In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform ID from each inbound message and uses it directly as the Honcho peer ID. This gives every participant — humans and any other bots in the room — their own memory and representation in Honcho, rather than attributing all non-agent messages to a single generic peer. **How it works:** - The plugin reads the `sender_id` field from OpenClaw's "Conversation info (untrusted metadata):" block, which OpenClaw injects on every inbound message that has a known sender — including 1-on-1 DMs on platforms like Telegram, not just group chats. -- If the sender ID has a `peerMappings` entry, the mapped Honcho peer ID is used. -- If no mapping exists, a Honcho peer is auto-created using the channel peer ID directly (e.g., `U07KX7DG002` becomes the Honcho peer ID). +- Each distinct sender ID becomes its own Honcho peer (e.g., `U07KX7DG002` becomes the Honcho peer ID directly). - The default `owner` peer is only used as a fallback when a message has no sender metadata at all (e.g., synthetic/system messages, or channel integrations that don't emit a `Conversation info` block). On platforms like Telegram, even DMs are attributed to the sender's own peer, not `owner`. -- `agentPeerMappings` works the same way for agent peers — by default, an agent with ID `slack` gets Honcho peer ID `agent-slack`, but you can override it (e.g., to `demerzel`). +- Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, e.g., `agent-main`). - All tools (`honcho_context`, `honcho_ask`, etc.) automatically resolve the correct peer for the current session. -Auto-created peer mappings are persisted in workspace metadata so they survive gateway restarts. - **One-turn warmup for context injection in brand-new sessions.** Message *attribution* (capture) works correctly from the first turn — `extractSenderId` reads `sender_id` from each message's metadata block and routes to the right peer regardless of session state. *Context injection*, however, runs at `before_prompt_build` and looks up the session's primary participant peer via `resolveSessionParticipantPeer`, which reads `participantSenderId` from session metadata. On a brand-new session there is no session metadata yet, so the first turn's prompt context is built for the default `owner` peer. From the second turn onward (after capture writes `participantSenderId`), context is built for the resolved sender. Sessions whose channel never emits sender metadata (no `Conversation info` block) stay attributed to `owner`. ## How it works @@ -155,7 +129,7 @@ Once installed, the plugin works automatically: - **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models. Message capture starts when the plugin is active for a session, and preserves original timestamps for captured messages. Messages are also flushed before session compaction and `/new`/`/reset`, so no conversation data is lost. - **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, and `honcho_ask` to retrieve relevant context about the user. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. -- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer — mapped via `peerMappings` or auto-created from their platform ID. Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, overridable via `agentPeerMappings`). The default `owner` peer is only used as a fallback when a channel emits no sender metadata. This gives every participant isolated, personalized memory. +- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer, using their platform ID directly as the Honcho peer ID. Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`). The default `owner` peer is only used as a fallback when a channel emits no sender metadata. This gives every participant isolated, personalized memory. - **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted. Noise messages (heartbeat acks, cron boilerplate, startup commands) are dropped entirely via configurable pattern filters. Honcho handles all reasoning and synthesis in the cloud. diff --git a/config.ts b/config.ts index 77670fb..b3ae005 100644 --- a/config.ts +++ b/config.ts @@ -17,8 +17,6 @@ export type HonchoConfig = { noisePatterns: string[]; disableDefaultNoisePatterns: boolean; ownerObserveOthers: boolean; - peerMappings: Record; - agentPeerMappings: Record; crossSessionSearch: boolean; }; @@ -36,17 +34,6 @@ function resolveEnvVars(value: string): string { }); } -function parseStringRecord(raw: unknown): Record { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - const result: Record = {}; - for (const [k, v] of Object.entries(raw as Record)) { - if (typeof k === "string" && typeof v === "string" && k.length > 0 && v.length > 0) { - result[k] = v; - } - } - return result; -} - export const honchoConfigSchema = { parse(value: unknown): HonchoConfig { const cfg = (value ?? {}) as Record; @@ -93,8 +80,6 @@ export const honchoConfigSchema = { noisePatterns, disableDefaultNoisePatterns, ownerObserveOthers: typeof cfg.ownerObserveOthers === "boolean" ? cfg.ownerObserveOthers : false, - peerMappings: parseStringRecord(cfg.peerMappings), - agentPeerMappings: parseStringRecord(cfg.agentPeerMappings), crossSessionSearch: typeof cfg.crossSessionSearch === "boolean" ? cfg.crossSessionSearch : true, }; }, diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 33f9a87..5bb8d75 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -37,18 +37,6 @@ "default": false, "description": "When true, built-in noise patterns are not applied — only noisePatterns entries are used." }, - "peerMappings": { - "type": "object", - "additionalProperties": { "type": "string" }, - "default": {}, - "description": "Maps channel peer IDs (e.g., Discord user IDs) to Honcho peer IDs. Unmapped senders are auto-created using their channel peer ID." - }, - "agentPeerMappings": { - "type": "object", - "additionalProperties": { "type": "string" }, - "default": {}, - "description": "Maps OpenClaw agent IDs to Honcho peer IDs. By default, agent 'foo' gets peer ID 'agent-foo'." - }, "crossSessionSearch": { "type": "boolean", "default": true, @@ -94,16 +82,6 @@ "advanced": true, "help": "When enabled, only custom noisePatterns entries are used — built-in defaults are skipped." }, - "peerMappings": { - "label": "Peer Mappings", - "advanced": true, - "help": "Maps channel peer IDs (e.g., Discord user IDs) to Honcho peer IDs. Unmapped senders are auto-created." - }, - "agentPeerMappings": { - "label": "Agent Peer Mappings", - "advanced": true, - "help": "Maps OpenClaw agent IDs to custom Honcho peer IDs. Default: agent 'foo' → peer 'agent-foo'." - }, "crossSessionSearch": { "label": "Cross-Session Search", "advanced": true, diff --git a/runtime.test.ts b/runtime.test.ts index 2890dce..7114af3 100644 --- a/runtime.test.ts +++ b/runtime.test.ts @@ -130,7 +130,6 @@ function createState(baseUrl = "https://api.honcho.dev", { crossSessionSearch = } as never, participantPeer, participantPeers: new Map(), - participantPeerMap: {}, agentPeers: new Map(), agentPeerMap: {}, turnStartIndex: new Map(), diff --git a/state.ts b/state.ts index 27d191e..186fa45 100644 --- a/state.ts +++ b/state.ts @@ -33,8 +33,6 @@ export type PluginState = { * "Participant" intentionally generalizes over humans AND non-agent bots/agents in group * chats — anyone in the conversation who isn't the local OpenClaw agent peer. */ participantPeers: Map; - /** Persistent mapping of channel peer ID → honcho peer ID, stored in workspace metadata. */ - participantPeerMap: Record; agentPeers: Map; agentPeerMap: Record; /** Message count recorded at before_prompt_build time, keyed by Honcho session key. @@ -78,15 +76,10 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { // workspace metadata. Errors propagate to all waiters. let initPromise: Promise | null = null; - // Serialize workspace metadata writes to prevent concurrent read-modify-write - // races between getParticipantPeer() and getAgentPeer(). - let metadataWriteLock: Promise = Promise.resolve(); - const state: PluginState = { honcho, cfg, participantPeers: new Map(), - participantPeerMap: {}, agentPeers: new Map(), agentPeerMap: {}, turnStartIndex: new Map(), @@ -124,23 +117,14 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { const wsMeta = await honcho.getMetadata(); state.agentPeerMap = (wsMeta.agentPeerMap as Record) ?? {}; - state.participantPeerMap = (wsMeta.participantPeerMap as Record) ?? {}; - - // Config mappings take precedence over workspace metadata - for (const [channelId, honchoId] of Object.entries(cfg.peerMappings)) { - state.participantPeerMap[channelId] = honchoId; - } - for (const [agentId, honchoId] of Object.entries(cfg.agentPeerMappings)) { - state.agentPeerMap[agentId] = honchoId; - } const defaultId = resolveDefaultAgentId(); if (Object.keys(state.agentPeerMap).length === 0) { state.agentPeerMap[defaultId] = `agent-${defaultId}`; - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, participantPeerMap: state.participantPeerMap }); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); } else if (Object.values(state.agentPeerMap).includes(LEGACY_PEER_ID) && !state.agentPeerMap[defaultId]) { state.agentPeerMap[defaultId] = LEGACY_PEER_ID; - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap, participantPeerMap: state.participantPeerMap }); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); } // Create default "owner" peer @@ -165,32 +149,9 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { let peer = state.participantPeers.get(channelPeerId); if (peer) return peer; - // Resolve honcho peer ID from mapping or use channel peer ID directly - let honchoId = state.participantPeerMap[channelPeerId]; - if (!honchoId) { - honchoId = channelPeerId; - // Persist auto-created mapping (serialized to prevent concurrent write races) - state.participantPeerMap[channelPeerId] = honchoId; - // Chain off `prev.catch(...)` so a failed prior write doesn't poison the - // next one, but re-expose this write's own errors via the awaited lock. - const prev = metadataWriteLock; - const current = prev.catch(() => undefined).then(async () => { - const wsMeta = await honcho.getMetadata(); - await honcho.setMetadata({ ...wsMeta, participantPeerMap: state.participantPeerMap }); - }); - metadataWriteLock = current; - try { - await current; - } catch (err) { - api.logger.error( - `[honcho] Failed to persist participantPeerMap for "${channelPeerId}": ${err instanceof Error ? err.message : String(err)}` - ); - throw err; - } - api.logger.info(`[honcho] Auto-created participant peer mapping: "${channelPeerId}" → "${honchoId}"`); - } - - peer = await honcho.peer(honchoId, { metadata: { channelPeerId } }); + // Use the channel peer ID directly as the Honcho peer ID — each participant + // is its own separate peer. + peer = await honcho.peer(channelPeerId, { metadata: { channelPeerId } }); state.participantPeers.set(channelPeerId, peer); return peer; } @@ -213,12 +174,11 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { function isParticipantPeerId(peerId: string): boolean { if (peerId === OWNER_ID) return true; - // Check if this peer ID is a known participant peer (either as a channel ID key or honcho ID value) + // Check if this peer ID is a known participant peer for (const [, peer] of state.participantPeers) { if (peer.id === peerId) return true; } - // Also check the mapping values - return Object.values(state.participantPeerMap).includes(peerId); + return false; } async function getAgentPeer(agentId?: string): Promise { @@ -248,22 +208,8 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { if (state.agentPeerMap[id] !== peerId) { state.agentPeerMap[id] = peerId; - // Chain off `prev.catch(...)` so a failed prior write doesn't poison the - // next one, but re-expose this write's own errors via the awaited lock. - const prev = metadataWriteLock; - const current = prev.catch(() => undefined).then(async () => { - const wsMeta = await honcho.getMetadata(); - await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); - }); - metadataWriteLock = current; - try { - await current; - } catch (err) { - api.logger.error( - `[honcho] Failed to persist agentPeerMap for "${id}": ${err instanceof Error ? err.message : String(err)}` - ); - throw err; - } + const wsMeta = await honcho.getMetadata(); + await honcho.setMetadata({ ...wsMeta, agentPeerMap: state.agentPeerMap }); } peer = await honcho.peer(peerId); From 7cb869df26cf7bf55d92ce0b75cf572661469aa9 Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 15 Apr 2026 16:46:31 -0400 Subject: [PATCH 06/19] fix: extract sender_id from inbound message in before_prompt_build --- README.md | 2 +- hooks/context.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3b304a1..f809e2b 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform - Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, e.g., `agent-main`). - All tools (`honcho_context`, `honcho_ask`, etc.) automatically resolve the correct peer for the current session. -**One-turn warmup for context injection in brand-new sessions.** Message *attribution* (capture) works correctly from the first turn — `extractSenderId` reads `sender_id` from each message's metadata block and routes to the right peer regardless of session state. *Context injection*, however, runs at `before_prompt_build` and looks up the session's primary participant peer via `resolveSessionParticipantPeer`, which reads `participantSenderId` from session metadata. On a brand-new session there is no session metadata yet, so the first turn's prompt context is built for the default `owner` peer. From the second turn onward (after capture writes `participantSenderId`), context is built for the resolved sender. Sessions whose channel never emits sender metadata (no `Conversation info` block) stay attributed to `owner`. +Both message *attribution* (capture) and *context injection* (`before_prompt_build`) read `sender_id` directly from the current inbound message's metadata block, so the right participant peer is used from the very first turn — and on every turn in group chats, even when the speaker changes between turns. Sessions whose channel never emits sender metadata (no `Conversation info` block) stay attributed to `owner`. ## How it works diff --git a/hooks/context.ts b/hooks/context.ts index 28083a7..7b762a8 100644 --- a/hooks/context.ts +++ b/hooks/context.ts @@ -1,7 +1,7 @@ // @ts-ignore - resolved by openclaw runtime import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { PluginState } from "../state.js"; -import { buildSessionKey, isSubagentSession } from "../helpers.js"; +import { buildSessionKey, extractSenderId, isSubagentSession } from "../helpers.js"; export function registerContextHook(api: OpenClawPluginApi, state: PluginState): void { api.on("before_prompt_build", async (event, ctx) => { @@ -16,7 +16,14 @@ export function registerContextHook(api: OpenClawPluginApi, state: PluginState): try { await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(agentId); - const participantPeer = await state.resolveSessionParticipantPeer(sessionKey); + // Prefer the sender of the current inbound message — capture has not + // run yet for this turn, so session metadata still reflects the previous + // speaker. In group chats this would otherwise build context against the + // prior participant's representation whenever the speaker changes. + const currentSenderId = extractSenderId(event.prompt); + const participantPeer = currentSenderId + ? await state.getParticipantPeer(currentSenderId) + : await state.resolveSessionParticipantPeer(sessionKey); const sections: string[] = []; From a25ef7e27dc3958483261908c705e84aca558fa3 Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 15 Apr 2026 18:15:04 -0400 Subject: [PATCH 07/19] feat: adding multi-peer support --- CHANGELOG.md | 26 +++++++++++++++++++++++ README.md | 30 +++++++++++++++++++++++++-- config.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ config.ts | 25 ++++++++++++++++++++++ openclaw.plugin.json | 10 +++++++++ package.json | 2 +- state.ts | 9 ++++---- 7 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd3d63..1518be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to `@honcho-ai/openclaw-honcho` will be documented in this file. +## [1.4.0] - 2026-04-15 + +Minor version bump: introduces per-sender participant peers for group-chat +scenarios. Single-user deployments behave the same — messages without a +`sender_id` still flow to the default owner peer. + +### Added +- **Multi-peer support for group chats (#68)**: User messages now carry per-sender attribution. `extractSenderId` parses `sender_id` (falling back to `sender`) from the leading `Conversation info (untrusted metadata):` block of an inbound message, and capture resolves a distinct Honcho peer per sender. Each participant is its own peer (the channel peer ID is used directly as the Honcho peer ID). Messages without a `sender_id` continue to go to the default owner peer, so DMs are unaffected. +- **`-p, --peer ` flag on `honcho ask` and `honcho search` CLI commands**: Target a specific participant peer when querying (defaults to owner). Useful for group-chat deployments where the operator wants to inspect memory from the perspective of a single participant. +- **Manual `peerMappings` config**: A `sender_id` → Honcho peer ID map in the plugin config. Unmapped senders still use their `sender_id` directly (no behavior change), but operators can alias platform IDs to friendlier peer IDs (or merge two channel identities onto one peer). Edits to `openclaw.json` take effect on gateway restart; the plugin does not write the file itself. See `README.md` § Peer Mappings. +- **`crossSessionSearch` in the plugin manifest (`openclaw.plugin.json`)**: The option existed in code since 1.3.0 but was missing from `configSchema`/`uiHints`, causing OpenClaw config validation to reject it. Now declared with `default: true`. +- **Session metadata fields `participantSenderId` and `participantSenderIds`**: Capture records the last active sender (for default tool resolution) and the full set of known senders in the session. Named "sender" (not "peer") to keep raw channel IDs distinguishable from resolved Honcho peer IDs. +- **`extractSenderId` unit tests (`helpers.test.ts`)**: Cover the happy path, missing blocks (DMs), malformed JSON, duplicate sentinels (user-pasted metadata), and `sender_id`/`sender` fallback. + +### Changed +- **`ownerPeer` → participant peer abstraction**: `state.ownerPeer` is replaced by a `participantPeers: Map` cache plus `getParticipantPeer(channelPeerId?)`, `resolveSessionParticipantPeer(sessionKey)`, and `isParticipantPeerId(peerId)` helpers. Callers that previously reached for `state.ownerPeer!` now resolve through these helpers. The "participant" naming intentionally generalizes over humans and non-agent bots. +- **Context hook (`before_prompt_build`) uses the inbound message's sender**: Previously it relied on session metadata, which still reflects the prior speaker on turn start. `extractSenderId(event.prompt)` is now consulted first so context is built against the *current* speaker's representation — the prior behavior silently mis-targeted context in group chats whenever the speaker changed. +- **Capture resolves participant peers in parallel**: `Promise.all` over the unique sender IDs in a batch, avoiding a sequential await bottleneck in group chats. +- **`extractMessages` signature**: Takes a `defaultParticipantPeer` plus an optional `resolvePeer(senderId)` callback instead of a single `ownerPeer`. Callers outside of capture (tests, future consumers) can pass no resolver to preserve pre-1.4.0 behavior. + +### Fixed +- **Concurrent `ensureInitialized()` races could corrupt workspace metadata**: Init is now guarded by a shared promise so concurrent hook entries await the same initialization; errors propagate to all waiters. + +### Migration Notes +Historical owner-attributed messages in existing sessions are **not** rewritten. After upgrade, context queries against pre-existing sessions will show attribution mixed across the upgrade boundary — e.g., `owner said X, then real-user Y said Z` — because older turns retain their original owner attribution while new turns use per-sender peer IDs. This is acceptable; operators running multi-participant deployments should be aware. + ## [1.3.2] - 2026-04-09 ### Added diff --git a/README.md b/README.md index f809e2b..d92c6b4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Run `openclaw honcho setup` to configure interactively, or set values directly i | `disableDefaultNoisePatterns` | `boolean` | `false` | When `true`, built-in noise patterns are not applied — only `noisePatterns` entries are used. | | `crossSessionSearch` | `boolean` | `true` | Allow `memory_search` and `memory_get` to access any session. Set to `false` to restrict to the active session and its children. | | `ownerObserveOthers` | `boolean` | `false` | Whether the owner peer observes agent messages in Honcho's social model. | +| `peerMappings` | `object` | `{}` | Manual `sender_id` → Honcho peer ID map (see [Peer Mappings](#peer-mappings)). | ### Self-Hosted / Local Honcho @@ -110,13 +111,38 @@ Honcho's `observeOthers` controls whether a peer forms representations of other Set `ownerObserveOthers: true` to let the owner peer also observe agent messages. This gives Honcho perspective-aware memory: the owner stores conclusions about the agent based only on what it witnessed, enabling the user's representation to reflect the full conversational context rather than just their own side of it. +### Peer Mappings + +By default, each inbound `sender_id` is used directly as the Honcho peer ID. If you want to alias a platform-specific sender ID to a friendlier Honcho peer (or merge two channel identities into one peer), add a `peerMappings` entry to the plugin config: + +```json +{ + "plugins": { + "entries": { + "openclaw-honcho": { + "config": { + "peerMappings": { + "U01ZB5DG019": "abigail", + "telegram-8461078551": "abigail" + } + } + } + } + } +} +``` + +- **Manual only.** Edits to `peerMappings` take effect on gateway restart (`openclaw gateway restart`). The plugin does not write back to `openclaw.json`. +- **Unmapped senders pass through.** If a `sender_id` has no entry, it is used as the Honcho peer ID unchanged — matching the default behavior. +- **Adding a mapping after messages exist splits history.** Messages already stored under the raw `sender_id` stay there; new messages land under the mapped peer. Remapping is most useful when set up before the peer accumulates history. + ### Multi-Peer Participants In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform ID from each inbound message and uses it directly as the Honcho peer ID. This gives every participant — humans and any other bots in the room — their own memory and representation in Honcho, rather than attributing all non-agent messages to a single generic peer. **How it works:** - The plugin reads the `sender_id` field from OpenClaw's "Conversation info (untrusted metadata):" block, which OpenClaw injects on every inbound message that has a known sender — including 1-on-1 DMs on platforms like Telegram, not just group chats. -- Each distinct sender ID becomes its own Honcho peer (e.g., `U07KX7DG002` becomes the Honcho peer ID directly). +- Each distinct sender ID becomes its own Honcho peer (e.g., `U07KX7DG002` becomes the Honcho peer ID directly). You can alias a sender to a friendlier peer ID via [`peerMappings`](#peer-mappings). - The default `owner` peer is only used as a fallback when a message has no sender metadata at all (e.g., synthetic/system messages, or channel integrations that don't emit a `Conversation info` block). On platforms like Telegram, even DMs are attributed to the sender's own peer, not `owner`. - Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, e.g., `agent-main`). - All tools (`honcho_context`, `honcho_ask`, etc.) automatically resolve the correct peer for the current session. @@ -129,7 +155,7 @@ Once installed, the plugin works automatically: - **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models. Message capture starts when the plugin is active for a session, and preserves original timestamps for captured messages. Messages are also flushed before session compaction and `/new`/`/reset`, so no conversation data is lost. - **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, and `honcho_ask` to retrieve relevant context about the user. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. -- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer, using their platform ID directly as the Honcho peer ID. Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`). The default `owner` peer is only used as a fallback when a channel emits no sender metadata. This gives every participant isolated, personalized memory. +- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer, using their platform ID directly as the Honcho peer ID (or aliased via [`peerMappings`](#peer-mappings) if configured). Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`). The default `owner` peer is only used as a fallback when a channel emits no sender metadata. This gives every participant isolated, personalized memory. - **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted. Noise messages (heartbeat acks, cron boilerplate, startup commands) are dropped entirely via configurable pattern filters. Honcho handles all reasoning and synthesis in the cloud. diff --git a/config.test.ts b/config.test.ts new file mode 100644 index 0000000..07272fb --- /dev/null +++ b/config.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { honchoConfigSchema } from "./config.js"; + +describe("honchoConfigSchema.peerMappings", () => { + it("defaults to an empty object when absent", () => { + const cfg = honchoConfigSchema.parse({}); + expect(cfg.peerMappings).toEqual({}); + }); + + it("passes through valid string→string entries", () => { + const cfg = honchoConfigSchema.parse({ + peerMappings: { + U01ZB5DG019: "abigail", + "telegram-8461078551": "abigail", + }, + }); + expect(cfg.peerMappings).toEqual({ + U01ZB5DG019: "abigail", + "telegram-8461078551": "abigail", + }); + }); + + it("trims keys and values", () => { + const cfg = honchoConfigSchema.parse({ + peerMappings: { " U-foo ": " alice " }, + }); + expect(cfg.peerMappings).toEqual({ "U-foo": "alice" }); + }); + + it("drops malformed entries (non-string values, empty strings)", () => { + const cfg = honchoConfigSchema.parse({ + peerMappings: { + good: "peer-ok", + empty: "", + whitespace: " ", + numeric: 42, + nullish: null, + "": "orphaned-key", + }, + }); + expect(cfg.peerMappings).toEqual({ good: "peer-ok" }); + }); + + it("ignores non-object peerMappings values", () => { + expect(honchoConfigSchema.parse({ peerMappings: "nope" }).peerMappings).toEqual({}); + expect(honchoConfigSchema.parse({ peerMappings: [] }).peerMappings).toEqual({}); + expect(honchoConfigSchema.parse({ peerMappings: null }).peerMappings).toEqual({}); + }); +}); diff --git a/config.ts b/config.ts index b3ae005..e77c660 100644 --- a/config.ts +++ b/config.ts @@ -18,6 +18,10 @@ export type HonchoConfig = { disableDefaultNoisePatterns: boolean; ownerObserveOthers: boolean; crossSessionSearch: boolean; + /** Manual mapping from inbound channel sender_id → Honcho peer ID. + * Edited by hand in openclaw.json; re-read on gateway restart. + * Unmapped sender_ids fall through to using the sender_id directly. */ + peerMappings: Record; }; /** @@ -81,6 +85,27 @@ export const honchoConfigSchema = { disableDefaultNoisePatterns, ownerObserveOthers: typeof cfg.ownerObserveOthers === "boolean" ? cfg.ownerObserveOthers : false, crossSessionSearch: typeof cfg.crossSessionSearch === "boolean" ? cfg.crossSessionSearch : true, + peerMappings: parsePeerMappings(cfg.peerMappings), }; }, }; + +/** + * Coerce an unknown value into a sender_id → peer_id record, dropping any + * entries whose key or value is not a non-empty trimmed string. Returns {} + * when the input is missing or not a plain object. + */ +function parsePeerMappings(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (typeof k !== "string") continue; + const key = k.trim(); + if (!key) continue; + if (typeof v !== "string") continue; + const val = v.trim(); + if (!val) continue; + out[key] = val; + } + return out; +} diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 5bb8d75..b611bb8 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -41,6 +41,11 @@ "type": "boolean", "default": true, "description": "When true (default), memory_search/memory_get can return results from any session in the workspace. When false, results are scoped to the active session and its child sessions only." + }, + "peerMappings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Manual map of inbound sender_id → Honcho peer ID. Unmapped senders use their sender_id directly. Edits require a gateway restart to take effect." } } }, @@ -86,6 +91,11 @@ "label": "Cross-Session Search", "advanced": true, "help": "When enabled (default), memory tools can return results from any session in the workspace. Disable to scope results to the active session only." + }, + "peerMappings": { + "label": "Peer Mappings", + "advanced": true, + "help": "Manual sender_id → Honcho peer ID mapping. Unmapped senders use their sender_id directly. Restart the gateway after edits." } } } diff --git a/package.json b/package.json index 5105748..2af9c34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@honcho-ai/openclaw-honcho", - "version": "1.3.2", + "version": "1.4.0", "type": "module", "description": "Honcho AI-native memory integration for OpenClaw", "main": "dist/index.js", diff --git a/state.ts b/state.ts index 186fa45..bc0a58c 100644 --- a/state.ts +++ b/state.ts @@ -145,13 +145,14 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { return peer; } - // Check cache + // Cache is keyed by the inbound sender_id so lookups don't depend on + // whether the user has added/changed a mapping. The mapped Honcho peer + // ID (or the sender_id itself, when unmapped) is what's sent to the SDK. let peer = state.participantPeers.get(channelPeerId); if (peer) return peer; - // Use the channel peer ID directly as the Honcho peer ID — each participant - // is its own separate peer. - peer = await honcho.peer(channelPeerId, { metadata: { channelPeerId } }); + const mappedPeerId = cfg.peerMappings[channelPeerId] ?? channelPeerId; + peer = await honcho.peer(mappedPeerId, { metadata: { channelPeerId } }); state.participantPeers.set(channelPeerId, peer); return peer; } From 96f3ca29e8742265afa39ff199863507e036b670 Mon Sep 17 00:00:00 2001 From: ajspig Date: Fri, 24 Apr 2026 16:59:25 -0400 Subject: [PATCH 08/19] fix: adding peer mappings to ~./honcho --- config.test.ts | 49 ----------- config.ts | 25 ------ openclaw.plugin.json | 10 --- peers.test.ts | 198 +++++++++++++++++++++++++++++++++++++++++++ peers.ts | 160 ++++++++++++++++++++++++++++++++++ state.ts | 50 ++++++++--- 6 files changed, 394 insertions(+), 98 deletions(-) delete mode 100644 config.test.ts create mode 100644 peers.test.ts create mode 100644 peers.ts diff --git a/config.test.ts b/config.test.ts deleted file mode 100644 index 07272fb..0000000 --- a/config.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { honchoConfigSchema } from "./config.js"; - -describe("honchoConfigSchema.peerMappings", () => { - it("defaults to an empty object when absent", () => { - const cfg = honchoConfigSchema.parse({}); - expect(cfg.peerMappings).toEqual({}); - }); - - it("passes through valid string→string entries", () => { - const cfg = honchoConfigSchema.parse({ - peerMappings: { - U01ZB5DG019: "abigail", - "telegram-8461078551": "abigail", - }, - }); - expect(cfg.peerMappings).toEqual({ - U01ZB5DG019: "abigail", - "telegram-8461078551": "abigail", - }); - }); - - it("trims keys and values", () => { - const cfg = honchoConfigSchema.parse({ - peerMappings: { " U-foo ": " alice " }, - }); - expect(cfg.peerMappings).toEqual({ "U-foo": "alice" }); - }); - - it("drops malformed entries (non-string values, empty strings)", () => { - const cfg = honchoConfigSchema.parse({ - peerMappings: { - good: "peer-ok", - empty: "", - whitespace: " ", - numeric: 42, - nullish: null, - "": "orphaned-key", - }, - }); - expect(cfg.peerMappings).toEqual({ good: "peer-ok" }); - }); - - it("ignores non-object peerMappings values", () => { - expect(honchoConfigSchema.parse({ peerMappings: "nope" }).peerMappings).toEqual({}); - expect(honchoConfigSchema.parse({ peerMappings: [] }).peerMappings).toEqual({}); - expect(honchoConfigSchema.parse({ peerMappings: null }).peerMappings).toEqual({}); - }); -}); diff --git a/config.ts b/config.ts index e77c660..b3ae005 100644 --- a/config.ts +++ b/config.ts @@ -18,10 +18,6 @@ export type HonchoConfig = { disableDefaultNoisePatterns: boolean; ownerObserveOthers: boolean; crossSessionSearch: boolean; - /** Manual mapping from inbound channel sender_id → Honcho peer ID. - * Edited by hand in openclaw.json; re-read on gateway restart. - * Unmapped sender_ids fall through to using the sender_id directly. */ - peerMappings: Record; }; /** @@ -85,27 +81,6 @@ export const honchoConfigSchema = { disableDefaultNoisePatterns, ownerObserveOthers: typeof cfg.ownerObserveOthers === "boolean" ? cfg.ownerObserveOthers : false, crossSessionSearch: typeof cfg.crossSessionSearch === "boolean" ? cfg.crossSessionSearch : true, - peerMappings: parsePeerMappings(cfg.peerMappings), }; }, }; - -/** - * Coerce an unknown value into a sender_id → peer_id record, dropping any - * entries whose key or value is not a non-empty trimmed string. Returns {} - * when the input is missing or not a plain object. - */ -function parsePeerMappings(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (typeof k !== "string") continue; - const key = k.trim(); - if (!key) continue; - if (typeof v !== "string") continue; - const val = v.trim(); - if (!val) continue; - out[key] = val; - } - return out; -} diff --git a/openclaw.plugin.json b/openclaw.plugin.json index b611bb8..5bb8d75 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -41,11 +41,6 @@ "type": "boolean", "default": true, "description": "When true (default), memory_search/memory_get can return results from any session in the workspace. When false, results are scoped to the active session and its child sessions only." - }, - "peerMappings": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "Manual map of inbound sender_id → Honcho peer ID. Unmapped senders use their sender_id directly. Edits require a gateway restart to take effect." } } }, @@ -91,11 +86,6 @@ "label": "Cross-Session Search", "advanced": true, "help": "When enabled (default), memory tools can return results from any session in the workspace. Disable to scope results to the active session only." - }, - "peerMappings": { - "label": "Peer Mappings", - "advanced": true, - "help": "Manual sender_id → Honcho peer ID mapping. Unmapped senders use their sender_id directly. Restart the gateway after edits." } } } diff --git a/peers.test.ts b/peers.test.ts new file mode 100644 index 0000000..d3c03a5 --- /dev/null +++ b/peers.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + PEERS_FILE_VERSION, + PeersPersister, + loadPeersFile, + loadPeersFileSync, + resolvePeersFilePath, + resolveParticipantPeerId, +} from "./peers.js"; + +async function mktmp(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "openclaw-peers-")); +} + +describe("resolvePeersFilePath", () => { + const originalEnv = process.env.OPENCLAW_HONCHO_PEERS_FILE; + + afterEach(() => { + if (originalEnv === undefined) delete process.env.OPENCLAW_HONCHO_PEERS_FILE; + else process.env.OPENCLAW_HONCHO_PEERS_FILE = originalEnv; + }); + + it("defaults to ~/.honcho/openclaw-peers.json", () => { + delete process.env.OPENCLAW_HONCHO_PEERS_FILE; + expect(resolvePeersFilePath()).toBe(path.join(os.homedir(), ".honcho", "openclaw-peers.json")); + }); + + it("respects OPENCLAW_HONCHO_PEERS_FILE override", () => { + process.env.OPENCLAW_HONCHO_PEERS_FILE = "/tmp/custom-peers.json"; + expect(resolvePeersFilePath()).toBe("/tmp/custom-peers.json"); + }); + + it("trims whitespace on the override", () => { + process.env.OPENCLAW_HONCHO_PEERS_FILE = " /tmp/ws.json "; + expect(resolvePeersFilePath()).toBe("/tmp/ws.json"); + }); + + it("ignores empty-string overrides", () => { + process.env.OPENCLAW_HONCHO_PEERS_FILE = ""; + expect(resolvePeersFilePath()).toBe(path.join(os.homedir(), ".honcho", "openclaw-peers.json")); + }); +}); + +describe("loadPeersFile", () => { + it("returns an empty seed when the file is missing", async () => { + const dir = await mktmp(); + const missing = path.join(dir, "does", "not", "exist.json"); + await expect(loadPeersFile(missing)).resolves.toEqual({ + version: PEERS_FILE_VERSION, + peers: {}, + }); + expect(loadPeersFileSync(missing)).toEqual({ + version: PEERS_FILE_VERSION, + peers: {}, + }); + }); + + it("reads a well-formed file", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + await fs.writeFile( + file, + JSON.stringify({ version: 1, peers: { "slack:U1": "owner", "slack:U2": "alice" } }), + ); + await expect(loadPeersFile(file)).resolves.toEqual({ + version: 1, + peers: { "slack:U1": "owner", "slack:U2": "alice" }, + }); + }); + + it("tolerates malformed JSON by returning empty", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + await fs.writeFile(file, "{ not valid json"); + await expect(loadPeersFile(file)).resolves.toEqual({ + version: PEERS_FILE_VERSION, + peers: {}, + }); + }); +}); + +describe("resolveParticipantPeerId", () => { + function persister(initial: Record = {}) { + return new PeersPersister("/dev/null", { + version: PEERS_FILE_VERSION, + peers: { ...initial }, + }); + } + + it("returns the mapped peer for a known sender", () => { + const p = persister({ "slack:U1": "alice" }); + expect(resolveParticipantPeerId("slack:U1", p)).toBe("alice"); + }); + + it("returns owner (no enqueue) for a sender auto-seeded to owner", () => { + const p = persister({ "slack:U2": "owner" }); + const enqueueSpy = vi.spyOn(p, "enqueue"); + expect(resolveParticipantPeerId("slack:U2", p)).toBe("owner"); + expect(enqueueSpy).not.toHaveBeenCalled(); + }); + + it("enqueues unknown senders and returns owner", () => { + const p = persister({}); + const enqueueSpy = vi.spyOn(p, "enqueue"); + expect(resolveParticipantPeerId("slack:U3", p)).toBe("owner"); + expect(enqueueSpy).toHaveBeenCalledWith("slack:U3", "owner"); + expect(p.peers["slack:U3"]).toBe("owner"); + }); +}); + +describe("PeersPersister", () => { + it("enqueue is idempotent per sender", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + const p = new PeersPersister(file, { + version: PEERS_FILE_VERSION, + peers: {}, + }); + p.enqueue("slack:U1"); + p.enqueue("slack:U1"); + p.enqueue("slack:U1"); + expect(Object.keys(p.peers)).toEqual(["slack:U1"]); + }); + + it("does not overwrite an existing mapping on enqueue", () => { + const p = new PeersPersister("/dev/null", { + version: PEERS_FILE_VERSION, + peers: { "slack:U1": "alice" }, + }); + p.enqueue("slack:U1"); + expect(p.peers["slack:U1"]).toBe("alice"); + }); + + it("coalesces 3 enqueues within the debounce window into one file write", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + const writeSpy = vi.spyOn(fs, "writeFile"); + const p = new PeersPersister( + file, + { version: PEERS_FILE_VERSION, peers: {} }, + { debounceMs: 50 }, + ); + + p.enqueue("slack:U1"); + p.enqueue("slack:U2"); + p.enqueue("slack:U3"); + + expect(writeSpy).not.toHaveBeenCalled(); + + await p.flushNow(); + expect(writeSpy).toHaveBeenCalledTimes(1); + + await p.flushNow(); + expect(writeSpy).toHaveBeenCalledTimes(1); + writeSpy.mockRestore(); + + const body = JSON.parse(await fs.readFile(file, "utf8")); + expect(body).toEqual({ + version: 1, + peers: { + "slack:U1": "owner", + "slack:U2": "owner", + "slack:U3": "owner", + }, + }); + }); + + it("creates the peers file on first flush when missing at boot", async () => { + const dir = await mktmp(); + const file = path.join(dir, "nested", "peers.json"); + const loaded = loadPeersFileSync(file); + expect(loaded).toEqual({ version: PEERS_FILE_VERSION, peers: {} }); + + const p = new PeersPersister(file, loaded, { debounceMs: 10 }); + p.enqueue("slack:Unew"); + await p.flushNow(); + + const body = JSON.parse(await fs.readFile(file, "utf8")); + expect(body).toEqual({ + version: 1, + peers: { "slack:Unew": "owner" }, + }); + }); + + it("flushNow is a no-op when nothing is dirty", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + const p = new PeersPersister(file, { + version: PEERS_FILE_VERSION, + peers: {}, + }); + await p.flushNow(); + await expect(fs.access(file)).rejects.toBeTruthy(); + }); +}); diff --git a/peers.ts b/peers.ts new file mode 100644 index 0000000..5122de9 --- /dev/null +++ b/peers.ts @@ -0,0 +1,160 @@ +/** + * Sender_id → Honcho peer_id map, persisted at ~/.honcho/openclaw-peers.json. + * + * Shared artifact: + * - Plugin auto-seeds unknown senders to OWNER_ID on first sight. + * - User hand-edits to split specific senders off to their own peer IDs. + * + * Re-read on gateway restart. Path override: OPENCLAW_HONCHO_PEERS_FILE. + */ + +import { promises as fs, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const PEERS_FILE_VERSION = 1; + +export type PeersFile = { + version: typeof PEERS_FILE_VERSION; + peers: Record; +}; + +export function resolvePeersFilePath(): string { + const envPath = process.env.OPENCLAW_HONCHO_PEERS_FILE; + if (envPath && envPath.trim().length > 0) return envPath.trim(); + return path.join(os.homedir(), ".honcho", "openclaw-peers.json"); +} + +function parsePeersJson(raw: string): PeersFile { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const obj = parsed as Record; + const peers = + obj.peers && typeof obj.peers === "object" && !Array.isArray(obj.peers) + ? coerceStringMap(obj.peers as Record) + : {}; + return { version: PEERS_FILE_VERSION, peers }; + } + return { version: PEERS_FILE_VERSION, peers: {} }; +} + +/** + * Read the peers file; return an empty seed if missing or malformed. + * Does not create the file — that happens on the first flush. + */ +export async function loadPeersFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return parsePeersJson(raw); + } catch { + return { version: PEERS_FILE_VERSION, peers: {} }; + } +} + +/** Synchronous variant for bootstrapping at plugin-state construction time. */ +export function loadPeersFileSync(filePath: string): PeersFile { + try { + const raw = readFileSync(filePath, "utf8"); + return parsePeersJson(raw); + } catch { + return { version: PEERS_FILE_VERSION, peers: {} }; + } +} + +function coerceStringMap(value: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (typeof v === "string" && v.length > 0) out[k] = v; + } + return out; +} + +/** + * Resolve an inbound sender_id to a Honcho peer ID. If the sender is already + * known (hand-mapped or previously auto-seeded), return its mapping; otherwise + * enqueue it as OWNER_ID and return OWNER_ID. + */ +export function resolveParticipantPeerId( + senderId: string, + persister: PeersPersister, + defaultPeerId = "owner", +): string { + const mapped = persister.peers[senderId]; + if (mapped !== undefined) return mapped; + persister.enqueue(senderId, defaultPeerId); + return defaultPeerId; +} + +export type PeersPersisterOptions = { + /** Flush debounce window in milliseconds. Default 1000. */ + debounceMs?: number; +}; + +/** + * Debounced, serialized writer for the peers file. + * + * enqueue() is synchronous — it mutates the in-memory map and schedules a + * background flush. Multiple enqueues within the debounce window coalesce + * into a single write. Flushes are chained via a single promise so writes + * cannot interleave. + * + * Note: the file is user-editable, but edits made at runtime are not picked + * up until the gateway restarts. Existing entries are never overwritten by + * enqueue(), so hand-edits to known senders survive; new senders added by + * hand could be clobbered if the plugin sees them before the restart. + */ +export class PeersPersister { + public readonly peers: Record; + private readonly filePath: string; + private readonly debounceMs: number; + private dirty = false; + private timer: ReturnType | null = null; + private chain: Promise = Promise.resolve(); + + constructor(filePath: string, initial: PeersFile, opts: PeersPersisterOptions = {}) { + this.filePath = filePath; + this.peers = { ...initial.peers }; + this.debounceMs = opts.debounceMs ?? 1000; + } + + /** + * Record a sender_id → peer_id mapping if absent. Schedules a debounced flush. + */ + enqueue(senderId: string, peerId = "owner"): void { + if (!senderId) return; + if (this.peers[senderId] !== undefined) return; + this.peers[senderId] = peerId; + this.dirty = true; + if (this.timer) return; + this.timer = setTimeout(() => { + this.timer = null; + this.chain = this.chain.then(() => this.flush()).catch(() => undefined); + }, this.debounceMs); + // unref so the timer doesn't block process exit in short-lived runs. + this.timer.unref?.(); + } + + /** + * Flush any pending changes immediately, awaiting completion. Safe to call + * concurrently with enqueue(). + */ + async flushNow(): Promise { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.chain = this.chain.then(() => this.flush()).catch(() => undefined); + await this.chain; + } + + private async flush(): Promise { + if (!this.dirty) return; + this.dirty = false; + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const body: PeersFile = { + version: PEERS_FILE_VERSION, + peers: this.peers, + }; + await fs.writeFile(this.filePath, JSON.stringify(body, null, 2) + "\n"); + } +} diff --git a/state.ts b/state.ts index bc0a58c..538d520 100644 --- a/state.ts +++ b/state.ts @@ -8,6 +8,12 @@ import { Honcho, type Peer } from "@honcho-ai/sdk"; // @ts-ignore - resolved by openclaw runtime import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { honchoConfigSchema, type HonchoConfig } from "./config.js"; +import { + PeersPersister, + loadPeersFileSync, + resolvePeersFilePath, + resolveParticipantPeerId, +} from "./peers.js"; export const OWNER_ID = "owner"; export const LEGACY_PEER_ID = "openclaw"; @@ -43,6 +49,10 @@ export type PluginState = { api: OpenClawPluginApi; ensureInitialized: () => Promise; getAgentPeer: (agentId?: string) => Promise; + /** Sender_id → Honcho peer_id map, backed by ~/.honcho/openclaw-peers.json. + * Unknown senders are auto-seeded to OWNER_ID; the user hand-edits the file + * to split specific senders off to their own peer IDs. */ + peersPersister: PeersPersister; /** Resolve a participant peer by channel peer ID. Returns default "owner" peer if no ID given. */ getParticipantPeer: (channelPeerId?: string) => Promise; /** Resolve the participant peer for a session by reading participantSenderId from session metadata. @@ -71,6 +81,12 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { timeout: cfg.timeoutMs, }); + const peersFilePath = resolvePeersFilePath(); + const peersPersister = new PeersPersister( + peersFilePath, + loadPeersFileSync(peersFilePath), + ); + // Promise-based init lock to prevent concurrent ensureInitialized() races. // Without this, two concurrent hooks entering init simultaneously can corrupt // workspace metadata. Errors propagate to all waiters. @@ -85,6 +101,7 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { turnStartIndex: new Map(), initialized: false, api, + peersPersister, ensureInitialized, getAgentPeer, getParticipantPeer, @@ -134,25 +151,30 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { state.initialized = true; } + async function ensureOwnerPeer(): Promise { + let peer = state.participantPeers.get(OWNER_ID); + if (peer) return peer; + peer = await honcho.peer(OWNER_ID, { metadata: {} }); + state.participantPeers.set(OWNER_ID, peer); + return peer; + } + async function getParticipantPeer(channelPeerId?: string): Promise { - if (!channelPeerId) { - // Return default owner peer - let peer = state.participantPeers.get(OWNER_ID); - if (!peer) { - peer = await honcho.peer(OWNER_ID, { metadata: {} }); - state.participantPeers.set(OWNER_ID, peer); - } - return peer; - } + if (!channelPeerId) return ensureOwnerPeer(); - // Cache is keyed by the inbound sender_id so lookups don't depend on - // whether the user has added/changed a mapping. The mapped Honcho peer - // ID (or the sender_id itself, when unmapped) is what's sent to the SDK. + // Known senders resolve via the peers file (plugin auto-seeds unknown + // senders to OWNER_ID; user hand-edits to split them off). Unknown + // senders enqueue for persistence and fall back to owner. let peer = state.participantPeers.get(channelPeerId); if (peer) return peer; - const mappedPeerId = cfg.peerMappings[channelPeerId] ?? channelPeerId; - peer = await honcho.peer(mappedPeerId, { metadata: { channelPeerId } }); + const resolvedPeerId = resolveParticipantPeerId(channelPeerId, peersPersister, OWNER_ID); + + if (resolvedPeerId === OWNER_ID) { + peer = await ensureOwnerPeer(); + } else { + peer = await honcho.peer(resolvedPeerId, { metadata: { channelPeerId } }); + } state.participantPeers.set(channelPeerId, peer); return peer; } From d430714f379259b683b816119055345f175a3771 Mon Sep 17 00:00:00 2001 From: ajspig Date: Fri, 24 Apr 2026 17:00:09 -0400 Subject: [PATCH 09/19] fix: add peer mapping to gateway log --- hooks/gateway.ts | 7 ++++++- peers.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/hooks/gateway.ts b/hooks/gateway.ts index f672a95..b9df356 100644 --- a/hooks/gateway.ts +++ b/hooks/gateway.ts @@ -7,7 +7,12 @@ export function registerGatewayHook(api: OpenClawPluginApi, state: PluginState): api.logger.info("Initializing Honcho memory..."); try { await state.ensureInitialized(); - api.logger.info("Honcho memory ready"); + const { filePath, peers } = state.peersPersister; + api.logger.info( + `Honcho memory ready — peer map: ${filePath} (${Object.keys(peers).length} known sender${ + Object.keys(peers).length === 1 ? "" : "s" + })`, + ); } catch (error) { api.logger.error(`Failed to initialize Honcho: ${error}`); } diff --git a/peers.ts b/peers.ts index 5122de9..3d2f3f8 100644 --- a/peers.ts +++ b/peers.ts @@ -105,7 +105,7 @@ export type PeersPersisterOptions = { */ export class PeersPersister { public readonly peers: Record; - private readonly filePath: string; + public readonly filePath: string; private readonly debounceMs: number; private dirty = false; private timer: ReturnType | null = null; From 5805aa2f73eb2cff0ff1d3d579149159e0aa316f Mon Sep 17 00:00:00 2001 From: ajspig Date: Fri, 24 Apr 2026 17:30:11 -0400 Subject: [PATCH 10/19] chore: language cleanup --- README.md | 4 ++-- helpers.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d92c6b4..4a3a290 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ By default, each inbound `sender_id` is used directly as the Honcho peer ID. If "openclaw-honcho": { "config": { "peerMappings": { - "U01ZB5DG019": "abigail", - "telegram-8461078551": "abigail" + "U0EXAMPLE01": "user", + "telegram-1234567890": "user" } } } diff --git a/helpers.test.ts b/helpers.test.ts index 1d10663..06578a9 100644 --- a/helpers.test.ts +++ b/helpers.test.ts @@ -15,12 +15,12 @@ function metadataBlock(payload: Record): string { describe("extractSenderId", () => { it("reads sender_id from a leading metadata block", () => { const content = [ - metadataBlock({ sender_id: "U01ZB5DG019", channel: "C-foo" }), + metadataBlock({ sender_id: "U0EXAMPLE01", channel: "C-foo" }), "", "hello there", ].join("\n"); - expect(extractSenderId(content)).toBe("U01ZB5DG019"); + expect(extractSenderId(content)).toBe("U0EXAMPLE01"); }); it("trusts only the first sentinel and never considers later quoted blocks", () => { From e03518a0151549477f60d71e8920d9769bb792ff Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 16:28:59 -0400 Subject: [PATCH 11/19] fix: owner_id fallback for legacy and new installs --- CHANGELOG.md | 31 +++++++--------- peers.test.ts | 92 +++++++++++++++++++++++++++++++++-------------- peers.ts | 98 +++++++++++++++++++++++++++++++-------------------- state.ts | 12 ++++--- 4 files changed, 145 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e86ae..691b01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,29 +4,22 @@ All notable changes to `@honcho-ai/openclaw-honcho` will be documented in this f ## [1.4.0] - 2026-04-15 -Minor version bump: introduces per-sender participant peers for group-chat -scenarios. Single-user deployments behave the same — messages without a -`sender_id` still flow to the default owner peer. +Per-sender participant peers when the channel emits `sender_id` (group chats +and many 1:1s — see README). No sender metadata → `owner` peer (operator/system +fallback: CLI runs, webchat-direct, cron, etc.). Routing controlled by +`defaultUnknownPolicy` in `~/.honcho/openclaw-peers.json`: existing installs +default to `"owner"` (pre-1.4.0 merge behavior); fresh installs default to +`"per-sender"`. ### Added -- **Multi-peer support for group chats (#68)**: User messages now carry per-sender attribution. `extractSenderId` parses `sender_id` (falling back to `sender`) from the leading `Conversation info (untrusted metadata):` block of an inbound message, and capture resolves a distinct Honcho peer per sender. Each participant is its own peer (the channel peer ID is used directly as the Honcho peer ID). Messages without a `sender_id` continue to go to the default owner peer, so DMs are unaffected. -- **`-p, --peer ` flag on `honcho ask` and `honcho search` CLI commands**: Target a specific participant peer when querying (defaults to owner). Useful for group-chat deployments where the operator wants to inspect memory from the perspective of a single participant. -- **Manual `peerMappings` config**: A `sender_id` → Honcho peer ID map in the plugin config. Unmapped senders still use their `sender_id` directly (no behavior change), but operators can alias platform IDs to friendlier peer IDs (or merge two channel identities onto one peer). Edits to `openclaw.json` take effect on gateway restart; the plugin does not write the file itself. See `README.md` (Peer Mappings). -- **`crossSessionSearch` in the plugin manifest (`openclaw.plugin.json`)**: The option existed in code since 1.3.0 but was missing from `configSchema`/`uiHints`, causing OpenClaw config validation to reject it. Now declared with `default: true`. -- **Session metadata fields `participantSenderId` and `participantSenderIds`**: Capture records the last active sender (for default tool resolution) and the full set of known senders in the session. Named "sender" (not "peer") to keep raw channel IDs distinguishable from resolved Honcho peer IDs. -- **`extractSenderId` unit tests (`helpers.test.ts`)**: Cover the happy path, missing blocks (DMs), malformed JSON, duplicate sentinels (user-pasted metadata), and `sender_id`/`sender` fallback. - -### Changed -- **`ownerPeer` → participant peer abstraction**: `state.ownerPeer` is replaced by a `participantPeers: Map` cache plus `getParticipantPeer(channelPeerId?)`, `resolveSessionParticipantPeer(sessionKey)`, and `isParticipantPeerId(peerId)` helpers. Callers that previously reached for `state.ownerPeer!` now resolve through these helpers. The "participant" naming intentionally generalizes over humans and non-agent bots. -- **Context hook (`before_prompt_build`) uses the inbound message's sender**: Previously it relied on session metadata, which still reflects the prior speaker on turn start. `extractSenderId(event.prompt)` is now consulted first so context is built against the *current* speaker's representation — the prior behavior silently mis-targeted context in group chats whenever the speaker changed. -- **Capture resolves participant peers in parallel**: `Promise.all` over the unique sender IDs in a batch, avoiding a sequential await bottleneck in group chats. -- **`extractMessages` signature**: Takes a `defaultParticipantPeer` plus an optional `resolvePeer(senderId)` callback instead of a single `ownerPeer`. Callers outside of capture (tests, future consumers) can pass no resolver to preserve pre-1.4.0 behavior. +- **Multi-peer**: `extractSenderId` parses `sender_id` from each inbound `Conversation info` block; both capture and `before_prompt_build` resolve a peer per sender from the current message, so the right participant is targeted on every turn — including when the speaker changes between turns. Peer IDs derive from the channel ID, sanitized to `[A-Za-z0-9_-]` and truncated to Honcho's 100-char limit. Switching `defaultUnknownPolicy` after the fact only affects new messages — already-captured messages stay where they originally landed. +- **`-p, --peer ` on `honcho ask` and `honcho search`**: query memory from a specific participant's perspective. +- **Peers file `~/.honcho/openclaw-peers.json`**: `sender_id` → peer ID map, auto-seeded by the plugin and hand-editable. Top-level `defaultUnknownPolicy` (`per-sender` for fresh installs, `owner` for legacy files without the field) controls auto-seeding; auto-seeded peers get `autoSeeded: true` metadata. Override path via `OPENCLAW_HONCHO_PEERS_FILE`. +- **Session metadata `participantSenderId` / `participantSenderIds`**: last active sender + all known senders in the session. ### Fixed -- **Concurrent `ensureInitialized()` races could corrupt workspace metadata**: Init is now guarded by a shared promise so concurrent hook entries await the same initialization; errors propagate to all waiters. - -### Migration Notes -Historical owner-attributed messages in existing sessions are **not** rewritten. After upgrade, context queries against pre-existing sessions will show attribution mixed across the upgrade boundary — e.g., `owner said X, then real-user Y said Z` — because older turns retain their original owner attribution while new turns use per-sender peer IDs. This is acceptable; operators running multi-participant deployments should be aware. +- **`ensureInitialized()` race could corrupt workspace metadata**: now guarded by a shared init promise. +- **`crossSessionSearch` rejected by OpenClaw config validation**: option existed in code since 1.3.0 but was missing from the plugin manifest's `configSchema`/`uiHints`. Now declared. ## [1.3.3] - 2026-04-16 diff --git a/peers.test.ts b/peers.test.ts index d3c03a5..eb26b18 100644 --- a/peers.test.ts +++ b/peers.test.ts @@ -45,20 +45,22 @@ describe("resolvePeersFilePath", () => { }); describe("loadPeersFile", () => { - it("returns an empty seed when the file is missing", async () => { + it("returns a per-sender seed when the file is missing (fresh install)", async () => { const dir = await mktmp(); const missing = path.join(dir, "does", "not", "exist.json"); await expect(loadPeersFile(missing)).resolves.toEqual({ version: PEERS_FILE_VERSION, + defaultUnknownPolicy: "per-sender", peers: {}, }); expect(loadPeersFileSync(missing)).toEqual({ version: PEERS_FILE_VERSION, + defaultUnknownPolicy: "per-sender", peers: {}, }); }); - it("reads a well-formed file", async () => { + it("reads a legacy file (no policy field) as owner-policy", async () => { const dir = await mktmp(); const file = path.join(dir, "peers.json"); await fs.writeFile( @@ -67,25 +69,45 @@ describe("loadPeersFile", () => { ); await expect(loadPeersFile(file)).resolves.toEqual({ version: 1, + defaultUnknownPolicy: "owner", peers: { "slack:U1": "owner", "slack:U2": "alice" }, }); }); - it("tolerates malformed JSON by returning empty", async () => { + it("respects an explicit defaultUnknownPolicy field", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + await fs.writeFile( + file, + JSON.stringify({ version: 1, defaultUnknownPolicy: "per-sender", peers: {} }), + ); + await expect(loadPeersFile(file)).resolves.toEqual({ + version: 1, + defaultUnknownPolicy: "per-sender", + peers: {}, + }); + }); + + it("treats malformed JSON in an existing file as legacy (owner) — never silently upgrades", async () => { const dir = await mktmp(); const file = path.join(dir, "peers.json"); await fs.writeFile(file, "{ not valid json"); await expect(loadPeersFile(file)).resolves.toEqual({ version: PEERS_FILE_VERSION, + defaultUnknownPolicy: "owner", peers: {}, }); }); }); describe("resolveParticipantPeerId", () => { - function persister(initial: Record = {}) { + function persister( + initial: Record = {}, + defaultUnknownPolicy: "owner" | "per-sender" = "owner", + ) { return new PeersPersister("/dev/null", { version: PEERS_FILE_VERSION, + defaultUnknownPolicy, peers: { ...initial }, }); } @@ -95,30 +117,48 @@ describe("resolveParticipantPeerId", () => { expect(resolveParticipantPeerId("slack:U1", p)).toBe("alice"); }); - it("returns owner (no enqueue) for a sender auto-seeded to owner", () => { + it("returns owner (no enqueue) for a sender already mapped to owner", () => { const p = persister({ "slack:U2": "owner" }); const enqueueSpy = vi.spyOn(p, "enqueue"); expect(resolveParticipantPeerId("slack:U2", p)).toBe("owner"); expect(enqueueSpy).not.toHaveBeenCalled(); }); - it("enqueues unknown senders and returns owner", () => { - const p = persister({}); - const enqueueSpy = vi.spyOn(p, "enqueue"); + it("under owner policy: enqueues unknown senders as owner", () => { + const p = persister({}, "owner"); expect(resolveParticipantPeerId("slack:U3", p)).toBe("owner"); - expect(enqueueSpy).toHaveBeenCalledWith("slack:U3", "owner"); expect(p.peers["slack:U3"]).toBe("owner"); }); + + it("under per-sender policy: derives a sanitized peer ID from the sender_id", () => { + const p = persister({}, "per-sender"); + expect(resolveParticipantPeerId("slack:U07A.bot@team", p)).toBe("slack_U07A_bot_team"); + expect(p.peers["slack:U07A.bot@team"]).toBe("slack_U07A_bot_team"); + }); + + it("under per-sender policy: hand-mapped owner mappings still resolve to owner", () => { + const p = persister({ "slack:U5": "owner" }, "per-sender"); + expect(resolveParticipantPeerId("slack:U5", p)).toBe("owner"); + }); + + it("under per-sender policy: truncates to fit Honcho's 100-char peer ID limit", () => { + const p = persister({}, "per-sender"); + const long = "x".repeat(200); + const id = resolveParticipantPeerId(long, p); + expect(id.length).toBeLessThanOrEqual(100); + expect(/^[a-zA-Z0-9_-]+$/.test(id)).toBe(true); + }); }); describe("PeersPersister", () => { + function emptyFile(defaultUnknownPolicy: "owner" | "per-sender" = "owner") { + return { version: PEERS_FILE_VERSION, defaultUnknownPolicy, peers: {} } as const; + } + it("enqueue is idempotent per sender", async () => { const dir = await mktmp(); const file = path.join(dir, "peers.json"); - const p = new PeersPersister(file, { - version: PEERS_FILE_VERSION, - peers: {}, - }); + const p = new PeersPersister(file, emptyFile()); p.enqueue("slack:U1"); p.enqueue("slack:U1"); p.enqueue("slack:U1"); @@ -128,6 +168,7 @@ describe("PeersPersister", () => { it("does not overwrite an existing mapping on enqueue", () => { const p = new PeersPersister("/dev/null", { version: PEERS_FILE_VERSION, + defaultUnknownPolicy: "owner", peers: { "slack:U1": "alice" }, }); p.enqueue("slack:U1"); @@ -138,11 +179,7 @@ describe("PeersPersister", () => { const dir = await mktmp(); const file = path.join(dir, "peers.json"); const writeSpy = vi.spyOn(fs, "writeFile"); - const p = new PeersPersister( - file, - { version: PEERS_FILE_VERSION, peers: {} }, - { debounceMs: 50 }, - ); + const p = new PeersPersister(file, emptyFile("owner"), { debounceMs: 50 }); p.enqueue("slack:U1"); p.enqueue("slack:U2"); @@ -160,6 +197,7 @@ describe("PeersPersister", () => { const body = JSON.parse(await fs.readFile(file, "utf8")); expect(body).toEqual({ version: 1, + defaultUnknownPolicy: "owner", peers: { "slack:U1": "owner", "slack:U2": "owner", @@ -168,30 +206,32 @@ describe("PeersPersister", () => { }); }); - it("creates the peers file on first flush when missing at boot", async () => { + it("creates the peers file on first flush when missing at boot (fresh install → per-sender)", async () => { const dir = await mktmp(); const file = path.join(dir, "nested", "peers.json"); const loaded = loadPeersFileSync(file); - expect(loaded).toEqual({ version: PEERS_FILE_VERSION, peers: {} }); + expect(loaded).toEqual({ + version: PEERS_FILE_VERSION, + defaultUnknownPolicy: "per-sender", + peers: {}, + }); const p = new PeersPersister(file, loaded, { debounceMs: 10 }); - p.enqueue("slack:Unew"); + p.enqueue("slack:Unew", "slack_Unew"); await p.flushNow(); const body = JSON.parse(await fs.readFile(file, "utf8")); expect(body).toEqual({ version: 1, - peers: { "slack:Unew": "owner" }, + defaultUnknownPolicy: "per-sender", + peers: { "slack:Unew": "slack_Unew" }, }); }); it("flushNow is a no-op when nothing is dirty", async () => { const dir = await mktmp(); const file = path.join(dir, "peers.json"); - const p = new PeersPersister(file, { - version: PEERS_FILE_VERSION, - peers: {}, - }); + const p = new PeersPersister(file, emptyFile()); await p.flushNow(); await expect(fs.access(file)).rejects.toBeTruthy(); }); diff --git a/peers.ts b/peers.ts index 3d2f3f8..3994ef0 100644 --- a/peers.ts +++ b/peers.ts @@ -1,11 +1,15 @@ /** * Sender_id → Honcho peer_id map, persisted at ~/.honcho/openclaw-peers.json. * - * Shared artifact: - * - Plugin auto-seeds unknown senders to OWNER_ID on first sight. - * - User hand-edits to split specific senders off to their own peer IDs. + * Plugin auto-seeds unknown senders per `defaultUnknownPolicy`: + * "owner" → strangers merge into the OWNER_ID peer (legacy contract). + * "per-sender" → each stranger gets a peer derived from its sender_id + * (sanitized + truncated to satisfy Honcho's RESOURCE_NAME_PATTERN). + * Fresh installs (file missing) default to "per-sender"; pre-existing files + * (no policy field) keep "owner" so legacy users see no behavior change. * - * Re-read on gateway restart. Path override: OPENCLAW_HONCHO_PEERS_FILE. + * User can hand-edit to remap any sender. Re-read on gateway restart. + * Path override: OPENCLAW_HONCHO_PEERS_FILE. */ import { promises as fs, readFileSync } from "node:fs"; @@ -13,9 +17,14 @@ import os from "node:os"; import path from "node:path"; export const PEERS_FILE_VERSION = 1; +/** Honcho enforces RESOURCE_NAME_PATTERN = ^[a-zA-Z0-9_-]+$ with 1..100 length on peer IDs. */ +const HONCHO_PEER_ID_MAX_LEN = 100; + +export type DefaultUnknownPolicy = "owner" | "per-sender"; export type PeersFile = { version: typeof PEERS_FILE_VERSION; + defaultUnknownPolicy: DefaultUnknownPolicy; peers: Record; }; @@ -26,38 +35,45 @@ export function resolvePeersFilePath(): string { } function parsePeersJson(raw: string): PeersFile { - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - const obj = parsed as Record; - const peers = - obj.peers && typeof obj.peers === "object" && !Array.isArray(obj.peers) - ? coerceStringMap(obj.peers as Record) - : {}; - return { version: PEERS_FILE_VERSION, peers }; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const obj = parsed as Record; + const peers = + obj.peers && typeof obj.peers === "object" && !Array.isArray(obj.peers) + ? coerceStringMap(obj.peers as Record) + : {}; + return { + version: PEERS_FILE_VERSION, + defaultUnknownPolicy: obj.defaultUnknownPolicy === "per-sender" ? "per-sender" : "owner", + peers, + }; + } + } catch { + // fall through } - return { version: PEERS_FILE_VERSION, peers: {} }; + // Existing-but-malformed file → preserve legacy contract. + return { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "owner", peers: {} }; } -/** - * Read the peers file; return an empty seed if missing or malformed. - * Does not create the file — that happens on the first flush. - */ +/** Read the peers file. Missing → per-sender (fresh install); anything else → owner (legacy). */ export async function loadPeersFile(filePath: string): Promise { try { - const raw = await fs.readFile(filePath, "utf8"); - return parsePeersJson(raw); - } catch { - return { version: PEERS_FILE_VERSION, peers: {} }; + return parsePeersJson(await fs.readFile(filePath, "utf8")); + } catch (err) { + return (err as NodeJS.ErrnoException)?.code === "ENOENT" + ? { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "per-sender", peers: {} } + : { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "owner", peers: {} }; } } -/** Synchronous variant for bootstrapping at plugin-state construction time. */ export function loadPeersFileSync(filePath: string): PeersFile { try { - const raw = readFileSync(filePath, "utf8"); - return parsePeersJson(raw); - } catch { - return { version: PEERS_FILE_VERSION, peers: {} }; + return parsePeersJson(readFileSync(filePath, "utf8")); + } catch (err) { + return (err as NodeJS.ErrnoException)?.code === "ENOENT" + ? { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "per-sender", peers: {} } + : { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "owner", peers: {} }; } } @@ -70,19 +86,25 @@ function coerceStringMap(value: Record): Record } /** - * Resolve an inbound sender_id to a Honcho peer ID. If the sender is already - * known (hand-mapped or previously auto-seeded), return its mapping; otherwise - * enqueue it as OWNER_ID and return OWNER_ID. + * Resolve an inbound sender_id to a Honcho peer ID. Known senders return their + * mapping; unknown senders auto-seed under the persister's policy and enqueue + * for persistence. Per-sender peer IDs are derived from the sender_id — + * sanitized to satisfy Honcho's RESOURCE_NAME_PATTERN and truncated to its + * 100-char limit. */ export function resolveParticipantPeerId( senderId: string, persister: PeersPersister, - defaultPeerId = "owner", + ownerPeerId = "owner", ): string { const mapped = persister.peers[senderId]; if (mapped !== undefined) return mapped; - persister.enqueue(senderId, defaultPeerId); - return defaultPeerId; + const seedPeerId = + persister.defaultUnknownPolicy === "per-sender" + ? senderId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, HONCHO_PEER_ID_MAX_LEN) + : ownerPeerId; + persister.enqueue(senderId, seedPeerId); + return seedPeerId; } export type PeersPersisterOptions = { @@ -106,6 +128,7 @@ export type PeersPersisterOptions = { export class PeersPersister { public readonly peers: Record; public readonly filePath: string; + public readonly defaultUnknownPolicy: DefaultUnknownPolicy; private readonly debounceMs: number; private dirty = false; private timer: ReturnType | null = null; @@ -114,12 +137,11 @@ export class PeersPersister { constructor(filePath: string, initial: PeersFile, opts: PeersPersisterOptions = {}) { this.filePath = filePath; this.peers = { ...initial.peers }; + this.defaultUnknownPolicy = initial.defaultUnknownPolicy; this.debounceMs = opts.debounceMs ?? 1000; } - /** - * Record a sender_id → peer_id mapping if absent. Schedules a debounced flush. - */ + /** Record a sender_id → peer_id mapping if absent. Schedules a debounced flush. */ enqueue(senderId: string, peerId = "owner"): void { if (!senderId) return; if (this.peers[senderId] !== undefined) return; @@ -134,10 +156,7 @@ export class PeersPersister { this.timer.unref?.(); } - /** - * Flush any pending changes immediately, awaiting completion. Safe to call - * concurrently with enqueue(). - */ + /** Flush any pending changes immediately. Safe to call concurrently with enqueue(). */ async flushNow(): Promise { if (this.timer) { clearTimeout(this.timer); @@ -153,6 +172,7 @@ export class PeersPersister { await fs.mkdir(path.dirname(this.filePath), { recursive: true }); const body: PeersFile = { version: PEERS_FILE_VERSION, + defaultUnknownPolicy: this.defaultUnknownPolicy, peers: this.peers, }; await fs.writeFile(this.filePath, JSON.stringify(body, null, 2) + "\n"); diff --git a/state.ts b/state.ts index 538d520..1434aa7 100644 --- a/state.ts +++ b/state.ts @@ -162,18 +162,22 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { async function getParticipantPeer(channelPeerId?: string): Promise { if (!channelPeerId) return ensureOwnerPeer(); - // Known senders resolve via the peers file (plugin auto-seeds unknown - // senders to OWNER_ID; user hand-edits to split them off). Unknown - // senders enqueue for persistence and fall back to owner. + // Known senders resolve via the peers file. Unknown senders auto-seed + // per the persister's defaultUnknownPolicy: legacy installs merge into + // OWNER_ID; fresh installs mint a distinct participant- peer. let peer = state.participantPeers.get(channelPeerId); if (peer) return peer; + const wasInFile = channelPeerId in peersPersister.peers; const resolvedPeerId = resolveParticipantPeerId(channelPeerId, peersPersister, OWNER_ID); + const autoSeeded = !wasInFile && resolvedPeerId !== OWNER_ID; if (resolvedPeerId === OWNER_ID) { peer = await ensureOwnerPeer(); } else { - peer = await honcho.peer(resolvedPeerId, { metadata: { channelPeerId } }); + const metadata: Record = { channelPeerId }; + if (autoSeeded) metadata.autoSeeded = true; + peer = await honcho.peer(resolvedPeerId, { metadata }); } state.participantPeers.set(channelPeerId, peer); return peer; From a6314e441d46bfc1ecf0e764c79dd74d29ef1c5a Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 16:37:33 -0400 Subject: [PATCH 12/19] fix: cr suggestion, harden Honcho error handling --- peers.ts | 24 +++++++++++++++--------- state.ts | 16 ++++++---------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/peers.ts b/peers.ts index 3994ef0..1825546 100644 --- a/peers.ts +++ b/peers.ts @@ -162,19 +162,25 @@ export class PeersPersister { clearTimeout(this.timer); this.timer = null; } - this.chain = this.chain.then(() => this.flush()).catch(() => undefined); - await this.chain; + const flushed = this.chain.then(() => this.flush()); + this.chain = flushed.catch(() => undefined); + await flushed; } private async flush(): Promise { if (!this.dirty) return; this.dirty = false; - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - const body: PeersFile = { - version: PEERS_FILE_VERSION, - defaultUnknownPolicy: this.defaultUnknownPolicy, - peers: this.peers, - }; - await fs.writeFile(this.filePath, JSON.stringify(body, null, 2) + "\n"); + try { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const body: PeersFile = { + version: PEERS_FILE_VERSION, + defaultUnknownPolicy: this.defaultUnknownPolicy, + peers: this.peers, + }; + await fs.writeFile(this.filePath, JSON.stringify(body, null, 2) + "\n"); + } catch (err) { + this.dirty = true; + throw err; + } } } diff --git a/state.ts b/state.ts index 1434aa7..615de64 100644 --- a/state.ts +++ b/state.ts @@ -184,17 +184,13 @@ export function createPluginState(api: OpenClawPluginApi): PluginState { } async function resolveSessionParticipantPeer(sessionKey: string): Promise { - try { - const session = await honcho.session(sessionKey); - const meta = await session.getMetadata(); - if (meta && typeof meta === "object") { - const senderId = (meta as Record).participantSenderId; - if (typeof senderId === "string" && senderId.length > 0) { - return await getParticipantPeer(senderId); - } + const session = await honcho.session(sessionKey); + const meta = await session.getMetadata(); + if (meta && typeof meta === "object") { + const senderId = (meta as Record).participantSenderId; + if (typeof senderId === "string" && senderId.length > 0) { + return await getParticipantPeer(senderId); } - } catch { - // Fall through to default } return await getParticipantPeer(); } From 03f6dc25b424fd29ba106548a386b6a99621dd45 Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 16:44:08 -0400 Subject: [PATCH 13/19] docs: update readme --- README.md | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4a3a290..191a2a9 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ Run `openclaw honcho setup` to configure interactively, or set values directly i | `disableDefaultNoisePatterns` | `boolean` | `false` | When `true`, built-in noise patterns are not applied — only `noisePatterns` entries are used. | | `crossSessionSearch` | `boolean` | `true` | Allow `memory_search` and `memory_get` to access any session. Set to `false` to restrict to the active session and its children. | | `ownerObserveOthers` | `boolean` | `false` | Whether the owner peer observes agent messages in Honcho's social model. | -| `peerMappings` | `object` | `{}` | Manual `sender_id` → Honcho peer ID map (see [Peer Mappings](#peer-mappings)). | ### Self-Hosted / Local Honcho @@ -113,28 +112,24 @@ Set `ownerObserveOthers: true` to let the owner peer also observe agent messages ### Peer Mappings -By default, each inbound `sender_id` is used directly as the Honcho peer ID. If you want to alias a platform-specific sender ID to a friendlier Honcho peer (or merge two channel identities into one peer), add a `peerMappings` entry to the plugin config: +The plugin maintains a `sender_id` → Honcho peer ID map at `~/.honcho/openclaw-peers.json` (override the path with `OPENCLAW_HONCHO_PEERS_FILE`). The file is auto-seeded as new senders appear, hand-editable, and re-read on gateway restart. To alias a platform-specific sender ID to a friendlier Honcho peer (or merge two channel identities into one), edit the `peers` map: ```json { - "plugins": { - "entries": { - "openclaw-honcho": { - "config": { - "peerMappings": { - "U0EXAMPLE01": "user", - "telegram-1234567890": "user" - } - } - } - } + "version": 1, + "defaultUnknownPolicy": "per-sender", + "peers": { + "U0EXAMPLE01": "user", + "telegram-1234567890": "user" } } ``` -- **Manual only.** Edits to `peerMappings` take effect on gateway restart (`openclaw gateway restart`). The plugin does not write back to `openclaw.json`. -- **Unmapped senders pass through.** If a `sender_id` has no entry, it is used as the Honcho peer ID unchanged — matching the default behavior. -- **Adding a mapping after messages exist splits history.** Messages already stored under the raw `sender_id` stay there; new messages land under the mapped peer. Remapping is most useful when set up before the peer accumulates history. +- **`defaultUnknownPolicy`** controls how unknown `sender_id`s are seeded into `peers`: + - `per-sender` — default for fresh installs. Each new sender becomes its own peer; the seeded peer ID is the `sender_id` sanitized to `[A-Za-z0-9_-]` and truncated to Honcho's 100-char limit. + - `owner` — default for pre-existing files missing the field (preserves legacy behavior). All unknown senders merge into the owner peer. +- **Auto-seeded, manually overridable.** The plugin only inserts entries for senders not already in the map, so hand-edits to known senders survive. Edits take effect on gateway restart (`openclaw gateway restart`). +- **Adding a mapping after messages exist splits history.** Messages already stored under the original peer stay there; new messages land under the new peer. Remap before the peer accumulates history. ### Multi-Peer Participants @@ -142,8 +137,8 @@ In group chats (Discord, Slack, etc.), the plugin extracts the sender's platform **How it works:** - The plugin reads the `sender_id` field from OpenClaw's "Conversation info (untrusted metadata):" block, which OpenClaw injects on every inbound message that has a known sender — including 1-on-1 DMs on platforms like Telegram, not just group chats. -- Each distinct sender ID becomes its own Honcho peer (e.g., `U07KX7DG002` becomes the Honcho peer ID directly). You can alias a sender to a friendlier peer ID via [`peerMappings`](#peer-mappings). -- The default `owner` peer is only used as a fallback when a message has no sender metadata at all (e.g., synthetic/system messages, or channel integrations that don't emit a `Conversation info` block). On platforms like Telegram, even DMs are attributed to the sender's own peer, not `owner`. +- Each distinct sender ID becomes its own Honcho peer (e.g., `U07KX7DG002` becomes the Honcho peer ID directly, sanitized to `[A-Za-z0-9_-]`). You can alias a sender to a friendlier peer ID by editing the [peers file](#peer-mappings). +- The default `owner` peer is used as a fallback when a message has no sender metadata at all (e.g., synthetic/system messages, or channel integrations that don't emit a `Conversation info` block), and — on legacy installs whose peers file uses `defaultUnknownPolicy: "owner"` — for any unknown sender. On fresh installs (`per-sender` policy) and platforms like Telegram, even DMs are attributed to the sender's own peer, not `owner`. - Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`, e.g., `agent-main`). - All tools (`honcho_context`, `honcho_ask`, etc.) automatically resolve the correct peer for the current session. @@ -155,7 +150,7 @@ Once installed, the plugin works automatically: - **Message Observation** — After every AI turn, the conversation is persisted to Honcho. Both user and agent messages are observed, allowing Honcho to build and refine its models. Message capture starts when the plugin is active for a session, and preserves original timestamps for captured messages. Messages are also flushed before session compaction and `/new`/`/reset`, so no conversation data is lost. - **Tool-Based Context Access** — The AI can query Honcho mid-conversation using tools like `honcho_context`, `honcho_search_conclusions`, and `honcho_ask` to retrieve relevant context about the user. Context is injected during OpenClaw's `before_prompt_build` phase, ensuring accurate turn boundaries. -- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer, using their platform ID directly as the Honcho peer ID (or aliased via [`peerMappings`](#peer-mappings) if configured). Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`). The default `owner` peer is only used as a fallback when a channel emits no sender metadata. This gives every participant isolated, personalized memory. +- **Multi-Peer Model** — Honcho maintains separate representations for each participant. Whenever an inbound message carries a `sender_id` (group chats, and DMs on platforms like Telegram), that sender gets their own peer, using their platform ID directly as the Honcho peer ID (or aliased via the [peers file](#peer-mappings) if configured). Each OpenClaw agent gets its own Honcho peer (default `agent-{id}`). The default `owner` peer is used as a fallback when a channel emits no sender metadata, and — on legacy installs whose peers file uses `defaultUnknownPolicy: "owner"` — for any unknown sender. **Migration boundary:** historical turns already attributed to `owner` (or to any prior peer ID) are not retroactively re-attributed when the plugin upgrades or when `peers` / `defaultUnknownPolicy` change. Only new inbound `sender_id`s create per-sender peers, so pre-existing sessions may show mixed attribution across the rollout. This gives every participant isolated, personalized memory going forward. - **Clean Persistence** — Platform metadata (conversation info, sender headers, thread context, forwarded messages) is stripped before saving to Honcho, ensuring only meaningful content is persisted. Noise messages (heartbeat acks, cron boilerplate, startup commands) are dropped entirely via configurable pattern filters. Honcho handles all reasoning and synthesis in the cloud. From 2f8e77f762b5c8a9fc5f225443ccb6a1b3455473 Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 16:54:53 -0400 Subject: [PATCH 14/19] chore: organizing tests --- helpers.test.ts => test/helpers.test.ts | 2 +- {tools => test}/memory-passthrough.test.ts | 2 +- peers.test.ts => test/peers.test.ts | 2 +- runtime.test.ts => test/runtime.test.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename helpers.test.ts => test/helpers.test.ts (97%) rename {tools => test}/memory-passthrough.test.ts (98%) rename peers.test.ts => test/peers.test.ts (99%) rename runtime.test.ts => test/runtime.test.ts (99%) diff --git a/helpers.test.ts b/test/helpers.test.ts similarity index 97% rename from helpers.test.ts rename to test/helpers.test.ts index 06578a9..72e32a5 100644 --- a/helpers.test.ts +++ b/test/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { extractSenderId } from "./helpers.js"; +import { extractSenderId } from "../helpers.js"; const SENTINEL = "Conversation info (untrusted metadata):"; diff --git a/tools/memory-passthrough.test.ts b/test/memory-passthrough.test.ts similarity index 98% rename from tools/memory-passthrough.test.ts rename to test/memory-passthrough.test.ts index f627b30..155bdc8 100644 --- a/tools/memory-passthrough.test.ts +++ b/test/memory-passthrough.test.ts @@ -8,7 +8,7 @@ vi.mock("../runtime.js", () => ({ getHonchoMemorySearchManager: getHonchoMemorySearchManagerMock, })); -import { registerMemoryPassthrough } from "./memory-passthrough.js"; +import { registerMemoryPassthrough } from "../tools/memory-passthrough.js"; describe("memory passthrough tools", () => { beforeEach(() => { diff --git a/peers.test.ts b/test/peers.test.ts similarity index 99% rename from peers.test.ts rename to test/peers.test.ts index eb26b18..3adf1cd 100644 --- a/peers.test.ts +++ b/test/peers.test.ts @@ -9,7 +9,7 @@ import { loadPeersFileSync, resolvePeersFilePath, resolveParticipantPeerId, -} from "./peers.js"; +} from "../peers.js"; async function mktmp(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), "openclaw-peers-")); diff --git a/runtime.test.ts b/test/runtime.test.ts similarity index 99% rename from runtime.test.ts rename to test/runtime.test.ts index 7114af3..eb7c329 100644 --- a/runtime.test.ts +++ b/test/runtime.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { getHonchoMemorySearchManager, resolveHonchoMemoryBackendConfig } from "./runtime.js"; -import type { PluginState } from "./state.js"; +import { getHonchoMemorySearchManager, resolveHonchoMemoryBackendConfig } from "../runtime.js"; +import type { PluginState } from "../state.js"; type TestState = PluginState & { participantPeer: { From 3bfc9e17e07d149dc52123ca325e5fe702ecd061 Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 17:15:42 -0400 Subject: [PATCH 15/19] fix: pruned participantSenderIds plural field --- CHANGELOG.md | 4 ++-- hooks/capture.ts | 15 +++------------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 691b01c..89b48a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `@honcho-ai/openclaw-honcho` will be documented in this file. -## [1.4.0] - 2026-04-15 +## [1.4.0] - 2026-04-27 Per-sender participant peers when the channel emits `sender_id` (group chats and many 1:1s — see README). No sender metadata → `owner` peer (operator/system @@ -15,7 +15,7 @@ default to `"owner"` (pre-1.4.0 merge behavior); fresh installs default to - **Multi-peer**: `extractSenderId` parses `sender_id` from each inbound `Conversation info` block; both capture and `before_prompt_build` resolve a peer per sender from the current message, so the right participant is targeted on every turn — including when the speaker changes between turns. Peer IDs derive from the channel ID, sanitized to `[A-Za-z0-9_-]` and truncated to Honcho's 100-char limit. Switching `defaultUnknownPolicy` after the fact only affects new messages — already-captured messages stay where they originally landed. - **`-p, --peer ` on `honcho ask` and `honcho search`**: query memory from a specific participant's perspective. - **Peers file `~/.honcho/openclaw-peers.json`**: `sender_id` → peer ID map, auto-seeded by the plugin and hand-editable. Top-level `defaultUnknownPolicy` (`per-sender` for fresh installs, `owner` for legacy files without the field) controls auto-seeding; auto-seeded peers get `autoSeeded: true` metadata. Override path via `OPENCLAW_HONCHO_PEERS_FILE`. -- **Session metadata `participantSenderId` / `participantSenderIds`**: last active sender + all known senders in the session. +- **Session metadata `participantSenderId`**: last active sender, used by tools to resolve the session's current participant peer. ### Fixed - **`ensureInitialized()` race could corrupt workspace metadata**: now guarded by a shared init promise. diff --git a/hooks/capture.ts b/hooks/capture.ts index 7419ed0..e9c49d4 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -143,15 +143,9 @@ async function flushMessages( (senderId) => resolvedPeers.get(senderId), ); - // Store sender IDs in session metadata for tool resolution. - // participantSenderId = last active sender (default for tools). - // participantSenderIds = all known senders in this session (for future multi-target tools). - // Named "sender" (not "peer") to distinguish raw channel IDs from resolved Honcho peer IDs. - const previousSenderIds: string[] = Array.isArray(existingMeta.participantSenderIds) - ? (existingMeta.participantSenderIds as string[]) - : []; - const allSenderIds = [...new Set([...previousSenderIds, ...senderIds])]; - + // participantSenderId = last active sender, used by tools to resolve the + // session's current participant peer. Named "sender" (not "peer") to + // distinguish raw channel IDs from resolved Honcho peer IDs. const updatedMeta: Record = { ...existingMeta, ...sessionMeta, @@ -160,9 +154,6 @@ async function flushMessages( if (lastSenderId) { updatedMeta.participantSenderId = lastSenderId; } - if (allSenderIds.length > 0) { - updatedMeta.participantSenderIds = allSenderIds; - } if (extracted.length === 0) { await session.setMetadata(updatedMeta); From 3660356864e00e549dc0cfdf23a1c30856b98528 Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 17:20:21 -0400 Subject: [PATCH 16/19] fix: console.warn --- peers.ts | 15 ++++++++++----- test/peers.test.ts | 3 +++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/peers.ts b/peers.ts index 1825546..4b94422 100644 --- a/peers.ts +++ b/peers.ts @@ -34,7 +34,7 @@ export function resolvePeersFilePath(): string { return path.join(os.homedir(), ".honcho", "openclaw-peers.json"); } -function parsePeersJson(raw: string): PeersFile { +function parsePeersJson(raw: string, filePath: string): PeersFile { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { @@ -49,8 +49,13 @@ function parsePeersJson(raw: string): PeersFile { peers, }; } - } catch { - // fall through + console.warn( + `peers file ${filePath}: top-level value is not an object; falling back to legacy owner policy with empty map`, + ); + } catch (err) { + console.warn( + `peers file ${filePath}: ${(err as Error).message}; falling back to legacy owner policy with empty map`, + ); } // Existing-but-malformed file → preserve legacy contract. return { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "owner", peers: {} }; @@ -59,7 +64,7 @@ function parsePeersJson(raw: string): PeersFile { /** Read the peers file. Missing → per-sender (fresh install); anything else → owner (legacy). */ export async function loadPeersFile(filePath: string): Promise { try { - return parsePeersJson(await fs.readFile(filePath, "utf8")); + return parsePeersJson(await fs.readFile(filePath, "utf8"), filePath); } catch (err) { return (err as NodeJS.ErrnoException)?.code === "ENOENT" ? { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "per-sender", peers: {} } @@ -69,7 +74,7 @@ export async function loadPeersFile(filePath: string): Promise { export function loadPeersFileSync(filePath: string): PeersFile { try { - return parsePeersJson(readFileSync(filePath, "utf8")); + return parsePeersJson(readFileSync(filePath, "utf8"), filePath); } catch (err) { return (err as NodeJS.ErrnoException)?.code === "ENOENT" ? { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "per-sender", peers: {} } diff --git a/test/peers.test.ts b/test/peers.test.ts index 3adf1cd..d2aa9e5 100644 --- a/test/peers.test.ts +++ b/test/peers.test.ts @@ -92,11 +92,14 @@ describe("loadPeersFile", () => { const dir = await mktmp(); const file = path.join(dir, "peers.json"); await fs.writeFile(file, "{ not valid json"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); await expect(loadPeersFile(file)).resolves.toEqual({ version: PEERS_FILE_VERSION, defaultUnknownPolicy: "owner", peers: {}, }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(file)); + warn.mockRestore(); }); }); From 1c1805c2ddeb7c97f0ca0e9ad967bbbfc39db325 Mon Sep 17 00:00:00 2001 From: ajspig Date: Mon, 27 Apr 2026 17:40:37 -0400 Subject: [PATCH 17/19] fix: Flushes now re-read openclaw-peers.json and merge it with memory (disk wins on conflicts) so saving new senders no longer wipes hand-edited rows --- README.md | 4 +-- peers.ts | 69 ++++++++++++++++++++++++++++----------- test/peers.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 191a2a9..7770480 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Set `ownerObserveOthers: true` to let the owner peer also observe agent messages ### Peer Mappings -The plugin maintains a `sender_id` → Honcho peer ID map at `~/.honcho/openclaw-peers.json` (override the path with `OPENCLAW_HONCHO_PEERS_FILE`). The file is auto-seeded as new senders appear, hand-editable, and re-read on gateway restart. To alias a platform-specific sender ID to a friendlier Honcho peer (or merge two channel identities into one), edit the `peers` map: +Map `sender_id` → Honcho peer ID in `~/.honcho/openclaw-peers.json` (override with `OPENCLAW_HONCHO_PEERS_FILE`). New senders are added automatically; edit `peers` to alias or merge identities, then **`openclaw gateway restart`** so the gateway reloads the file. ```json { @@ -128,7 +128,7 @@ The plugin maintains a `sender_id` → Honcho peer ID map at `~/.honcho/openclaw - **`defaultUnknownPolicy`** controls how unknown `sender_id`s are seeded into `peers`: - `per-sender` — default for fresh installs. Each new sender becomes its own peer; the seeded peer ID is the `sender_id` sanitized to `[A-Za-z0-9_-]` and truncated to Honcho's 100-char limit. - `owner` — default for pre-existing files missing the field (preserves legacy behavior). All unknown senders merge into the owner peer. -- **Auto-seeded, manually overridable.** The plugin only inserts entries for senders not already in the map, so hand-edits to known senders survive. Edits take effect on gateway restart (`openclaw gateway restart`). +- **Auto-seeded, manually overridable.** The plugin only adds entries for senders not already in the map. - **Adding a mapping after messages exist splits history.** Messages already stored under the original peer stay there; new messages land under the new peer. Remap before the peer accumulates history. ### Multi-Peer Participants diff --git a/peers.ts b/peers.ts index 4b94422..f9ce2ba 100644 --- a/peers.ts +++ b/peers.ts @@ -8,8 +8,8 @@ * Fresh installs (file missing) default to "per-sender"; pre-existing files * (no policy field) keep "owner" so legacy users see no behavior change. * - * User can hand-edit to remap any sender. Re-read on gateway restart. - * Path override: OPENCLAW_HONCHO_PEERS_FILE. + * Restart reloads from disk. Flush merges disk before write (disk wins conflicts) + * so concurrent file edits are not overwritten. OPENCLAW_HONCHO_PEERS_FILE overrides path. */ import { promises as fs, readFileSync } from "node:fs"; @@ -125,15 +125,14 @@ export type PeersPersisterOptions = { * into a single write. Flushes are chained via a single promise so writes * cannot interleave. * - * Note: the file is user-editable, but edits made at runtime are not picked - * up until the gateway restarts. Existing entries are never overwritten by - * enqueue(), so hand-edits to known senders survive; new senders added by - * hand could be clobbered if the plugin sees them before the restart. + * Each flush() re-reads the file and merges `{ ...memory, ...disk }` so disk + * wins on conflicting sender_ids; keys only on disk or only in memory are + * kept. enqueue() never overwrites an existing mapping. */ export class PeersPersister { public readonly peers: Record; public readonly filePath: string; - public readonly defaultUnknownPolicy: DefaultUnknownPolicy; + public defaultUnknownPolicy: DefaultUnknownPolicy; private readonly debounceMs: number; private dirty = false; private timer: ReturnType | null = null; @@ -174,18 +173,50 @@ export class PeersPersister { private async flush(): Promise { if (!this.dirty) return; - this.dirty = false; - try { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - const body: PeersFile = { - version: PEERS_FILE_VERSION, - defaultUnknownPolicy: this.defaultUnknownPolicy, - peers: this.peers, - }; - await fs.writeFile(this.filePath, JSON.stringify(body, null, 2) + "\n"); - } catch (err) { - this.dirty = true; - throw err; + while (this.dirty) { + this.dirty = false; + try { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const onDisk = await loadPeersFileForMerge(this.filePath, this.defaultUnknownPolicy); + const mergedPeers = { ...this.peers, ...onDisk.peers }; + this.defaultUnknownPolicy = onDisk.defaultUnknownPolicy; + replaceRecordInPlace(this.peers, mergedPeers); + + const body: PeersFile = { + version: PEERS_FILE_VERSION, + defaultUnknownPolicy: this.defaultUnknownPolicy, + peers: mergedPeers, + }; + await fs.writeFile(this.filePath, JSON.stringify(body, null, 2) + "\n"); + } catch (err) { + this.dirty = true; + throw err; + } + } + } +} + +/** Sync `target` to equal `source` (same keys and values). */ +function replaceRecordInPlace(target: Record, source: Record): void { + for (const k of Object.keys(target)) { + if (!(k in source)) delete target[k]; + } + Object.assign(target, source); +} + +/** + * Same as reading the peers file for parsing, except a missing file uses + * `memoryPolicy` instead of the fresh-install default (`per-sender`), so the + * first write does not clobber the policy loaded at boot from an in-memory-only state. + */ +async function loadPeersFileForMerge(filePath: string, memoryPolicy: DefaultUnknownPolicy): Promise { + try { + return parsePeersJson(await fs.readFile(filePath, "utf8"), filePath); + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "ENOENT") { + return { version: PEERS_FILE_VERSION, defaultUnknownPolicy: memoryPolicy, peers: {} }; } + return { version: PEERS_FILE_VERSION, defaultUnknownPolicy: "owner", peers: {} }; } } diff --git a/test/peers.test.ts b/test/peers.test.ts index d2aa9e5..a96bae8 100644 --- a/test/peers.test.ts +++ b/test/peers.test.ts @@ -238,4 +238,84 @@ describe("PeersPersister", () => { await p.flushNow(); await expect(fs.access(file)).rejects.toBeTruthy(); }); + + it("merge preserves hand-edited keys on disk not present in memory", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + await fs.writeFile( + file, + JSON.stringify({ + version: 1, + defaultUnknownPolicy: "owner", + peers: { "slack:U99": "alice", "slack:U1": "owner" }, + }) + "\n", + ); + + const loaded = loadPeersFileSync(file); + const p = new PeersPersister(file, loaded, { debounceMs: 10 }); + p.enqueue("slack:Unew", "slack_Unew"); + await p.flushNow(); + + const body = JSON.parse(await fs.readFile(file, "utf8")); + expect(body.peers["slack:U99"]).toBe("alice"); + expect(body.peers["slack:Unew"]).toBe("slack_Unew"); + expect(body.peers["slack:U1"]).toBe("owner"); + }); + + it("merge prefers on-disk mapping when the same sender exists in memory", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + await fs.writeFile( + file, + JSON.stringify({ + version: 1, + defaultUnknownPolicy: "owner", + peers: { "slack:U1": "from_disk" }, + }) + "\n", + ); + + const loaded = loadPeersFileSync(file); + const p = new PeersPersister(file, loaded, { debounceMs: 10 }); + expect(p.peers["slack:U1"]).toBe("from_disk"); + (p.peers as Record)["slack:U1"] = "stale_memory"; + p.enqueue("slack:U2", "owner"); + await p.flushNow(); + + const body = JSON.parse(await fs.readFile(file, "utf8")); + expect(body.peers["slack:U1"]).toBe("from_disk"); + expect(p.peers["slack:U1"]).toBe("from_disk"); + }); + + it("reloads defaultUnknownPolicy from disk on flush", async () => { + const dir = await mktmp(); + const file = path.join(dir, "peers.json"); + await fs.writeFile( + file, + JSON.stringify({ + version: 1, + defaultUnknownPolicy: "owner", + peers: {}, + }) + "\n", + ); + + const loaded = loadPeersFileSync(file); + const p = new PeersPersister(file, loaded, { debounceMs: 10 }); + expect(p.defaultUnknownPolicy).toBe("owner"); + + await fs.writeFile( + file, + JSON.stringify({ + version: 1, + defaultUnknownPolicy: "per-sender", + peers: {}, + }) + "\n", + ); + + p.enqueue("slack:Ux", "derived"); + await p.flushNow(); + + expect(p.defaultUnknownPolicy).toBe("per-sender"); + const body = JSON.parse(await fs.readFile(file, "utf8")); + expect(body.defaultUnknownPolicy).toBe("per-sender"); + }); }); From 23a5859cb6ea7ddad1542aa790a5503e0f271f76 Mon Sep 17 00:00:00 2001 From: ajspig Date: Tue, 28 Apr 2026 08:58:13 -0400 Subject: [PATCH 18/19] fix: simplifying cleaning message object --- helpers.ts | 37 +++++++++++++++++++++---------------- hooks/capture.ts | 23 +---------------------- 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/helpers.ts b/helpers.ts index 1f8b378..2d030fb 100644 --- a/helpers.ts +++ b/helpers.ts @@ -4,6 +4,26 @@ import type { Peer, MessageInput } from "@honcho-ai/sdk"; +type ContentBlock = { type?: string; text?: unknown }; +type RawMessage = { role?: string; content?: string | ContentBlock[]; timestamp?: number }; + +/** + * Extract plain text from a message's `content` (string or array of content blocks). + * Returns "" for non-message inputs or messages with no text blocks. + */ +export function getRawContent(msg: unknown): string { + if (!msg || typeof msg !== "object") return ""; + const { content } = msg as RawMessage; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((b): b is ContentBlock & { text: string } => + !!b && b.type === "text" && typeof b.text === "string", + ) + .map((b) => b.text) + .join("\n"); +} + /** * Build a Honcho session key from OpenClaw context. * Combines sessionKey + messageProvider to create unique sessions per platform. @@ -224,22 +244,7 @@ export function extractMessages( if (role !== "user" && role !== "assistant") continue; - // Extract raw content before cleaning - let rawContent = ""; - if (typeof m.content === "string") { - rawContent = m.content; - } else if (Array.isArray(m.content)) { - rawContent = m.content - .filter( - (block: unknown) => - typeof block === "object" && - block !== null && - (block as Record).type === "text" - ) - .map((block: unknown) => (block as Record).text) - .filter((t): t is string => typeof t === "string") - .join("\n"); - } + const rawContent = getRawContent(msg); // For user messages, extract sender ID before cleaning strips metadata let peer: Peer; diff --git a/hooks/capture.ts b/hooks/capture.ts index e9c49d4..4453eb0 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -7,31 +7,10 @@ import { isSubagentSession, extractMessages, extractSenderId, + getRawContent, } from "../helpers.js"; import { subagentParentMap } from "./subagent.js"; -/** - * Extract raw text content from a message object (before cleaning). - */ -function getRawContent(msg: unknown): string { - if (!msg || typeof msg !== "object") return ""; - const m = msg as Record; - if (typeof m.content === "string") return m.content; - if (Array.isArray(m.content)) { - return m.content - .filter( - (block: unknown) => - typeof block === "object" && - block !== null && - (block as Record).type === "text" - ) - .map((block: unknown) => (block as Record).text) - .filter((t): t is string => typeof t === "string") - .join("\n"); - } - return ""; -} - /** * Core message capture logic shared by agent_end, before_compaction, and before_reset. * Returns the number of new messages saved (or 0 if none). From e962facd06be40b7bd260c2a5bf3fb3d96dd1ee6 Mon Sep 17 00:00:00 2001 From: ajspig Date: Tue, 28 Apr 2026 09:29:35 -0400 Subject: [PATCH 19/19] fix: Reviewing peer ID resolution in the openclaw-honcho plugin --- tools/ask.ts | 13 +++++++++++-- tools/context.ts | 12 ++++++++++-- tools/message-search.ts | 12 +++++++++++- tools/search.ts | 13 +++++++++++-- tools/session.ts | 12 +++++++++++- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/tools/ask.ts b/tools/ask.ts index f390df7..ad975a5 100644 --- a/tools/ask.ts +++ b/tools/ask.ts @@ -23,18 +23,27 @@ export function registerAskTool(api: OpenClawPluginApi, state: PluginState): voi description: "Reasoning depth: 'quick' for simple facts (default), 'thorough' for synthesis and analysis.", }) ), + about: Type.Optional( + Type.String({ + description: + "Sender ID of the user to ask about. Defaults to the last active sender. Pass a specific sender_id to ask about a different participant.", + }) + ), }, { additionalProperties: false } ), async execute(_toolCallId, params) { - const { query, depth = "quick" } = params as { + const { query, depth = "quick", about } = params as { query: string; depth?: "quick" | "thorough"; + about?: string; }; await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(toolCtx.agentId); - const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); + const participantPeer = about + ? await state.getParticipantPeer(about) + : await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); const reasoningLevel = depth === "thorough" ? "high" : "low"; const answer = await agentPeer.chat(query, { diff --git a/tools/context.ts b/tools/context.ts index 66928cd..5d3c0d8 100644 --- a/tools/context.ts +++ b/tools/context.ts @@ -20,14 +20,22 @@ export function registerContextTool(api: OpenClawPluginApi, state: PluginState): description: "Detail level: 'card' for key facts (default, fast), 'full' for broad representation.", }) ), + about: Type.Optional( + Type.String({ + description: + "Sender ID of the user to query about. Defaults to the last active sender. Pass a specific sender_id to get context about a different participant.", + }) + ), }, { additionalProperties: false } ), async execute(_toolCallId, params) { - const { detail = "card" } = params as { detail?: "card" | "full" }; + const { detail = "card", about } = params as { detail?: "card" | "full"; about?: string }; await state.ensureInitialized(); - const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); + const participantPeer = about + ? await state.getParticipantPeer(about) + : await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); if (detail === "card") { const card = await participantPeer.card().catch((err) => { diff --git a/tools/message-search.ts b/tools/message-search.ts index 2314789..b3b71a1 100644 --- a/tools/message-search.ts +++ b/tools/message-search.ts @@ -25,6 +25,12 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS "Filter by sender: 'user' for user messages, 'agent' for this agent's messages, 'all' for everything (default: 'all').", }) ), + about: Type.Optional( + Type.String({ + description: + "Sender ID of the participant whose messages to search. Only used when from='user'. Defaults to the last active sender.", + }) + ), metadata: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { description: @@ -55,6 +61,7 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS const { query, from = "all", + about, metadata, created_after, created_before, @@ -62,6 +69,7 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS } = params as { query: string; from?: "user" | "agent" | "all"; + about?: string; metadata?: Record; created_after?: string; created_before?: string; @@ -91,7 +99,9 @@ export function registerMessageSearchTool(api: OpenClawPluginApi, state: PluginS // Route to the appropriate search method based on `from` let messages: Message[]; if (from === "user") { - const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); + const participantPeer = about + ? await state.getParticipantPeer(about) + : await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); messages = await participantPeer.search(query, searchOpts); } else if (from === "agent") { const agentPeer = await state.getAgentPeer(toolCtx.agentId); diff --git a/tools/search.ts b/tools/search.ts index 5376b48..3258cd5 100644 --- a/tools/search.ts +++ b/tools/search.ts @@ -30,18 +30,27 @@ export function registerSearchTool(api: OpenClawPluginApi, state: PluginState): maximum: 1, }) ), + about: Type.Optional( + Type.String({ + description: + "Sender ID of the user to query about. Defaults to the last active sender. Pass a specific sender_id to search conclusions about a different participant.", + }) + ), }, { additionalProperties: false } ), async execute(_toolCallId, params) { - const { query, topK, maxDistance } = params as { + const { query, topK, maxDistance, about } = params as { query: string; topK?: number; maxDistance?: number; + about?: string; }; await state.ensureInitialized(); - const participantPeer = await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); + const participantPeer = about + ? await state.getParticipantPeer(about) + : await state.resolveSessionParticipantPeer(buildSessionKey(toolCtx)); const representation = await participantPeer.representation({ searchQuery: query, diff --git a/tools/session.ts b/tools/session.ts index c6ab13c..9fa6373 100644 --- a/tools/session.ts +++ b/tools/session.ts @@ -35,6 +35,12 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): maximum: 32000, }) ), + about: Type.Optional( + Type.String({ + description: + "Sender ID of the user to get session context for. Defaults to the last active sender. Pass a specific sender_id to get session context about a different participant.", + }) + ), }, { additionalProperties: false } ), @@ -44,17 +50,21 @@ export function registerSessionTool(api: OpenClawPluginApi, state: PluginState): includeSummary = true, searchQuery, messageLimit = 4000, + about, } = params as { includeMessages?: boolean; includeSummary?: boolean; searchQuery?: string; messageLimit?: number; + about?: string; }; await state.ensureInitialized(); const agentPeer = await state.getAgentPeer(toolCtx.agentId); const sessionKey = buildSessionKey(toolCtx); - const participantPeer = await state.resolveSessionParticipantPeer(sessionKey); + const participantPeer = about + ? await state.getParticipantPeer(about) + : await state.resolveSessionParticipantPeer(sessionKey); try { const session = await state.honcho.session(sessionKey);