Several people can share one agent and one memory workspace. Channel entries currently retain a body prefix and also persist trustedspeaker_id/speaker_display as independent fields on message nodes. This page has three layers:Before the change,
How eight frameworks in references handle it, Our implementation. Sections 06–08 retain the superseded body-only intermediate design and its tradeoffs before security review. Sections 09–12 describe the replacing dual representation, source framing, and speaker filtering. The prose specification is indocs/reference/design/memory/overview.md; the overall mechanism is inarchitecture.html.
This layer records the state before implementation. Sender display names reached message nodes per message, while stable IDs stopped at the access gate. Memory writing read neither. The next sections explain the problem and where propagation broke. The session shapes in section 01 still apply and motivate this change.
The sender’sdisplay name reached each message node:user_display persisted it from the channel adapter through the session store;get_branch still returned it.
The sender’sStable IDatbase.py:176 was discarded after the access gate and never entereddispatch_inbound.
TurnRequest.peer_id contains the routing destination, which is the group ID in group chats.
_records()Reads onlyrole, discarding the already-availablepeer_display. After enteringSourceRecord, all three people’s statements becameuser.
Whether identity needs per-message transport depends on whether two speakers can appear in one session. They can. The routing key (openprogram/channels/_session_routing.py:33-52 session_key_for_agent) has two configurations placing multiple people in one session, one of which is the Telegram group default.
_session_routing.py:33-52’s branches (group / channel always isolate bypeer_id; scope == "main" combines all private chats); Telegram’speer_id_for in implementations/telegram.py:55-60, group_sessions defaults toshared (telegram.py:48).
A group message passes through eight stages before reaching a topic file. Before the change, display names reached stage seven; stable IDs stopped after stage two. Neither entered stage seven’sSourceRecord. This diagram records only the pre-change baseline. The current implementation carries both trusted fields to stage seven while retaining the body prefix; section 11 shows the final path.
All eight implementations were examined, rather than READMEs. Only three connect chat channels, and only two encounter multiple people in one session. Both use nearly the same approach:prepend the speaker’s name to message content, adding nothing else. The other five are single-user CLIs without the concept of multiple people in one session; that too is a design choice.
Distinguish “not implemented” from “not needed.” Multiple people can share a session only when a framework connects chat channels.
senderName
(src/tools/SendMessageTool/SendMessageTool.ts:93,157,
src/tools/TaskUpdateTool/TaskUpdateTool.ts:278-294), but that is attribution for team-mode messagesbetween agents, not human speakers. In codex-cli,participants matches occur only incodex-rs/rmcp-client/src/bin/test_stdio_server.rs’s test barrier server and are unrelated to identity. Searches for speaker terms in opencode, pi-ai, and pi-mono havezero matches.
| Framework | Multiple people per session | Identity storage layer | How the model knows who spoke | Long-term memory | Speaker implementation size |
|---|---|---|---|---|---|
| openclaw | Yes, groups/channels | Five fields per messageSenderId/Name/Username/Tag/E164 |
Group chatbody prefixName (id): ; private chats use envelope headers, plus a user-role JSON block present only in the current turn |
Memory hasnospeaker field. People are model-maintained wiki pages,canonicalId+aliases+handles |
10 label lines, six prefix lines, 106 JSON-block lines, and 317 lines stripping JSON from history |
| hermes-agent | Yes, groups/threads | Session origin objectSessionSource, read as each message arrives |
body prefix[Name] , added only for shared sessions; system prompt adds “This is a multi-person conversation” |
Memory hasnospeaker field.MEMORY.md/USER.md is free text |
Two prefix lines, six shared-session decision lines, one system-prompt sentence |
| weclaw | No, one-to-one iLink | No such feature | no | no | FromUserID is the session key; no speaker concept anywhere in the repository |
| claude-code-leaked | no | no | no | no | senderNameUsed only for inter-agent message attribution |
| codex-cli | no | no | no | no | no |
| opencode | no | no | no | no | no |
| pi-ai | no | no | no | no | no |
| pi-mono | no | no | no | no | no |
memory-core, memory-lancedb,
active-memory three plugins have zero matches forsender/speaker/participant; hermes’stools/memory_tool.py likewise has none. Identity reaches memory only because it is already in body text and memory accepts that unchanged. Two implementations of the same behavior, shown together. Key lines on both sides were checked verbatim.
Only one point is shared—names in content. They differ on four other choices, each addressed in section 08.
| Difference | openclaw | hermes-agent | Cost difference |
|---|---|---|---|
| Include stable ID in label? | Yes,Name (id); name→username→tag with ordered fallbacks; ID usese164→id |
No, onlyuser_name |
openclaw adds ten lines so renamed or identically named people remain distinguishable |
| Prefix private chats? | No; put names in envelope headers[Discord Person B id:702 12:04] |
No; the first condition isdm → False |
Both agree: a one-to-one session already identifies the person |
| Strip prefixes from history? | Strip the additional JSON block,strip-inbound-meta.ts317 lines total; retain the body prefix |
No stripping code | openclaw’s 317 lines pay for JSON blocks, not prefixes |
| Explain in the system prompt? | Explanation lives in a user-role “untrusted metadata” block, explicitly because sender names are external input and must not enter authoritative system prompts; changing IDs in system prompts would also invalidate prefix caches | Add one sentence for multi-person sessions, deliberately avoiding names for the same prefix-cache reason | Same reasoning, different placement |
| How are group sessions partitioned? | One session per groupagent:x:telegram:group:id; private chats default to all sharingmain; session.identityLinks merges one person’s accounts across channels |
Groups default toper-person isolation (group_sessions_per_user=True); threads default to shared |
hermes makes multi-person sessions opt-in; openclaw makes them the default |
_session_routing.py’s docstring states that the session_scope enum derives from openclaw’sdmScope; group/channel always share bypeer_id. Multiple speakers in a group are therefore the default, not an option requiring another decision. Sections 06–08 record the pre-security-review body-only intermediate design. It used hermes’s body form and openclaw’sName (id) labels and initially planned to modify only two files. It could neither provide trusted filter keys nor prevent body labels from creating false identities, so sections 09–12 replace it with dual representation. The final implementation retains body prefixes, independently carries structured speaker fields, and uses trusted frames in source archives.
This section describes the superseded intermediate design. It added a body prefix only inbase.py:_dispatch_and_reply, where both ch_msg.user_id andch_msg.user_display were available and which was thedispatch_inbound ’sonlycall site. The final implementation still generates the compatibility prefix here, but also passes both values independently to the dispatcher.
_records() reads only role, there is no trusted retrieval key. Parsing identity back fromcontent lets senders forge that key. The final implementation retains prefixes while making_records() read only persisted structured speaker fields. This section retains the original scope comparison between structured fields and body-only transport. The two-file approach on the right was later replaced because its trust boundary was insufficient. Current implementation uses structured transport on the left, plus source framing and retrieval interfaces.
channels/base.py, so they have no channel prefix and do not synthesize speaker identity. Channel messages carry both body prefixes and structured fields. Dispatcher, SourceRecord, writer/archive, BM25/inspect, andmemory_search all follow section 11’s final contract. The body-only two-file estimate remains historical design evidence only. This section retains six decisions from the reference audit. A changed to dual representation after security review; the others still apply.
A. Identity in content or structured fields?
Final choice: dual representation for current-turn visibility and the memory trust boundary respectively.
f"[{user_name}] {text}" (run.py:7759-7765); openclaw uses`${sender}: ${body}` (envelope.ts:214-219). The current-turn agent reads onlycontent, so the compatibility prefix remains.speaker_id/speaker_display derived only from channel messages. Writers and retrieval never infer new identity from forgeable content.B. Include a stable ID in labels?
Display names can change or collide; stable IDs persist but are not human-readable. References differ here.
Display name (id) (sender-label.ts:21-30, ten lines).base.py already has both fields, so adding the parenthesized ID needs no additional source.C. Prefix private chats too?
Both references omit private-chat prefixes because one-to-one sessions already identify the person.
session_scope: main, all private peers share one session (_session_routing.py), containing several people whilechat_type is alwaysdirect. Scope is computed by_conversation.py after obtaining the agent;base.py does not know it here. Skipping by private-chat status is neither safe nor decidable at this stage.user_id anduser_display empty), use an empty prefix.D. Sanitize externally supplied display names?
Senders choose platform display names, which are inserted directly into the writing-agent prompt.
render_conversation and archives. A newline in a name splits one record into two and breaks ref/content correspondence; square brackets can forge a second speaker prefix and misattribute content. openclaw does all three (sanitizeEnvelopeHeaderPart, with the comment that header content must not break the bracketed prefix).E. Strip prefixes from display and history?
openclaw wrotestrip-inbound-meta.ts, 317 lines, for this.
inbound-meta.ts:436-541, 106 lines), contains openclaw-specific reply chains, forwarding origin, group members, and location—separate from speaker identity.F. Isolate memory per person?
One shared memory is the product model, not a defect.
memory-wiki: a person is a Markdown page withcanonicalId+aliases+handles (markdown.ts:42-56, compile.ts:424-445), maintained by the model rather than a pipeline field. Ourtopics/people/ serves the same purpose, including merging one person’s cross-channel identities.openprogram --profile <name>).Compatibility prefixes remain in content, so a sender may type another label. That label remains body text without creating a new record identity. Trusted boundaries are persisted speaker fields, speaker fields in writer JSON objects, and valid source frames.
speaker_id/speaker_display enters node metadata only from channel messages; _records() never parses the body. Writer input encodes every real turn as one physical JSONL line. Only the complete JSON object’sspeaker field establishes identity;content newlines and forged records remain JSON string content. Source parsing starts only from the fixed format marker at byte zero ofsources/<provider>/_v2/<thread>.md, strictly accepts completerecord-lines frames, and skips body lines by declared count. Speaker IDs must use canonical UTF-8 percent encoding; malformed escapes or literal-- invalidate frames. An empty marker remains a valid display-only identity. Parsing stops at an invalid frame.user records. Speaker markers and forged frames there never establish trusted identity. Results usespeaker_trusted to distinguish v2 identity (true) from legacy hints (false). Change body text: apply display-name bracket rules at its beginning
The most direct approach, and the only one changing the user’s original words.
[ in Markdown links, list items, logs, and pasted code—the core material sent to programming agents. No rule can distinguish[2026-08-09] INFO ready from forged labels.Change prompt and input protocol: separate identity and content fields
Preserve everyone’s text; use no handwritten headers, XML, or Markdown fences.
ref, speaker, content use separate fields. Standard JSON encoding escapes LF, CR, quotes, and backslashes; rendering additionally escapes U+2028/U+2029 that a model might display as newlines. The prompt recognizes only thespeaker field;content text can establish neither identity nor a second record.Add fields: place speaker identity in runtime-populated slots
Structured fields and v2 framing are implemented.
speaker field, generated from runtime structured fields. New sources write only to fixed-marker-prefixed_v2/ files. Percent-encodedspeaker-id is trusted only within strictly validrecord-lines frames. Body labels remain because the current-turn agent reads onlycontent.| Who sanitizes what | Speaker label | Body | Source |
|---|---|---|---|
| openclaw | Sanitized:sanitizeEnvelopeHeaderPartremove newlines and replace square brackets with parentheses; the comment says header content must not break the bracketed prefix |
Not sanitized:${resolvedSender}: ${params.body}concatenate unchanged |
src/auto-reply/envelope.ts:58-67, 213-219 |
| hermes-agent | Not sanitized | Not sanitized:f"[{user_name}] {message_text}"Both unchanged |
gateway/run.py:7765 |
| Ours today | Sanitized with the same rules as openclaw | Not sanitized | channels/base.py:46, 243 |
| openclaw elsewhere | wrapPromptDataBlock: label untrusted text, enclose it, escape the enclosure’s<> so text cannot close its own enclosure, and remove control/format characters. Used for sub-agent outputs and internal events,noused for inbound channel body text |
src/agents/sanitize-for-prompt.ts:16-42 |
|
Name (id): .But openclaw elsewhere has the general rule: escape enclosure delimiters so enclosed text cannot close them. That principle is sound; here JSON field boundaries separate speaker and content, so the solution is structured serialization rather than changing people’s words. Current BM25 source retrieval can answer “What did Person A say about the budget?” using trusted speaker keys. Filters match only stable IDs, normalized display names, or complete source-record labels, not mentions in content or topic paragraphs.
sources/: topic paragraphs are the writer’s statements about a topic, not utterances by a person. “What did Person A say?” differs from “What is known about Person A?” The latter ispath_prefix=topics/people/, already available. Supplying speaker restricts retrieval to source records and reports that restriction in results.sources/<provider>/_v2/<thread>.md, usingrecord-lines:N to declare literal LF line counts. Parser and deduplication scans start at the file beginning and skip exactly N lines. Even valid hash anchors,source-id, speaker-id, and record lines inside content cannot create another event or block later real archival. Invalid/truncated frames stop parsing. Onlyspeaker-id within valid v2 frames is trusted. Markdown trailing double spaces, CRLF, and final newlines survive writer and workspace-runtime temporary-file flush/fsync/os.replace unchanged. Temporary files enter neither revisions, visible listings, nor stage copies. Old files stay frozen, support restricted compatibility parsing, and never participate in v2 deduplication. Results usespeaker_trusted=false to identify legacy hints.persist=False). Persistent cache format advances to v8 and ignores old entries missing current speaker or trust fields. | Framework | Can memory filter by person? | Implementation / gap | Source |
|---|---|---|---|
| hermes-agent | Yes, through an external provider it did not implement | Honcho writes each message’s through its respective peer object; every read interface acceptspeer, making speaker a persisted dimension. Its owntools/memory_tool.py is single-user;session_searchfilters role, not identity |
plugins/memory/honcho/session.py:365-373, 1025-1069 |
| openclaw | Can answer “about someone,” not “said by someone” | entities/<slug>.mdInclude itcanonicalId/aliases/handles, retrieved through paths, compiled people catalogs, or byboosting rather than filtering person pages. The model writes these fields manually;wiki_applyhas no corresponding parameter.memory_searchAccepts only{query,maxResults,minScore,corpus}; LanceDB uses raw vector retrieval withoutwhere;active-memorybefore retrieval, removes query lines beginning withsender |
memory-wiki/src/markdown.ts:42-101, query.ts:624-668, tool.ts:81-92, memory-core/src/tools.shared.ts:31-36, memory-lancedb/index.ts:260-262, active-memory/index.ts:2238 |
| codex-cli | Cannot | Memory distills into flat files under~/.codex/memories; state rows group only bythread_id, without speaker fields or a query interface |
codex-rs/memories/README.md, state/memory_migrations/0001_memories.sql |
| claude-code-leaked | Cannot | Memory-file frontmatter contains only{description, type}, type; inuser, this means the sole machine owner, not one of several speakers. Recall is the model selecting filenames from description lists. |
src/memdir/memoryScan.ts:13-19, memoryTypes.ts:14-20 |
| opencode | No long-term memory | No memory/recall tool category | Search formemory_search|recall|rememberzero matches |
| pi-ai | No long-term memory | Repository is a provider SDK | Search formemory|recallzero matches |
| pi-mono | No long-term memory | The only match isInMemorySessionRepo, an in-memory session store | harness/session/memory-repo.ts:5 |
| weclaw | No long-term memory | All 35 Go files yield only two log strings | messaging/sender.go:34,77 |
topics/people/, not this section’s requirement. The remaining six have nothing for this question and are recorded as absent. speaker_id and speaker_display independently of content and routingpeer_id into persisted nodes. Content retains the [Display name (id)] compatibility prefix, but writers/retrieval never infer new-record identity from content.SourceRecord.speaker_label generates canonical labels uniformly. Writer serializes every turn into one compact JSON object on one physical line, with ref, speaker, content as separate fields. Prompts recognize onlyspeaker and never parse labels incontent. New sources write only fixed-marker-prefixed_v2/ files. Speaker IDs use canonical UTF-8 percent encoding validated by round trip;record-lines isolates forgeable content. Parsing stops on invalid frames, and only markers in valid v2 frames are trusted. Valid markerless records remain speakerless. BM25 v8 filters by stable ID, display name, or label in combination with path/date filters; results come only fromsources/.memory_search exposesspeaker; combining embeddings and speaker explicitly returnsINVALID_ARGUMENT rather than ignoring the filter.owner/local. Paired channels retain their channel-provided stable speaker IDs. Historical nodes and old framed records lacking trusted fields remain speakerless. Only unframed legacy records strictly headeduser may use limited historical-prefix retrieval compatibility. Legacy archives are frozen and excluded from v2 deduplication. Valid v2 events for the same source ID take precedence in retrieval, linking, and validation; absent v2, legacy remains readable. speaker_trusted is true only for trusted v2 markers; pending, legacy hints, and speakerless records are false. Regressions cover Markdown trailing double spaces, CRLF/final newlines, complete forged blocks, truncation stops, old-cache rebuilding, and independent-process imports.memory_search source results use real#source-... anchors and show the calling agent speaker_trusted, speaker_id andspeaker_display.runtime.json’scursors reads validated legacy headers and strict valid v2 prefixes, merges node markers per session, then deletes old fields. It never extracts IDs from body content or after invalid tails.docs/reference/design/memory/overview.md’s “Who said it” section; product documentation is indocs/integrations/channels.md.owner/install/<16 hex> is read or first created only from the instance state directory’sowner.json, never inferred from speakers. Runtime has two tiers: local owners get the complete capability set, while paired channels have onlyreply and memory.source.append. Unpaired messages are not a third tier and never enter the agent. Unpaired group bodies archive only as pending sources and do not enter proactive extraction. The sole tool-execution check is inopenprogram/agent/permissions/approval.py::_gated_execute, after nonconfigurable hard constraints but before permission rules, approval, andbypass. It reads onlyprincipal_id and authority_tier, never body speakers. Synchronous continuations, Tasks, inbox, subagents, and spawned subprocesses propagate serializable fields explicitly and may only inherit existing tiers. Pairedmemory_update is restricted to creation or byte appends at the workspace layer. Pending sources can be promoted only by a local interactive owner throughmemory_promote; promotion records an audit event and writes Topics through the same writer transaction, skipping already-referenced sources. This does not change Source v2 record-lines:N framing orspeaker-id percent encoding. Background writers now use the default chat agent’s provider, model, and credentials;memory.writer.model can override them live;memory.backend=none disables prompt injection, recall, automatic writing, organization, and memory threads, and rejects all CLI memory verbs and/api/memory/* routes before workspace initialization. Tools, CLI, and Web can query the writer’s last success, failure category, retryability, and pending count. One-off memory backfill is implemented: processes only trusted Sources not referenced by Topics, ignores markers, excludes pending, and reuses ordinary writer transactions per batch. The official workspace still has 23 v1 source files using the oldorigin_scope field. Readers accept only its exact canonical structure and map it to current tiers without rewriting append-only files. All 154 frames passed scanning, then two pending messages completed Topic transactions and marker writes. This real acceptance did not run backfill, leaving 152 unreferenced frames in the official workspace. Combined tests cover the complete SessionDB-to-watcher write sequence. Hold queues, requester-tier read filtering, and remote Web authentication remain deferred. Write-failure reason codes are a closed enum; restricted writer stages cannot see Source archives.