Synced from Hive. This page is pulled from hivecommons/hive@v5 during the docs build. Edit the canonical source in the Hive repository.
Draining CLI auto-memory into draft knowledge (#17033)
Status: shipped. The three open questions in #17033 are decided below; the work is cut into three implementation children, listed in Implementation plan. Each phase shipped with the differences recorded in Phase 1 as shipped, Phase 2 as shipped and Phase 3 as shipped.
All citations are against origin/v5 at 7a929356. The CLI-memory isolation
and disable work this design builds on (#17031, #17032) is in open PRs
#17039 and
#17040. Their symbols are
named below without line numbers because they are not on v5 yet.
The problem: a hidden, per-agent store
Every coding CLI Hive drives keeps its own persistent memory across sessions:
| CLI | Store | Written by |
|---|---|---|
| Claude Code | ~/.claude/projects/<project>/memory/MEMORY.md plus sibling topic files | auto memory |
| Codex | $CODEX_HOME/memories, $CODEX_HOME/memories_v2 | the memories feature |
| Gemini CLI | ~/.gemini/GEMINI.md (the save_memory tool appends to it) | the model |
| Copilot CLI | none on disk — Copilot Memory is stored on GitHub | — |
Hive does not see what is in those stores. A one-off inference written on
Tuesday comes back on every later session, it is not scoped, nobody reviews
it, and until #17039 lands it is shared across agents: v5 bridges ~/.claude,
~/.gemini and ~/.codex whole into the shared home
(src/crux/agent/interactive_home.go:68-71).
From the 2026-11-05 community meeting, quoted in #17033:
the CLIs’ built-in memory “creates its own hidden bead storage … like 60% of these should have been beads … is there a create-memory hook where instead we could overload that for our CLIs and make it inject a bead instead? I pulled them down locally, created them as beads, and turned that feature off on the CLI.”
#17040 does the “turned that feature off” half. With agents.cli_memory it
seeds each CLI’s documented disable (autoMemoryEnabled: false for Claude
Code, [features] memories = false for Codex). With clear_on_kick it wipes
the agent’s own store before each kick (clearCLIMemoryLocked, called from
deliverKickLocked). This design does the “created them as beads” half: what
a CLI writes to its memory store becomes a reviewable, scoped,
lifecycle-managed knowledge entry instead of being lost or kept hidden.
The destination already exists
Nothing new is needed on the read side.
- Lifecycle states. Every fact is
draft,approved,deprecatedorsuperseded(src/pkg/knowledge/lifecycle.go:16-21). OnlyapprovedpasseslifecycleAllowedunless a caller opts other states in (src/pkg/knowledge/lifecycle.go:88-98). The kick primer filters withFilterByLifecycle(src/pkg/knowledge/primer.go:144). See Knowledge lifecycle states. - Per-agent scope.
knowledge.agent_scopes(src/pkg/config/knowledge_config.go:43-46) binds an agent to layers, repos, types, tags and admitted states. It is resolved for the kick primer inprimeKnowledge(src/pkg/scheduler/kick_priming.go:256-305). See Scoped knowledge table of contents. - A write path.
POST /api/knowledge/create(src/pkg/dashboard/api.go:389, handler atsrc/pkg/dashboard/api_knowledge.go:577-610) creates a fact from aCreateFactRequest(src/pkg/knowledge/api.go:293-300) and audits it asknowledge_create_fact.bd kbalready talks to the dashboard API rather than the local bead store (Beads CLI). - State changes and review.
PUT /api/knowledge/entry/{id}/stateandhivectl knowledge statemove a local-channel fact between states with an audit entry.UpdateFactRequest(src/pkg/knowledge/api.go:335-344) already carriestagsandstate. - The curator.
knowledge.curator(KnowledgeCurator,src/pkg/config/knowledge_config.go:269-289) drivesPromoter.AutoPromoteCandidates(src/pkg/knowledge/promote.go:127) on a schedule throughPromotionScheduler(src/pkg/knowledge/curator_schedule.go:31). It is opt-in and off by default. See Knowledge promotion configuration.
Two gaps on the write side remain, and phase 1 closes them:
bd createhas noknowledgetype (src/docs/beads-cli.md:11lists the types), and there is nobdverb that creates a fact.CreateFactRequesthas nostatefield, so there is no way to create a fact asdraft. A fact with nostate:frontmatter loads asapproved, so a drained entry written through today’s create path would reach primers immediately. That is the reason the field is required, not a nicety.
What is missing is the drain: a step that moves what is in each store into
draft facts, then empties the store.
Bead shape
Each memory entry becomes one knowledge fact, created through the same verb the hook and the manager both use:
bd create --type knowledge --state draft \
--tag auto-memory --tag agent:<name> --tag cli:<claude|codex|gemini> \
--tag memhash:<16 hex> \
--title "<first line of the entry, ≤ 80 chars>" --body-file <entry>
bd create --type knowledgeis routed toPOST /api/knowledge/create(asbd kbis), not to the local bead store. That keeps facts in one place: the vault the curator, the TOC andhivectl knowledge statealready work on.- Layer:
project, in the local vault channel, so the existinghivectl knowledge statepath (local channels only) can approve or discard it. Type defaults topattern, the existing create default. - State: always
draft. The create path rejects--tag auto-memorywith any other state, so a caller cannot create an approved entry directly. - Tags: an allow-list, set only by the drain code and never parsed out of
the entry:
auto-memory,agent:<name>,cli:<backend>,memhash:<hex>.<name>must match the agent-name grammar and<backend>is one of the three CLIs. Anything else is refused. - Entry splitting. Claude Code: one entry per topic file in the memory
directory, plus one per top-level bullet or
##section ofMEMORY.md(the index lines that only link to a topic file are dropped). Gemini: one entry per bullet under the## Gemini Added Memoriesheading ofGEMINI.md; the rest of the file is operator context and is never touched. Codex: one entry per file undermemories/andmemories_v2/(the on-disk format is not a stable contract, so files are taken whole). - Content hash.
memhashis the first 16 hex characters of the SHA-256 of the normalised entry: LF line endings, trailing whitespace stripped, runs of blank lines collapsed. The hash is over the body only, never the title or the agent. - Size cap. An entry over 8 KiB is stored truncated with a visible
[truncated by drain]marker. The hash covers the full normalised entry.
Truncating the store
The store is emptied only after every entry from that file has either created
a fact or matched an existing one (see dedup below). Claude topic files are
removed and MEMORY.md is truncated to zero bytes. The Gemini memories section
is removed and the rest of GEMINI.md is kept. Codex files are removed.
A file whose drain fails part-way is left untouched and retried on the next drain. That is safe because dedup makes the drain idempotent.
Reads and the truncate follow the same rules as #17040’s
cliMemoryStores. Only stores private to the agent are drained. The plan
was to Lstat every path component and to truncate and remove as the agent
through the same su-exec path as cliMemoryRemover; as shipped, the drain
runs in the hive process and instead opens every memory file through an
os.Root anchored at its store (see “Phase 2 as shipped” below and #17251).
A store that is still shared (a legacy whole-directory
bridge, a UID 0 agent, HIVE_SHARED_AGENT_HOME=1) is not drained. It
cannot be attributed to one agent, and truncating it would wipe other agents’
memory. It is logged once per agent, exactly like clear_on_kick, and is left
to the one-shot legacy migration in phase 3.
The drain, per CLI
The drain logic lives in one Go package inside the Crux module
(src/crux/agent/memdrain, beside cli_memory.go). It does not live in
src/pkg: the agent manager that calls it is in src/crux, and the Crux
outbound ratchet (src/internal/testutil/crux_outbound_ratchet_test.go)
forbids new imports from Crux into src/pkg. The package talks to the
knowledge store only over the dashboard API, the same way bd does, so it
needs no pkg/knowledge import. A thin hive memory-drain subcommand wraps
it for the Claude hook.
Claude Code: a SessionEnd hook
Claude Code has a hook surface (hooks reference), so its drain runs inside the session’s own lifecycle rather than waiting for the next kick:
SessionEndrunshive memory-drain --cli claude --agent "$HIVE_AGENT"over$HOME/.claude/projects/*/memory/. The hook gets the sessioncwdon stdin, which narrows the drain to that project’s directory.PostToolUseonWrite|Edit|MultiEditis optional and off by default. Its command exits at once unless the tool’sfile_pathis under~/.claude/projects/*/memory/, so the cost on every other write is one process spawn and a path compare. It exists for long-lived sessions that rarely end.SessionEndalone is the floor.- Seeding. The hooks are merged into the settings Hive already seeds. For
interactive agents that is
sharedClaudeSettingsFile(src/crux/agent/claude_remote_control.go:44-46), which #17039 keeps as a per-agent bridge to one fleet file. For inference agents it is the two filesensureClaudeSettingswrites (src/crux/agent/manager_homes.go:624). The merge goes throughseedJSONFile(src/crux/agent/manager_homes.go:582), so an operator’s ownhooksentries are kept and Hive adds its entry only when it is absent. The command reads the agent fromHIVE_AGENT, so one fleet-wide entry serves every agent. - Backstop. A session killed hard does not run
SessionEnd, so the manager also drains Claude stores at kick time (below). Dedup makes the double coverage free.
Codex, Gemini CLI: manager-side, at kick time
Neither CLI documents a hook that fires on a memory write. The manager drains
their stores itself at the same point #17040 clears them: inside
deliverKickLocked (src/crux/agent/manager_kick.go:242), after
rotateKickLogOnKickLocked (src/crux/agent/manager_kick.go:271) and
before clearCLIMemoryLocked.
- Codex:
$CODEX_HOME/memoriesand$CODEX_HOME/memories_v2, the pathscliMemoryStoresalready resolves forcodexBackend. - Gemini CLI: the
## Gemini Added Memoriessection of$HOME/.gemini/GEMINI.md, the filecliMemoryStoresalready resolves. - Claude Code: the hook backstop described above, over the same
projects/*/memorypaths. - Copilot CLI: nothing to drain. Copilot Memory is stored on GitHub, not on
disk, as #17040 records.
~/.copilot/session-stateholds transcripts, not memory, and is out of scope. If Copilot CLI ships a local memory store, it joins the table above.
Ordering with clear_on_kick: drain first, then clear. If the drain fails
(dashboard unreachable, knowledge disabled), the files are moved into a
Hive-owned spool (/data/knowledge/auto-memory-spool/<agent>/) instead of
being left in place. The clear still runs, so the kick still starts clean, and
the next drain retries the spool first. Neither the drain nor the spool ever
blocks a kick. Both are best-effort and logged, like clearCLIMemoryLocked.
Configuration
One new key next to #17040’s block, off by default:
agents:
cli_memory:
auto_memory: false # #17040
clear_on_kick: true # #17040
drain: false # this design: drain stores into draft knowledge
drain_on_write: false # Claude PostToolUse hook (needs drain: true)
drain is useful mainly to operators who turn auto_memory back on, and to
the one-shot migration of stores written before #17040. It needs
knowledge.enabled. With knowledge off, the drain logs once and does nothing,
and the store is left alone (or cleared, if clear_on_kick says so).
Decisions on the open questions
#17033 left three questions open. They are decided here. The maintainer can override any of them on #17033 or on the phase that implements it.
1. Dedup and merge across agents
Decision taken by hive: dedup on the content hash at create time. Before
creating, the drain looks up memhash:<hex> across every lifecycle state.
- No match: create a new
draft. - Match: no new fact is created. If the match lacks this agent’s
agent:<name>tag, the drain adds it throughUpdateFactRequest.Tags. A second agent reaching the same inference becomes a second tag on the one fact, not a second fact. - Match in
deprecatedorsuperseded: the tag is still added, but the state is not reset. A discarded memory that a CLI writes again stays discarded. Otherwise the drain would undo the reviewer’s work every session.
Semantic merge (two differently-worded entries that say the same thing) is
not done by the drain. That is the reviewer’s call, made with the existing
superseded_by link.
Rationale. An exact hash is deterministic, cheap and cannot merge two
different claims by mistake. Hive’s knowledge design already prefers
deterministic steps over using one model to verify another
(knowledge system). The agent: tag count is
also a useful review signal: an entry three agents wrote on their own is worth
reading first.
2. The curator or a bespoke hook
Decision taken by hive: the drain is a thin ingest step, a hook for
Claude Code and the manager for the rest. It only ever creates draft facts
and adds tags. The existing knowledge.curator remains the sole promoter;
no second curator is built. Approval of a drained draft goes through the paths
that already exist: hivectl knowledge state <id> approved,
PUT /api/knowledge/entry/{id}/state, or the dashboard knowledge view.
Rationale. The curator answers what is worth promoting; the drain only
answers where this text goes. Running ingest through the curator would tie
memory capture to knowledge.curator.enabled, which is off by default. It
would also run on the curator’s schedule rather than at session end, and would
mix untrusted input into the only component allowed to raise trust. A
second promoter would split review across two places. So the drain is
ingest-only and the curator stays the only path to approved. knowledge.curator
today only promotes facts between layers. Phase 3 makes sure it never selects
an auto-memory draft.
3. Prompt-injection posture
Decision taken by hive: drained text is untrusted, agent-written content.
- The body is stored verbatim as the fact body. It is never interpreted, so nothing in it can set the title’s provenance, the tags, the state, the layer or the type. Frontmatter-looking lines in the body are escaped so they cannot become frontmatter.
- Tags come only from the allow-list above and are set by the drain code, never parsed from the entry.
- No
auto-memoryfact is rendered into any kick, primer, TOC entry an agent reads, or public MCP response while it isdraft. This holds even for an agent whoseknowledge.agent_scopesentry admitsdraftthroughinclude_states. Phase 3 adds that hard exclusion to the primer and the agent-identified TOC/entry reads. A human, or the curator’s approval path, must move the fact toapprovedfirst. Operator reads (GET /api/knowledge, the dashboard) still show drafts so they can be reviewed. - The drain never runs the content and never follows links in it.
Rationale. A memory file is written by a model that may have read
attacker-controlled text (an issue body, a PR comment, a fetched page). If the
drain auto-primed that text, a single poisoned session would become an
instruction delivered to every later kick, across agents. That is worse than
the per-agent store it replaces. Keeping it draft and unread until a reviewer
approves it is the scoping asked for in #17033: “I don’t want a developer agent
working another ticket to pull that information in yet.”
Relation to the knowledge-system design
This resolves open question 5 of knowledge-system.md
(“should ~/.claude/projects/*/memory/ be migrated into the wiki?”). The
answer is yes: as draft knowledge facts, through the drain, after which the
memory file is emptied. The destination is the project layer’s local vault
rather than a separate personal wiki, so the existing review and scope tooling
applies.
Implementation plan
Each phase is one child issue of #17033. Phases 2 and 3 are blocked by phase 1.
Phase 1 depends on #17040 (it reuses cliMemoryStores and runs beside
clearCLIMemoryLocked). Attribution to one agent depends on #17039 (per-agent
CLI homes).
Phase 1 — drain library and manager-side drain (Codex, Gemini, Claude backstop)
Child: #17056.
CreateFactRequestgainsstate(draftorapproved, default unchanged).POST /api/knowledge/createrefusesauto-memory-tagged requests that are notdraft, and refuses tags outside the allow-list on those requests.bd create --type knowledge [--state] [--tag]…routes to that endpoint. Documented in Beads CLI.src/crux/agent/memdrain: read a private store, split, normalise, hash, look upmemhashacross all states, create the draft or add theagent:tag, truncate after success, spool on failure.agents.cli_memory.drainconfig key with the settings-parity entry.- Manager drain in
deliverKickLockedbeforeclearCLIMemoryLocked, for Codex, Gemini and (as backstop) Claude Code stores. - Tests: splitting per CLI, hash stability, dedup-adds-tag, a discarded fact is not revived, partial failure leaves the file, symlinked or shared stores are skipped, spool and retry, kick never blocked.
Phase 1 as shipped
Phase 1 landed in #17086 and the follow-up for #17056. Where the shipped code differs from the plan above, the shipped code is authoritative:
- Drain library. The drain is
src/pkg/knowledge/climemory, run in the hive process and installed on the in-process runtime throughcrux.CLIMemoryDrainHost, not an HTTP-onlysrc/crux/agent/memdrain. It writes into thebead-synth-wikivault the Primer already reads, so the Crux module still imports nothing fromsrc/pkg. - Names. The config key is
agents.cli_memory.drain_to_knowledge, and the provenance tag iscli-memory(withsource: cli-memoryfrontmatter) rather thanauto-memory. - Dedup. As designed: a duplicate in any state only gains the draining
agent’s
agent:<name>tag; its state is never reset. - Store emptying. The drain does not truncate the store;
clear_on_kickdoes. A file whose facts did not all reach the vault is copied to/data/knowledge/auto-memory-spool/<agent>/before the wipe and retried on the next drain, which gives the “partial failure loses nothing” property without a second remover running as the agent. - Create path.
CreateFactRequest.stateand thecli-memorytag allow-list onPOST /api/knowledge/create, andbd create --type knowledge, shipped as planned; a draft is written into the local channel named bylayer.
Phase 2 — Claude Code SessionEnd and PostToolUse hooks
Child: #17057.
hive memory-drainsubcommand wrapping the phase-1 library (reads hook JSON on stdin,HIVE_AGENTfrom the environment).- Seed
hooks.SessionEnd(andhooks.PostToolUse, matcherWrite|Edit|MultiEdit, whendrain_on_writeis on) into the Hive-seeded Claude settings throughseedJSONFile, merge-only, and remove Hive’s own entry whendrainis turned off. - Tests: seed and merge keep operator hooks, the hook exits fast on writes
outside the memory directory, end-to-end hook → draft fact → empty
MEMORY.md.
Phase 2 entry point (#17208)
The hook runs as the agent’s UID, so it can neither write the hive-owned
vault nor reach the in-process drainer. It asks the hive instead: the
hive memory-drain command (#17209) drops {"agent", "cwd", "truncate"} into
/var/run/hive-metrics/memory-drain-requests, the same drop-box as the GitHub
relays, and reads <name>.result.json back.
- Auth. The owner UID of the request file is the caller’s identity. The
runtime (
crux.CLIMemoryDrainNowHost) refuses a request naming any other agent. Every request, refused or not, is audited ascli_memory_drain_requested. - Sources. Resolved from the agent’s own home with
cliMemoryDrainSources, never from the request.cwdonly narrows them to the Claude project slug Claude derives from it. - Truncate. With
truncate, a drain that reports no error is followed byDrainer.TruncateDrained, which empties only the files whose everymemhashis in the vault. - Drain off. With
agents.cli_memory.drain_to_knowledgeoff for the agent, the answer isokwithran: falseand nothing drained.
Phase 2 as shipped
Phase 2 landed in #17208 (entry point above), #17209 and #17210. Where the shipped code differs from the plan above, the shipped code is authoritative:
- Command.
hive memory-drain --cli claude --agent "$HIVE_AGENT"does not wrap the drain library: it queues a request for the entry point above, withtruncateset and the hook’scwd, and waits up to 10 seconds (--wait) for the answer. Every outcome exits 0; a refusal, a timeout or a missing request directory is a warning on stderr, and the kick-time drain remains the backstop. - PostToolUse. The command exits before queuing anything unless
tool_input.file_pathis under~/.claude/projects/<slug>/memory/(or$CLAUDE_CONFIG_DIR/projects/<slug>/memory/). Its request carrieson_write, which the hive answers as a no-op unless the agent hasdrain_on_write. - Open through the store root (#17251). The drain never reads or
truncates a memory file by path. Each
Sourceis resolved withEvalSymlinksand dropped unless it still lies under the agent’s own resolved home (a slug or.claudedirectory symlinked at another agent’s store is not drained). The store is then opened as anos.Root(the parent directory for a single-file store such asGEMINI.md), checked withSameFileagainst whatLstatsaw, and every listed file isLstated through the root — a symlink entry is refused even when its target stays inside the root — opened through it, required to be a regular file within the size cap and the same inodeLstatreported, andTruncateDrainedtruncates through that read descriptor. A path swapped for a symlink between the listing and the open lands inkeptand the drain error; nothing outside the store is ever read or emptied. - Keys. The new key is
agents.cli_memory.drain_on_write, fleet-wide and per agent, and it has no effect withoutdrain_to_knowledge. - Seeding. Not through
seedJSONFile, which never touches an existing top-level key: a separate merge works perhooks.<event>. It adds Hive’s entry only when no matcher group already runs the exact command, keeps every operator entry, and when the hook is off removes only Hive’s command (and a group or event it leaves empty). The settings files are shared, soSessionEndis seeded while any agent hasdrain_to_knowledgeandPostToolUsewhile any agent hasdrain_on_write; the hive’s per-agent answer keeps every other agent a no-op. Both are merged into the shared interactive settings file and into the two filesensureClaudeSettingswrites for inference agents.
Phase 3 — review flow, draft exclusion, legacy migration, operator docs
Child: #17058.
- Hard-exclude
auto-memoryfacts indraftfrom the kick primer, agent-identified TOC and entry reads, and the public MCP, even wheninclude_statesadmitsdraft. Make surePromoter.AutoPromoteCandidatesnever selects them. - A dashboard knowledge filter for
auto-memorydrafts that shows theagent:tags and approve and discard actions (backed by the existing state API). hivectl knowledge drain-memory --legacy: a one-shot, operator-run drain of the shared/data/home/.claude/projects/*/memorystore that #17039 leaves behind. Taggedagent:shared, never truncated without--truncate.- Operator docs: an
agents.cli_memory.drainsection in the operator reference, an “Auto-memory drafts” section in Knowledge lifecycle states, and the status line in the design index moved to partly shipped or shipped.
Phase 3 as shipped
Where the shipped code differs from the plan above, the shipped code is authoritative. User-facing behaviour is documented in Auto-memory drafts.
- Marker. The drain shipped with the tag
cli-memoryandsource: cli-memoryfrontmatter, notauto-memory.knowledge.IsAutoMemoryaccepts either marker. A fact is excluded when its effective state isdraft. - Exclusion.
FilterByLifecycleFor(the kick primer and the knowledge advisor) never admits an auto-memory draft.TOCScope.HideAutoMemoryDraftsdrops it from every scopeAgentScopebuilds and from every?agent=TOC, entry and effective read. The operator filterFilterByLifecycleand operator TOC reads still list it. The public MCP was already approved-only. - Review in the reserved vault. Drained entries live in
bead-synth-wiki, whichPUT /api/knowledge/entry/{id}/stateused to refuse outright. It now accepts entries whose frontmatter sayssource: cli-memory, because the drain never rewrites an existing entry. The dashboard’s Discard setsdeprecatedrather than deleting, so thememhashkeeps the fact from being re-drafted. - Auto-promotion.
AutoPromoteCandidatesskips any page taggedcli-memory, whatever its status. - Legacy drain.
hivectl knowledge drain-memory --legacy [--truncate]calls the owner-onlyPOST /api/knowledge/cli-memory/drain-legacy, which runs the phase-1 drainer in the hive process. With--truncateit empties only the files whose every fact’smemhashis now in the vault. - Config key. The operator reference documents the shipped key,
agents.cli_memory.drain_to_knowledge. There is no separateagents.cli_memory.drain.
References
- #17033 — this design’s issue.
- #17031 / #17039 — per-agent CLI homes.
- #17032 / #17040 —
agents.cli_memory: disable auto-memory, clear on kick. - #11102 — knowledge lifecycle states.
- Knowledge system design, knowledge lifecycle, scoped knowledge TOC, knowledge curator.