Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions commands/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,13 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void {
.command("ask <question>")
.description("Ask Honcho about the user")
.option("-a, --agent <id>", "Agent ID to query as (default: primary agent)")
.action(async (question: string, options: { agent?: string }) => {
.option("-p, --peer <id>", "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}`);
Expand All @@ -273,10 +275,12 @@ export function registerCli(api: OpenClawPluginApi, state: PluginState): void {
.description("Semantic search over Honcho memory")
.option("-k, --top-k <number>", "Number of results to return", "10")
.option("-d, --max-distance <number>", "Maximum semantic distance (0-1)", "0.5")
.action(async (query: string, options: { topK: string; maxDistance: string }) => {
.option("-p, --peer <id>", "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),
Expand Down
15 changes: 15 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type HonchoConfig = {
noisePatterns: string[];
disableDefaultNoisePatterns: boolean;
ownerObserveOthers: boolean;
peerMappings: Record<string, string>;
agentPeerMappings: Record<string, string>;
};

/**
Expand All @@ -32,6 +34,17 @@ function resolveEnvVars(value: string): string {
});
}

function parseStringRecord(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
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<string, unknown>;
Expand Down Expand Up @@ -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),
};
},
};
67 changes: 56 additions & 11 deletions helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand All @@ -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[] = [];

Expand All @@ -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" &&
Expand All @@ -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;
Expand Down
112 changes: 100 additions & 12 deletions hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>).type === "text"
)
.map((block: unknown) => (block as Record<string, unknown>).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).
Expand Down Expand Up @@ -55,30 +78,95 @@ 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<string>();
let lastSenderId: string | undefined;
let userMsgCount = 0;
for (const msg of newRawMessages) {
if (!msg || typeof msg !== "object") continue;
const m = msg as Record<string, unknown>;
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(", ")}`);
}

const resolvedPeers = new Map<string, Awaited<ReturnType<typeof state.getHumanPeer>>>();
for (const senderId of senderIds) {
resolvedPeers.set(senderId, await state.getHumanPeer(senderId));
}

const defaultHumanPeer = await state.getHumanPeer();

// Build peer configs: default owner + all resolved human peers + agent + parent
const peerConfigMap = new Map<string, { observeMe: boolean; observeOthers: boolean }>();
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.
// humanPeerId = last active sender (default for tools).
// humanPeerIds = all known senders in this session (for future multi-target tools).
const previousPeerIds: string[] = Array.isArray(existingMeta.humanPeerIds)
? (existingMeta.humanPeerIds as string[])
: [];
const allPeerIds = [...new Set([...previousPeerIds, ...senderIds])];

const updatedMeta: Record<string, unknown> = {
...existingMeta,
...sessionMeta,
lastSavedIndex: messages.length,
};
if (lastSenderId) {
updatedMeta.humanPeerId = lastSenderId;
}
if (allPeerIds.length > 0) {
updatedMeta.humanPeerIds = allPeerIds;
}
Comment on lines +143 to +161
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Improvement: now persists all sender IDs for multi-participant access.

This addresses the previous review concern about only storing the last sender. The dual storage (humanPeerId for default tool resolution, humanPeerIds for future multi-target tools) is a reasonable design.

However, the naming is misleading: these metadata keys store sender IDs (the raw identifiers extracted from messages), not Honcho peer object IDs. Consider renaming to humanSenderId/humanSenderIds to clarify the distinction, or update the comment to explicitly note these are sender IDs that must be passed to getHumanPeer() for resolution.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@hooks/capture.ts` around lines 143 - 161, The metadata keys humanPeerId and
humanPeerIds in updatedMeta currently store raw sender IDs (derived from
senderIds/lastSenderId) rather than Honcho peer object IDs; either rename these
keys to humanSenderId/humanSenderIds or update the nearby comment to state
explicitly that these values are sender IDs that must be passed to
getHumanPeer() for resolution (adjust references where existingMeta or callers
expect humanPeerId/humanPeerIds to avoid breaking usage). Ensure all references
to humanPeerId/humanPeerIds in this module and any consumers are updated to the
new names or documented behavior so tooling uses getHumanPeer(senderId) to
obtain peer objects.


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;
}

Expand Down
5 changes: 3 additions & 2 deletions hooks/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`);
}
Expand All @@ -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) {
Expand Down
Loading