Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
28cb11d
feat: multi-peer group chat support with review fixes
Apr 6, 2026
fdecfd2
fix: address CodeRabbit review feedback
Apr 6, 2026
fbef6b2
Merge branch 'main' into feat/multi-peer-with-fixes
ajspig Apr 9, 2026
e798223
fix: restore cli.ts to upstream main, keep only -p/--peer flag additions
Apr 12, 2026
71cca27
merge: incorporate upstream v1.3.2 (configurable timeout, multi-agent…
Apr 12, 2026
01222c2
fix: rename humanPeer → participantPeer
ajspig Apr 15, 2026
2e17e68
fix: removing peer mapping in favor of a more comprehensive solution
ajspig Apr 15, 2026
7cb869d
fix: extract sender_id from inbound message in before_prompt_build
ajspig Apr 15, 2026
a25ef7e
feat: adding multi-peer support
ajspig Apr 15, 2026
96f3ca2
fix: adding peer mappings to ~./honcho
ajspig Apr 24, 2026
d430714
fix: add peer mapping to gateway log
ajspig Apr 24, 2026
417e8c7
Merge origin/main into feat/multi-peer-support
ajspig Apr 24, 2026
5805aa2
chore: language cleanup
ajspig Apr 24, 2026
e03518a
fix: owner_id fallback for legacy and new installs
ajspig Apr 27, 2026
a6314e4
fix: cr suggestion, harden Honcho error handling
ajspig Apr 27, 2026
03f6dc2
docs: update readme
ajspig Apr 27, 2026
2f8e77f
chore: organizing tests
ajspig Apr 27, 2026
3bfc9e1
fix: pruned participantSenderIds plural field
ajspig Apr 27, 2026
3660356
fix: console.warn
ajspig Apr 27, 2026
1c1805c
fix: Flushes now re-read openclaw-peers.json and merge it with memory…
ajspig Apr 27, 2026
23a5859
fix: simplifying cleaning message object
ajspig Apr 28, 2026
e962fac
fix: Reviewing peer ID resolution in the openclaw-honcho plugin
ajspig Apr 28, 2026
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to `@honcho-ai/openclaw-honcho` will be documented in this file.

## [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
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**: `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 <id>` 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`**: 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.
- **`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

### Fixed
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,47 @@ 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

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
{
"version": 1,
"defaultUnknownPolicy": "per-sender",
"peers": {
"U0EXAMPLE01": "user",
"telegram-1234567890": "user"
}
}
```

- **`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 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

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, 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.

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

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. 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.
Expand Down
12 changes: 8 additions & 4 deletions commands/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,11 +451,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 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}`);
Expand All @@ -467,10 +469,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 participantPeer = await state.getParticipantPeer(options.peer);
const representation = await participantPeer.representation({
searchQuery: query,
searchTopK: parseInt(options.topK, 10),
searchMaxDistance: parseFloat(options.maxDistance),
Expand Down
100 changes: 78 additions & 22 deletions helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -142,6 +162,49 @@ 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.
*
* 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 ```
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 +230,10 @@ export function shouldSkipMessage(content: string, noisePatterns: string[]): boo

export function extractMessages(
rawMessages: unknown[],
ownerPeer: Peer,
defaultParticipantPeer: Peer,
agentPeer: Peer,
noisePatterns: string[] = []
noisePatterns: string[] = [],
resolvePeer?: (senderId: string) => Peer | undefined,
): MessageInput[] {
const result: MessageInput[] = [];

Expand All @@ -180,33 +244,25 @@ export function extractMessages(

if (role !== "user" && role !== "assistant") continue;

let content = "";
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
content = 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");
const rawContent = getRawContent(msg);

// For user messages, extract sender ID before cleaning strips metadata
let peer: Peer;
if (role === "user") {
const senderId = extractSenderId(rawContent);
peer = (senderId && resolvePeer?.(senderId)) || defaultParticipantPeer;
} else {
peer = agentPeer;
}

content = cleanMessageContent(content);
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
86 changes: 74 additions & 12 deletions hooks/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
buildSessionKey,
isSubagentSession,
extractMessages,
extractSenderId,
getRawContent,
} from "../helpers.js";
import { subagentParentMap } from "./subagent.js";

Expand Down Expand Up @@ -55,30 +57,90 @@ 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 participant 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)`);
}

// Parallel peer resolution — avoids sequential await bottleneck in group chats.
const resolvedPeers = new Map<string, Awaited<ReturnType<typeof state.getParticipantPeer>>>();
const senderIdArray = [...senderIds];
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 defaultParticipantPeer = await state.getParticipantPeer();

// Build peer configs: default owner + all resolved participant 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,
defaultParticipantPeer,
agentPeer,
state.cfg.noisePatterns,
(senderId) => resolvedPeers.get(senderId),
);

const newRawMessages = messages.slice(startIndex);
const extracted = extractMessages(newRawMessages, state.ownerPeer!, agentPeer, state.cfg.noisePatterns);
// 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<string, unknown> = {
...existingMeta,
...sessionMeta,
lastSavedIndex: messages.length,
};
if (lastSenderId) {
updatedMeta.participantSenderId = lastSenderId;
}

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
Loading
Loading