1. Executive Summary
Helm is a single-owner personal agent that runs on
the owner's own machine. Messages arrive from Discord, iMessage or a
terminal client; index.js shells out to the
claude CLI with the workspace and the home directory
mounted; the agent acts and reports back. Memory is not a product
surface here — it is one SQLite file, five columns of bookkeeping bolted
onto it, and a background process that reflects every fifteen
minutes.
The design worth reading the code for is the evidence
gate in workspace/memory/memory.mjs:87-162. A
write tagged --source observed — the tag the background
reflection loop uses when it thinks it has noticed something about the
owner — is capped at PROVISIONAL_CAP = 0.7 no matter what
confidence the caller asked for. It can only rise by 0.05 per
independent repeat, tracked in evidence_count. A
first observation is therefore structurally incapable of being stored as
certain. That is the evidence before belief
pattern implemented in about fifteen lines of SQL and arithmetic, in a
project whose whole memory layer is 401 lines.
The apparatus around it is unusually complete for its size.
Supersession expires the old row and keeps it (expired_at),
so history <key> returns the chain. Decay in
consolidate.mjs applies only to single-evidence facts,
exempts rows sourced from CLAUDE.md, and is slowed by
retrieval — log1p(access_count) is subtracted from the
number of stale weeks, so a fact the agent keeps reaching for holds its
confidence. Recall fuses BM25 with a genuinely local MiniLM embedding by
reciprocal rank, and degrades to TF-IDF cosine and then to keyword-only
without saying so. Retrieval quality is anchored by behavioural tests
that assert ranking, not just round-trips:
smoke.mjs:892 asserts a high-confidence fact outranks a
low-confidence match, smoke.mjs:1253 asserts BM25
term-frequency ordering.
And then the number is thrown away at the point of
use. recallMemories
(index.js:490-510) formats the top eight facts as
- (kind) key: value and prefixes them with
MEMORY — use these, never contradict them. No confidence,
no evidence count, no source, no id. A preference the loop guessed once
from a transcript and a fact the owner stated directly reach the model
as the same kind of sentence, under an instruction not to contradict
either. The one channel that does carry the number — the generated
INDEX.md, imported at CLAUDE.md:15 — prints
_(conf 0.62)_ for preference rows and omits it
for every other kind. The system spends real effort deciding how much to
believe each fact and then declines to tell the consumer.
The second weakness is structural rather than epistemic. Active rows
are identified by the predicate expired_at IS NULL,
enforced by a partial unique index
(memory.mjs:54-64), and every reader has to remember it.
Three do not: getAutonomyMode (index.js:244)
reads the agent's own autonomy setting without the filter and will
return the stale pre-supersession value; the distillation lookup in
consolidate.mjs:74 can write a distilled value onto a dead
row; the dedupe pass at consolidate.mjs:146 selects
candidate rows without the filter, so the branch that fires can delete
superseded history and sum retracted evidence onto the survivor. A
soft-delete predicate with no view and no accessor is a design that only
works while every author remembers it.
Where it is weakest overall: three memory surfaces are advertised to
the model and only one of them is in the repository. mind
drives a Markdown vault whose "AI-first note format" — frontmatter
confidence, recency, a
## Contradictions section with open | resolved
status — is defined entirely in a prompt
(workspace/mind/MIND.md) and parsed by no code. Twelve
cortex.* tools are registered as "the 5-layer memory
stack", and their backend is gitignored and absent.
2. Mental Model
A memory is a (kind, key) slot holding one
current value. kind is an open string used by
convention — preference, learned,
note, goal, profile,
exam, fact — not an enum in code. The key is
meant to be stable and short; the CLAUDE.md guidance and the think
prompt both tell the agent to reuse a key rather than mint one.
Alongside the slots is an episode log: one row of free text per notable turn, with a channel tag and a timestamp. Episodes are the raw stream; facts are what survives it.
Confidence is a float, not a state. But there are effectively two grades, distinguished by who wrote the row and how it was tagged:
stateDiagram-v2
direction TB
[*] --> Provisional: remember --source observed<br/>conf capped at 0.70, evidence 1
[*] --> Asserted: remember untagged or --force<br/>conf as requested, 1.0 by default
Provisional --> Corroborated: same value again<br/>conf climbs 0.05 per repeat
Asserted --> Corroborated: same value again<br/>conf ratchets to the highest seen
Provisional --> Decaying: 30 days untouched<br/>while evidence is still 1
Asserted --> Decaying: 30 days untouched<br/>unless sourced from CLAUDE.md
Decaying --> Corroborated: re-asserted with new evidence
Decaying --> Pruned: conf falls below 0.05
Provisional --> Expired: a different value arrives<br/>for the same key
Asserted --> Expired: a different value arrives<br/>for the same key
Corroborated --> Expired: a different value arrives<br/>for the same key
Expired --> Deleted: the dedupe branch,<br/>on a legacy database
Pruned --> [*]
Deleted --> [*]
note right of Corroborated
Immortal against time. Decay and prune both
require evidence_count below 2, so only a
replacement ends a corroborated fact.
end note
note left of Deleted
forget id reaches every state — the delete
filters on id alone, expired rows included.
Hard DELETE either way, with no record of
the value that was removed.
end note
Decay is conf *= 0.9 per stale week, with
log1p(access_count) subtracted from the week count first,
so retrieval slows the fall without ever reversing it.
Three properties of this machine matter.
Corroboration is the only thing that raises belief.
Nothing in the system verifies a fact against the world. A repeat
observation of the same value is the entire evidence model, and it is
deliberately weak: 0.05 per repeat means six independent
sightings to reach 1.0 from the cap.
Death is asymmetric. Being replaced is
recoverable — the row stays and history shows it. Being
forgotten or pruned is not: both are
DELETE FROM facts, and nothing records that the value ever
existed. So the system remembers what it changed its mind about but not
what it discarded, and the next reflection tick is free to re-derive a
pruned fact from the same episodes that produced it.
Who moves memory between states. Almost everything
is agent-controlled but not owner-visible. The owner writes memory only
by talking — either the hot-path regex catches an explicit "remember
that …", or the reflection loop reads the transcript later and decides
for itself. There is no review surface:
unsure --threshold 0.7 lists the weakly-evidenced
preferences, and the weekly prompt tells the agent to re-assert the ones
it found evidence for, but no code takes an owner verdict and writes it
back. The nearest thing to human adjudication is the owner saying
something different next time and hoping the loop notices.
3. Architecture
Helm is a set of Node processes on one machine, plus the
claude CLI as the reasoning engine. There is no server, no
container, no vector service.
index.js(1,241 lines) — the Discord brain. Owner-locked toOWNER_ID, holds the session, assembles the prompt, spawnsclaude -pwith--append-system-prompt,--add-dir WORKSPACE,--add-dir ~.imessage.jsandcli.jsare alternative front doors onto the same session.workspace/memory/— the store.memory.mjsis the CLI and the schema;embed.mjsthe embedding cache;consolidate.mjsthe nightly hygiene pass;refresh-index.mjsthe prompt-facing Markdown projection;migrate.mjsa one-shot CLAUDE.md importer.workspace/think/think.mjs— background cognition as a long-lived process under launchd/systemd (com.helm.think), ticking everyTHINK_INTERVAL_MIN(default 15).workspace/tools/— a JSON registry of 135 shell-out tools, two of which arememory.rememberandmemory.recall.workspace/sessions.mjs— a second SQLite file mapping the single key'owner'to a Claude Code session id for--resume.
flowchart TB
subgraph FE["Front doors (owner-locked)"]
D["Discord DM"]
IM["iMessage"]
T["helm terminal"]
end
D --> BRAIN["index.js"]
IM --> BRAIN
T --> BRAIN
BRAIN -->|"raw message, 240 chars"| RC["recallMemories()<br/>spawnSync, 12s timeout"]
RC --> MEM["memory.mjs recall<br/>--with-episodes"]
MEM --> DB[("memory.db<br/>facts · episodes · vectors")]
RC -->|"8 facts + 6 episodes,<br/>confidence stripped"| PROMPT["--append-system-prompt"]
IDX["INDEX.md<br/>(@memory/INDEX.md in CLAUDE.md)"] --> PROMPT
PROMPT --> CC["claude -p<br/>--resume <sid>"]
CC --> SESS[("sessions.db")]
CC -->|"reply"| BRAIN
BRAIN -->|"detached, per turn"| CAP["captureTurn()<br/>episode + regex fact"]
CAP --> DB
subgraph BG["Background (launchd / systemd)"]
TH["think.mjs tick<br/>every ~15 min"]
WK["weekly deep review<br/>every 7 days"]
CONS["consolidate.mjs<br/>distil · decay · prune · dedupe"]
RI["refresh-index.mjs"]
end
TH --> CC
WK --> CC
TH --> RI
WK --> CONS
CONS --> DB
RI --> IDX
DB --> RI
subgraph OTHER["Advertised, not in this store"]
VAULT["HelmBrain vault<br/>mind.mjs, 8 prompt verbs"]
CX["cortex.* — 12 tools<br/>backend gitignored, absent"]
end
CC -.->|"tool call"| VAULT
CC -.->|"tool call, fails closed"| CX
Deployment and ergonomics
This is the cheapest memory deployment in the atlas that still has an
epistemic model. What has to be running: nothing. The
store is a SQLite file opened synchronously by whichever script needs
it, through Node's built-in node:sqlite — hence the hard
engines: node >=22.5.0. There is no separate dependency
for the database at all.
Fully local and offline, with one graceful degradation: semantic
recall wants Xenova/all-MiniLM-L6-v2 under
~/.helm-models, and embed.mjs:48-51 sets
allowRemoteModels = false, so it will never fetch
the model mid-flight. If the cache is cold,
ensurePipelineLoaded() returns false and retrieval falls
back to TF-IDF cosine.
node workspace/memory/embed.mjs download is the explicit
opt-in that populates it. Refusing to download at query time is the
right call and rarer than it should be.
No API key is required to store anything — memory is entirely local
arithmetic. A key is required to think, and even that is
optional: AUTH_MODE=custom points the engine at Ollama.
The store is human-readable and repairable by hand: it is one SQLite
file with three small tables, a dump verb that emits JSON,
and a generated INDEX.md in Markdown. Recovery from a bad
memory is sqlite3 or
memory.mjs forget <id>. Against that:
*.db is gitignored, so there is no committed fixture, and
every schema change is expressed as a
try { ALTER TABLE … } catch {} executed on every process
start (memory.mjs:41-49, repeated in
consolidate.mjs:29-35 and migrate.mjs:29-33).
It works, it is idempotent, and it means the real schema is the union of
five files rather than anything declared in one place.
4. Essential Implementation Paths
Capture — hot path. index.js:572-583
captureTurn(userText, reply, channel) runs after every
reply. It spawns two detached, unwaited children: an
episode add "<user, 160 chars> → <reply, 160 chars>",
and — only if the user text matches
\b(?:remember(?:\s+that)?|note\s+that|for\s+the\s+record|fyi[:,]?)\s+(.{4,200})
— a
remember learned note-<base36 timestamp> "<captured span>" --source observed --confidence 0.8.
This is the whole zero-LLM write path.
Capture — gateway backfill.
index.js:520 ingestEpisodes() bulk-inserts
text as episodes, deduped against the last 2,000 summaries.
catchUpDiscord() (index.js:537) uses it on
reconnect to absorb messages Discord did not replay while the bot was
down, marks the last seen id in
workspace/.discord-last-seen, and deliberately does not act
on the backlog — it ingests and sends one notice.
scanDiscordHistory() pages up to 2,000 messages on
demand.
Extraction / consolidation. Two passes, one LLM and one not.
The LLM pass is the weekly deep review (think.mjs:54-82,
WEEKLY_PROMPT): read dump, the last 50
episodes, and unsure --threshold 0.7; write 1-3 summary
episodes; turn themes appearing in 2+ recent episodes into
learned facts; re-assert low-confidence preferences that
got new evidence. It is instruction only — no schema, no validator, no
retry.
The deterministic pass is consolidate.mjs, four
stages:
- Distil (lines 44-92) — count word stems across
episodes in the window; any stem in ≥3 distinct episodes becomes a
learnedfact whose value is literallymentioned in N episodes (last: "…"), atmin(0.9, 0.4 + 0.1·N). - Decay (lines 94-123) —
evidence_count < 2andlast_seenolder than--decay-days(30) and notsource='CLAUDE.md':conf ·= 0.9^max(0, (weeksStale+1) − log1p(access_count)). - Prune (lines 125-134) —
DELETEwhereconfidence < 0.05andevidence_count < 2. - Dedupe (lines 136-158) — merge multiple active rows
per
(kind, key), summingevidence_countonto the highest-confidence survivor.
Retrieval. memory.mjs:164-326, one
function, no abstraction. Detailed in §6.
Context assembly. Two channels, and they behave differently:
- Static:
workspace/CLAUDE.md:15is the line@memory/INDEX.md. Claude Code resolves that import into the base prompt on every turn, so the whole active fact set — everypreferencewith its confidence, up to 30 rows per other kind, and the last 12 episodes — is present by default.refresh-index.mjsregenerates it after every think tick, writing to a.tmpandrenameSync-ing over the target so a reader never sees a partial file. - Dynamic:
index.js:601appendsrecallMemories(prompt)tobuildPersona(mode)in--append-system-prompt. The query is the raw user message, whitespace-collapsed, first 240 characters.spawnSyncwith a 12 second timeout — the comment explains the number is sized for the first-call MiniLM load. Facts whosesourcematches/^(CLAUDE|helm)\.md$/iare dropped as already-present persona text; eight facts and six episodes survive, truncated to 80/180/200 characters and wrapped inMEMORY — use these, never contradict them; don't recite them unless asked:. Every failure path returns''.
Update / delete / forget / conflict.
remember with a different value for a live
(kind, key) is the conflict path
(memory.mjs:120-152): stamp expired_at on the
old row, insert the new one with evidence_count reset to 1,
and write a fact superseded: <kind>/<key>
episode on channel memory. That episode is gated twice —
skipped entirely for keys starting with __smoke, and
collapsed to one row per six hours per key by updating the existing
episode's timestamp rather than inserting.
forget <id> hard-deletes the fact and its cached
vector. history <key> is the only reader that
intentionally omits the expired_at filter.
Schema. memory.mjs:13-64. Base tables
facts, episodes, links; five
columns added by guarded ALTER; the partial unique index
facts_kind_key_uniq ON facts(kind, key) WHERE expired_at IS NULL,
with a self-healing check that drops and recreates it if an earlier
non-partial version is found (lines 54-64). vectors and
episode_vectors are created lazily by
embed.mjs on first use, keyed by fact/episode id, with the
vector stored as a JSON string. links is created and never
written or read by any file in the repository.
Background worker. think.mjs. Lock file
with stale-PID reclaim via process.kill(pid, 0) (lines
148-160); skip if the self-upgrade lock is held; skip between 00:00 and
05:00; MAX_TICKS 500 or eight days wall-clock then
process.exit(0) and let the service manager restart it.
After the tick it scores the thought with
computeInterruptScore (lines 93-125) —
0.7·urgency + 0.3·relevance, where urgency is keyword hits
over three and relevance is the mean confidence of the top three
--keyword-only recall hits, plus 0.2 if a
kind=goal value appears in the text — and DMs the owner
above 0.65. The weekly mark is stamped only when the engine exited zero
and unsignalled (lines 211-215), so a timed-out review is retried rather
than silently skipped.
Tool surface.
workspace/tools/registry.json declares
memory.remember (kind, key,
value, confidence) and
memory.recall (query, limit). The
wrappers in workspace/tools/impl/ are thin
spawnSync shims. Notably memory.remember.mjs
never passes --source, so a tool-mediated write is never
observed and never hits the provisional cap.
Tests. workspace/tests/smoke.mjs, 2,317
lines, 88 labelled cases, of which about nineteen exercise memory.
Covered in §10.
5. Memory Data Model
facts(id, kind, key, value, source, confidence,
created, updated, -- base
evidence_count, last_seen, -- added by ALTER
valid_from, expired_at, access_count)
episodes(id, ts, channel, summary, raw_ref)
links(from_id, to_id, kind) -- created, never used
vectors(fact_id, vector TEXT, model, created)
episode_vectors(episode_id, vector TEXT, created)
Scoping: none on the fact table. There is no user,
project, session or agent column. The boundary is the process:
index.js refuses anything but OWNER_ID, and
sessions.mjs hardcodes the key 'owner' with
the comment "this bot is owner-locked". Episodes carry a
channel (discord, chat,
memory, weekly-review), and no read path ever
filters on it — the episode arm of recall scores the last 500 rows
regardless of origin. A scope that is stored and never applied is
exactly the case the atlas withholds scope_enforced
for.
Provenance is one free-text source
column: observed, consolidate,
CLAUDE.md, or null. It carries real weight for two
mechanisms — source='observed' triggers the confidence cap,
source='CLAUDE.md' exempts a row from decay and prune — and
it is unvalidated and self-asserted by the caller. There is no link from
a fact back to the episodes that produced it; the links
table that could express it is dead. So a distilled learned
fact says "mentioned in 5 episodes" without being able to name one.
Temporal fields. Four: created,
updated, last_seen, valid_from,
plus the nullable expired_at. This is the near-miss on
bi-temporality. The columns are shaped for it, history
orders by valid_from DESC, and the ambition is legible. But
valid_from is only ever written as unixepoch()
at insert time (memory.mjs:126-128, 156-158)
or backfilled to created (line 49). No caller can express
"this became true last March"; validity time is record time under a
different name. expired_at is likewise the moment of
recording the replacement.
There is also a live correctness hazard in last_seen.
Decay advances it by weeksStale · 7 · 86400
(consolidate.mjs:108-110) purely so the same decay step is
not re-applied on the next nightly run. It is an effective idempotence
trick and it destroys the column's stated meaning: after a decay pass,
last_seen no longer records when the fact was last seen.
unsure orders by last_seen ASC, so the
ordering of the weakly-evidenced list is partly a function of how many
decay passes have run.
Versioning and correction. Supersession chains are
real and readable, keyed on (kind, key). What is absent is
any record keyed on the value: nothing prevents the next
distillation or reflection tick from re-asserting a value that was
superseded, forgotten or pruned, because there is nothing to consult. No
TTL, no pinning beyond the CLAUDE.md source exemption, no
contradiction representation — a fact cannot disagree with another fact,
only replace it.
Separation of memory types is by kind
convention only, enforced nowhere. Episodic (episodes) and
semantic (facts) are genuinely separate tables with
separate retrieval arms and separate vector caches, which is the one
clean separation in the model.
The HelmBrain vault is a second, disjoint data
model: PARA directories of Markdown under ~/HelmBrain, with
a frontmatter schema (type, tags,
sources, last_updated,
confidence: high|medium|low, recency) and a
## Contradictions section carrying
status: open | resolved. It is defined in
workspace/mind/MIND.md and read by no code —
grep for last_updated or
Contradictions outside that protocol file and the prompt
strings that quote it returns nothing. The vault's trust model is a
request to an LLM, not a structure. The two stores are kept in sync by
instruction: the think prompt tells the agent to persist a fact
and mirror it into 02 People/About Me.md
(think.mjs:45-47). Nothing reconciles them, and nothing
detects divergence.
6. Retrieval Mechanics
One function, memory.mjs:164-326, and it is more
sophisticated than the file size suggests.
Candidate set.
SELECT * FROM facts WHERE expired_at IS NULL ORDER BY updated DESC, confidence DESC LIMIT 500.
Everything downstream — document frequency, average document length,
BM25 idf, both rank lists — is computed in JavaScript over those 500
rows on every call. This is the ceiling on the whole design: past 500
active facts, retrieval stops being able to see the older ones at
all, and the window is ordered by recency rather than relevance, so
the facts that fall out are the ones that have not been touched lately —
precisely the long-lived preferences the evidence gate worked hardest to
establish. There is no warning and no test at that boundary.
Query processing. Lowercase, [a-z0-9]+
tokens longer than one character, a 26-word stop list, and a three-rule
suffix stemmer (ies→y, strip ing|ed|s). An
empty query after stopping returns the most recently updated rows.
Two arms, fused by rank.
- Lexical: BM25 with
k1=1.5,b=0.75, computed by hand (lines 197-213). - Semantic: MiniLM-L6-v2 mean-pooled and normalized via
@xenova/transformers, per-fact vectors cached invectorsas JSON text and compared by cosine in JS. If the model is not in the local cache, the arm is TF-IDF cosine over the same 500 documents instead (lines 215-243). If the import or the pipeline throws,catch {}keeps the TF-IDF scores. - Fusion:
1/(60 + rank_bm25) + 1/(60 + rank_cos)— reciprocal rank fusion with the conventional k=60 — then multiplied byconfWeight = 0.7 + 0.3·confand bykeyMatchBoost = 1.3when a query stem appears in the fact'skey. Survivors need_bm25 > 0 || _cos > 0.01.
RRF is the right choice here for the reason it usually is: the two arms produce incomparable scores, and Helm cannot calibrate them because it has no relevance data. Multiplying the fused score by a confidence weight rather than adding it means a 0.05-confidence fact is penalized by 0.715× rather than excluded — a sensible refusal to let belief fully override relevance.
The episode arm (--with-episodes, lines
299-322) is a parallel and deliberately different scorer over the last
500 episodes: (cos + 0.12·bm25) · (0.6 + 0.4·recency) with
recency = exp(-Δt / 30 days), gated at
cos > 0.18 || bm > 0, six results by default. Facts
get no recency term at all; episodes get an explicit 30-day half-life.
That asymmetry is correct — a preference should not fade from the
ranking because it is old — and it is the kind of distinction most
systems this size do not draw.
A write on the read path. Line 295: every returned
fact gets access_count = access_count + 1. This is what
feeds the access-slowed decay, so it earns its place, but it means
recall is not a read-only operation, the registry's
"side_effects": "none" declaration for
memory.recall is wrong, and
computeInterruptScore — which calls recall on every think
tick to score its own thought — reinforces facts as a side effect of
deciding whether to interrupt the owner.
Failure modes. Over-recall is bounded at eight facts
on the hot path. The real exposures are the 500-row window above; silent
degradation from MiniLM to TF-IDF to nothing (three quality tiers, one
output shape, no signal to the caller); the 240-character query
truncation, which for a long message searches only its opening; and
distillation artefacts polluting results — a learned row
reading mentioned in 4 episodes (last: "…") is a high-BM25
match for common vocabulary and carries no information.
7. Write Mechanics
Writes are one process per fact. Every path shells
out to node workspace/memory/memory.mjs remember …, which
opens the database, runs the guarded ALTERs, does its work,
prints JSON, and closes. There is no connection pool, no transaction
spanning more than one statement, and no write queue —
PRAGMA busy_timeout = 5000 is the entire concurrency
strategy, against a background think tick, a consolidation pass,
detached per-turn captures and the dashboard all opening the same
file.
Who writes:
| Path | Trigger | Tag | Blocking? |
|---|---|---|---|
captureTurn episode |
every turn | — | no, detached |
captureTurn regex fact |
"remember that …" in the turn | observed, 0.8 |
no, detached |
ingestEpisodes |
Discord reconnect / manual scan | — | in-process |
| think tick | every ~15 min | observed, 0.6 |
separate process |
| weekly review | every 7 days | LLM's choice | separate process |
consolidate distil |
after weekly review | consolidate |
separate process |
memory.remember tool |
agent decides | never observed |
inside the turn |
migrate.mjs |
first run | CLAUDE.md, 1.0 |
one-shot |
The gate is opt-in by the writer, and that is its weak point.
PROVISIONAL_CAP only applies when the caller passes
--source observed. The think prompt does. The hot-path
regex does. The registry tool memory.remember does not
(workspace/tools/impl/memory.remember.mjs:20-22 builds argv
without it), so whenever the model chooses to save something through its
tool surface, the value lands at the requested confidence — default
1.0 — and is exempt from decay because
evidence_count is 1 but confidence is at ceiling. The
mechanism that makes the design interesting can be bypassed by the least
deliberate writer in the system.
Extraction is a prompt, not a component. There is no
structured extractor, no JSON schema, no output validator anywhere on
the fact-write path. The weekly review's instruction to write
learned facts for themes in "2+ recent episodes" is checked
by nobody. The deterministic distiller in consolidate.mjs
is the counterweight, and what it produces is word frequency
wearing the vocabulary of learning: a stem appearing in three
episodes becomes a fact whose value is the sentence
mentioned in 3 episodes. It has no subject, predicate or
claim. It is retrievable, weighted at up to 0.9 confidence, and
injectable.
The stop list guarding that distiller is the most informative twelve
lines in the repository (consolidate.mjs:44-53). Past the
ordinary English stopwords sit fact, facts,
note, notes, supersed,
superseded, supersede,
superseding, episode, episodes,
tick, think, memory,
smoke, dump, index,
workspace, learned, pending,
loader. That is the system's own bookkeeping vocabulary,
and it is there because the distiller learned facts about its own
mechanism from its own supersession log. The QUEUE.md entry
dated 1 June 2026 states the cleanup: 42 smoke episodes, 85 duplicate
supersedes and 24 polluted learned rows deleted, "INDEX.md
is now legible again". Any system that logs its own mutations into the
same store it consolidates from will meet this, and most will not
notice.
Deduplication happens at three levels: the partial
unique index makes two live rows per (kind, key)
impossible; ingestEpisodes checks new summaries against the
last 2,000; the supersede episode collapses to one row per key per six
hours.
The note-<timestamp> leak. The
hot-path regex mints its key as
'note-' + Date.now().toString(36)
(index.js:578). Every captured note is therefore a
new (kind, key) pair, which means the uniqueness
index never applies, supersession can never fire, and saying "remember
that I prefer X" twice with different values leaves both facts live and
mutually contradictory at confidence 0.8. These rows also arrive with
evidence_count = 1, so they are decay-eligible after 30
days — the one thing the owner said explicitly is on the fastest path to
being pruned, and pruning leaves no record.
Operational cost
The write path never blocks the agent. No LLM call,
no embedding, no network — a fact write is a spawn, a few SQL statements
and an exit. The per-turn captures are detached and
unref'd, so the reply is not waiting on them.
Retrievable immediately. There is no indexing lag:
remember commits and the next recall sees the
row. The embedding, if any, is computed lazily on first retrieval
(getOrComputeVector) and cached, so the first recall
touching a new fact pays for its vector and later ones do not. The lag
that does exist is elsewhere — a fact the owner reveals in conversation
is not stored until the think tick reads the transcript, up to fifteen
minutes later, and is not distilled or corroborated until the weekly
pass.
The read path does block.
recallMemories is a synchronous spawnSync on
the critical path of every turn, with a 12-second ceiling, and a cold
MiniLM load is the reason the ceiling is that high. Every message the
owner sends pays a process spawn, a 500-row scan, BM25 over the corpus,
up to 500 cosine comparisons, and a second pass for episodes.
Background cost scales with the corpus, not the day.
consolidate.mjs reads every episode in the window and every
decay-candidate fact; refresh-index.mjs rewrites the whole
index from all active facts after every tick — roughly 96 times
a day. Both are pure SQL and arithmetic with no token cost, which is the
saving grace: the nightly rewrite that would be a token bill in an
LLM-consolidating system is free here. The LLM cost is bounded
differently — one cheap sonnet call per 15 minutes, one
deeper call per week.
Prompt-prefix caching is invalidated on every turn, by
construction. INDEX.md arrives through
CLAUDE.md's import and is stable between think ticks. But
--append-system-prompt carries
buildPersona(mode) + recallMemories(prompt), and the recall
block is a function of the current message, so the system prompt differs
every turn. The cheaper arrangement — the stable index in the prefix,
the query-specific memories in the user turn — is one line away and the
static channel already demonstrates it.
8. Agent Integration
The agent is Claude Code driven as a subprocess.
index.js builds
claude -p --output-format stream-json --verbose --model <m> --permission-mode <pm> --append-system-prompt <persona+memory> --add-dir workspace --add-dir ~ --strict-mcp-config --mcp-config <json>
and, when a session id exists, --resume <sid>. Memory
reaches the model three ways, in descending reliability:
- Automatic static injection —
@memory/INDEX.mdinCLAUDE.md. Not optional, not query-dependent, includes confidence for preferences. - Automatic dynamic injection —
recallMemories, eight facts and six episodes, confidence stripped, under a "never contradict" instruction. - Tool-mediated —
memory.recall/memory.rememberthrough the registry, plus the CLI verbs documented inCLAUDE.md:130-150which the agent can call directly through Bash since the workspace is mounted.
The model has broad agency and no gate.
PERMISSION_MODE defaults to bypassPermissions
in the think and mind paths, the workspace is --add-dir'd,
and the memory CLI is a shell command like any other. So the agent may
write any kind, any confidence, --force past
the provisional cap, or forget <id> — nothing
mediates. This is the governed write gateway
pattern's exact inverse: one store, many writers, no arbiter.
Session lifecycle is a single thread.
sessions.mjs maps 'owner' to one Claude Code
session id shared by Discord, iMessage and the terminal — a genuinely
nice property the README sells as "one brain, one memory, one
conversation" and which the code actually delivers. The cost is that
there is no session scope in memory either, so nothing distinguishes
what was said where.
Compaction handling is absent in practice.
workspace/sessions/compact.mjs implements
maybeCompact — estimate tokens at 4 characters each, at 60%
of a 100k budget call haiku for a five-field JSON anchor
(intent, changes_made, decisions,
next_steps, constraints), write
session_anchor.json. It is well-formed, tested
(smoke.mjs:1612), and called by nothing in the
running agent: swarm.mjs imports only
HANDOFF_SCHEMA and pruneFileReads, and no
other file calls maybeCompact. The live path relies
entirely on --resume and whatever Claude Code does
internally at its own context limit. Whatever the host drops at that
boundary is not mirrored into the fact store, and the anchor that was
built to survive the boundary is only reachable by hand.
Adapting this for another agent would be easy in the
parts that matter and irrelevant in the rest.
workspace/memory/ is four self-contained scripts with a
JSON-on-stdout CLI and no dependency on Helm beyond a path — genuinely
portable, and the cleanest way to lift the evidence gate into another
system. Everything around it is welded to Claude Code's flags.
9. Reliability, Safety, and Trust
Provenance is a self-asserted string with real
consequences and no verification. --source observed invites
the cap; source='CLAUDE.md' grants permanent immunity from
decay and prune. Any writer can claim either. Since the model can shell
out to the CLI directly, a fact can be made undecayable by typing the
right word.
The store is an injection surface, and the framing makes it
worse. Facts enter from: the owner's messages, Discord history
scans, the transcript as read by a reflection loop, and word-frequency
distillation over episodes that themselves contain arbitrary text. There
is no sanitization of any of it — no counterpart to the
envelope-stripping other local agents in this atlas were forced to
build. Those facts are then injected under
use these, never contradict them. Content that reaches an
episode — a web page the agent read aloud, a Discord message, a filename
— can become a learned fact with a confidence up to 0.9 and
be presented to the next turn as something not to be contradicted. The
mitigation is real but incidental: the gateway is locked to one owner,
so the attack surface is whatever that owner's machine and inbox
touch.
Uncertainty is representable and then discarded.
This is the central finding. confidence,
evidence_count, source and
access_count are all computed carefully, and the hot-path
injection format (index.js:504) emits
- (kind) key: value. A 0.7 provisional guess and a 1.0
owner statement are typographically identical in the prompt. Fixing it
is a template change; the data is already there.
Three readers ignore the expiry predicate. Active
rows are defined by expired_at IS NULL, and the partial
index enforces it for writes only — SELECTs must
remember it themselves:
index.js:244getAutonomyMode()—SELECT value FROM facts WHERE kind = 'preference' AND key = 'helm.autonomy_mode'with no expiry filter, noORDER BY, and.get(). Because SQLite cannot use the partial index for a query that does not imply its predicate, this scans and returns the first matching row by rowid — the oldest, which after any mode change is an expired one.setAutonomyModewrites throughremember, which supersedes. So the agent's own autonomy setting is read back stale. The corroborating detail is in the project's own changelog: theQUEUE.mdentry dated 1 June 2026 reports cleaning up "82helm.autonomy_modeduplicates" — the same key superseded 82 times. Consistent with a setting that never appears to take effect and gets re-set; not proof of it.consolidate.mjs:74— the distiller's lookup for an existinglearnedfact omits the filter, so it can find andUPDATEan expired row, writing the distilled value onto a dead record while the live one goes untouched.consolidate.mjs:146— dedupe selects the group's rows without the filter, though the group membership query (line 143) applies it. If that branch fires it deletes every row but one, including superseded history, and sums retracted evidence onto the survivor; if an expired row happens to hold the highest confidence it is kept and the live row deleted. The partial unique index makes duplicate live rows impossible going forward, so this branch should only be reachable on a legacy database — a conditional bug, in the cleanup path built for exactly the databases that can still trigger it.- The dashboard (
workspace/dashboard/server.mjs:58-65) counts and lists recent facts unfiltered, so the operator's view of the store overstates it and can show retracted values as current.
Concurrency. Four writer classes on one SQLite file
with a 5-second busy timeout and no WAL pragma in the code. Each
remember is read-then-write across separate statements with
no transaction, so two concurrent same-key writes can interleave; the
partial unique index is what turns the loser into an error rather than a
duplicate, which is a good backstop reached by accident.
refresh-index is properly atomic. think.mjs
locks against itself and against self-upgrade, which is the collision
that would actually hurt.
Data loss. Two hard deletes with no record:
forget <id> and the prune sweep. Prune is the more
concerning because it is automatic, silent, and targets exactly the
never-corroborated rows the hot-path regex creates for explicit owner
statements. No backup or sync of memory.db exists in the
repository; *.db is gitignored; recovery is whatever the
owner's machine does. The vault has no reconciliation with the fact
store, so a divergence between "what Helm knows" and "what the vault
says Helm knows" is undetectable by design.
Privacy is the strongest part of the safety story,
and it is a matter of architecture rather than mechanism: everything is
local, the store is a file on the owner's disk, .gitignore
excludes owner.md, memory.db, conversations,
the vault key and the secrets vault, and the install refuses to publish
them. Deletion is genuine — DELETE means gone — which is
the flip side of having no tombstone.
10. Tests, Evals, and Benchmarks
workspace/tests/smoke.mjs is 2,317 lines and 88 labelled
cases, with smoke-phase2.mjs and
smoke-phase3.mjs alongside. About nineteen cases touch
memory, and they are behavioural: they spawn the real CLI against the
real memory.db, assert on parsed JSON, and clean up by key
— __smoke-prefixed for the cases that must not pollute the
episode log.
What is genuinely covered:
- The evidence gate.
smoke.mjs:285— anobservedfirst write is capped at 0.7 and a repeat incrementsevidence_count. This is the mechanism the design rests on, and it is tested. - Ranking, not just recall.
smoke.mjs:892asserts a high-confidence fact outranks a low-confidence lexical match;smoke.mjs:1253asserts BM25 puts higher term frequency first. Retrieval quality assertions are rare at this scale. - Supersession end to end.
smoke.mjs:1293— the second value supersedes, the old row carriesexpired_at,recallreturns exactly one active row and it is the new value,historyreturns both. - The schema invariant.
smoke.mjs:535asserts the unique index exists and blocks a direct duplicateINSERT— testing the constraint, not just the code path that respects it. - The read-path side effect.
smoke.mjs:1339assertsaccess_countis bumped for returned facts. - Its own noise gates.
smoke.mjs:1953asserts a__smokesupersede emits zero episodes;smoke.mjs:1975asserts four rapid supersedes of one key collapse to one episode row. - Graceful degradation.
smoke.mjs:399assertsisModelAvailableis safe with no model on disk. - A named regression.
smoke.mjs:200, labelledBUG-2, asserts a doublemigrate.mjsrun does not grow the fact count.
negative_eval is earned, narrowly. The
supersede case asserts that a value still present in the database must
not come back from recall — exactly one active
row, and failing with "recall returned old value" if the expired one
appears. The episode-gate cases assert that particular material must not
be written to the stream at all. That is a retrieval-exclusion assertion
rather than a boundary or default-off check, so the flag holds. It is
two cases, not a suite: nothing asserts that a pruned or
forgotten value stays unreachable, which is where this store is
actually weak.
What is missing, in order of what I would want first: a case at the
500-row recall boundary, since that limit silently caps the whole
design; any assertion that getAutonomyMode returns the
value just written, which would have caught the stale read; a test that
the injected memory block carries enough for the model to weight what it
is given; a decay test over more than one pass, to pin the
last_seen mutation; and anything at all exercising the
mind verbs against a real vault —
smoke.mjs:1893 explicitly checks "no Claude run", so the
entire second brain is covered by "the verbs and the protocol file
exist". The CORTEX case (smoke.mjs:2299) asserts the client
parses and the tools are registered, which is all it can do, since the
server is not in the repository.
No eval harness, no retrieval benchmark, no committed benchmark artefacts. Nothing measures recall quality against a labelled set, and nothing in the README claims otherwise — the comparison table on the marketing page claims capabilities, not scores, which is the honest version of this gap.
The self-audit is the most unusual artefact here.
workspace/repo-scan-report.md, dated 2 June 2026, is a
committed 25-issue bug report: severity, file:line, a
described failure mode, "reproduced empirically" where it was, and a
named fix that usually points at a correct pattern already present
elsewhere in the same repository. It is candid against its own marketing
— one Low entry is "Public marketing site still promises the removed
multi-machine fleet feature", filed as "user-facing false
advertising".
It is also closed. Every issue I checked at this commit is
fixed: the migrate.mjs column guards are in place before
the dedup DELETE (migrate.mjs:29-33, High #1);
gui_task.mjs:115 uses { shell: true } (High
#3); swarm.mjs:39 wraps npm with shell: IS_WIN
(High #4/#5); web.mjs:18 calls bare curl
instead of /usr/bin/curl (High #2); the fleet remnants are
gone from dashboard/server.mjs, cli.js and
docs/index.html (Low #3/#4/#5);
compact.mjs:148 reads fd 0 (Medium #9). The report's own
architectural recommendation — "factor resolveClaude() into
a shared module imported by index.js, scheduler.mjs, think.mjs,
self-upgrade.mjs and plan.mjs" — became
workspace/lib/engine.mjs, now imported by fourteen modules.
workspace/upgrades/QUEUE.md records the same loop in
changelog form: dated entries, each naming the smoke test that anchors
it and, in the best ones, the observed pollution that forced it. Finding
your own bugs is common; writing them down with reproduction steps and
then closing them is not.
That maturity is uneven, and it is worth being precise about where it stops. It covers process spawning, cross-platform correctness and mechanism hygiene thoroughly. It has not reached the memory model's epistemics: the injection template that discards confidence, the readers that forget the expiry predicate, and the 500-row window are all invisible to a scan looking for crashes, because none of them crash.
11. For Your Own Build
Steal
- Cap a first observation and make belief earn its way
up. Fifteen lines: a constant, a source tag,
min(requested, CAP + step·(evidence−1)), and a counter. It gives you a defensible answer to "the model thought it noticed something" without a trust-state machine, and it is the cheapest correct thing in this report. - Let retrieval slow decay, and keep the two signals
apart. Subtracting
log1p(access_count)from stale weeks means use extends life without ever raising confidence. Access is evidence of relevance; corroboration is evidence of truth; systems that add usage into a single score conflate them. - Give episodes a recency term and facts none. A preference should not fall down the ranking for being old, and an event should. One ranking function for both is a decision, usually an unexamined one.
- Refuse to download a model on the query path.
allowRemoteModels = falseplus an explicitdownloadsubcommand converts a mysterious first-call stall into a documented setup step. - Test the constraint, not just the code that honours
it. Asserting the unique index rejects a raw
INSERTcovers every future writer, including the ones written by an LLM at 3am. - Write your bugs down where the code lives. A
committed report with
file:line, a reproduction, and a pointer to the correct pattern already in the repo is worth more than an issue tracker, because the next agent reading the repository finds it.
Avoid
- Computing a confidence and not shipping it. If the consumer of a memory is a language model, the number has to appear in the string. Otherwise the epistemics are an internal ceremony, and the phrasing you do ship — "never contradict them" — silently promotes every guess to a fact.
- A soft-delete predicate with no accessor.
WHERE expired_at IS NULLin fourteen places is fourteen chances to forget it, and a partial unique index protects the writes while leaving everySELECTon its own. Expose a view or a single query function and make the raw table unreachable. This is the generalizable half of §9. - Minting a unique key per captured item. A timestamped key defeats every uniqueness, supersession and update mechanism you built, and turns "remember X" said twice into two contradictory live rows.
- Word frequency dressed as learning. "Mentioned in N episodes" is a term count with no claim in it. If a distiller cannot produce a subject and a predicate, keep the count out of the store the model reads.
- Logging your own mutations into the store you consolidate from. The supersession log becomes input to the distiller, the distiller learns about supersession, and you discover it by reading a stop list. Tag mechanism events and exclude them at the source, and test that exclusion.
- Mutating a timestamp to make a batch job
idempotent. Advancing
last_seenso decay does not re-run is effective and destroys the column's meaning for every other reader. Keep a separatelast_decayed. - Making a gate opt-in for writers. A cap that applies only when the caller passes the right flag will be bypassed by the laziest call site — here, the tool the model itself reaches for.
Fit
This is a design for one person, one machine, a few thousand
facts, and inside those bounds the economics are hard to beat:
no service to run, no key required to store anything, a store you can
repair with sqlite3, and a genuine epistemic model in 401
lines. If you are building a personal assistant that lives on your own
hardware and you want memory that is more than a JSON blob, this is a
better starting point than most of the larger systems here, and
workspace/memory/ lifts out cleanly.
Walk away if any of three things is true. If the store will
exceed a few hundred active facts, the recency-ordered 500-row
window means retrieval quietly stops seeing your oldest and
best-established memories, and fixing it means replacing the retrieval
function rather than tuning it. If more than one person or
project shares the store, there is no scope to add — the
boundary is the process, and channel demonstrates how a
scope column behaves when nothing applies it. If deletion must
be durable against re-derivation — regulated data, a user's
explicit retraction — the hard-delete-with-no-record semantics put you
on the wrong side of the requirement, and a reflection loop reading the
same transcripts will re-derive what you removed.
There is also a maintenance judgement to make that is not about
scale. This codebase improves itself nightly, under a smoke gate, with
an auto-revert. That is why the audit findings are closed and why the
noise gates exist. It is also why the schema is the union of five files'
ALTER statements and why three readers drifted from the
expiry predicate — a system that grows by accretion under test pressure
gets locally correct and globally inconsistent. Adopting the memory
module is straightforward. Adopting the development model that produced
it means accepting that the invariants live in the smoke tests, and that
anything the smoke tests cannot see is free to rot.
12. Open Questions
- Does
getAutonomyModeactually return the stale row? The reasoning is from SQLite's partial-index eligibility rules and rowid scan order, and the 82 duplicate supersessions inQUEUE.mdare consistent with it. Confirming it needs a live database with a supersededhelm.autonomy_modeand anEXPLAIN QUERY PLAN. - How large does the store get in practice? Whether
the 500-row window is a live problem or a latent one depends on the fact
count after months of 15-minute ticks, and
memory.dbis gitignored. TheINDEX.mdcap of 30 rows per kind suggests the author has seen kinds grow past 30. - Is MiniLM usually loaded? The three retrieval
quality tiers are indistinguishable from the output shape, and whether a
typical install ever runs
embed.mjs downloadis unknowable from the repository. - What is CORTEX? Twelve registered tools, a
documented five-layer memory stack, on-device embeddings, an HTTP API on
port 7002 — and
workspace/cortex/is gitignored. Whether it is a prototype, a separate product, or abandoned changes how to read the memory architecture, since the vault and the fact store are described inCLAUDE.mdas deliberately distinct from it. - Does the vault stay coherent with the fact store?
Both are written by prompt instruction with no reconciliation. Only
running the nightly
com.helm.mindpass against a real vault would show whether "the vault rewrites itself" converges or accumulates contradictory notes. - What did the nightly self-upgrade do to the memory layer
that the smoke tests did not catch? The QUEUE records what was
intended and anchored. Whether auto-reverted attempts left partial
schema changes would need the git history of
workspace/memory/read commit by commit.
Appendix: File Index
Storage and schema
workspace/memory/memory.mjs:13-64—facts,episodes,links; guardedALTERs; the self-healing partial unique index.workspace/memory/embed.mjs:14-23,104-110—vectorsandepisode_vectors, created lazily.workspace/sessions.mjs—sessions(key, session_id, adapter, updated), key always'owner'.
Write path
workspace/memory/memory.mjs:87-162—remember: the provisional cap, evidence ratchet, supersession, and the gated supersede episode.workspace/memory/memory.mjs:341-349—forget, hard delete plus vector.index.js:572-583—captureTurn: per-turn episode and the regex fact.index.js:520,537,559—ingestEpisodes,catchUpDiscord,scanDiscordHistory.workspace/memory/migrate.mjs— CLAUDE.md bullets toprofile/exam/notefacts at confidence 1.0, updated in place with no supersession.workspace/tools/impl/memory.remember.mjs— the wrapper that omits--source.
Retrieval path
workspace/memory/memory.mjs:164-326— the 500-row window, BM25, TF-IDF and MiniLM arms, RRF fusion, confidence weight, key boost,access_countbump, and the episode scorer.workspace/memory/embed.mjs:44-101— pipeline load with remote models disabled,getOrComputeVectorcache.workspace/memory/memory.mjs:328-339—unsure.workspace/memory/memory.mjs:369-379—history, the intentional unfiltered read.
Context assembly
index.js:490-510—recallMemories, the injection template that drops confidence.index.js:595-607— prompt assembly,--append-system-prompt,--resume.workspace/CLAUDE.md:15—@memory/INDEX.md, the static channel.workspace/memory/refresh-index.mjs— the index projection, written atomically.
Background workers
workspace/think/think.mjs:39-82— the cheap and weekly prompts.workspace/think/think.mjs:93-136—computeInterruptScore,loadActiveGoals.workspace/think/think.mjs:146-225— the tick: locks, quiet window, consolidate, refresh, guards.workspace/memory/consolidate.mjs:44-158— distil, decay, prune, dedupe, and the stop list.
Other memory surfaces
workspace/mind/MIND.md— the AI-first note format and three rules, parsed by no code.workspace/tools/impl/mind.mjs:21-30,47-56— eight verbs as prompt strings,bypassPermissionson the vault.workspace/tools/impl/cortex.mjs— HTTP client for the absent server.workspace/tools/registry.json—memory.*,mind, and the twelvecortex.*declarations.workspace/sessions/compact.mjs:81-132—maybeCompact, unreferenced by the running agent.workspace/dashboard/server.mjs:58-65— the unfiltered operator view.
Tests and self-audit
workspace/tests/smoke.mjs:285,892,1253,1293,1339,535,1953,1975,399,200— the memory cases named in §10.workspace/repo-scan-report.md— the 25-issue committed self-audit dated 2 June 2026.workspace/upgrades/QUEUE.md— dated self-upgrade changelog with anchoring test numbers.