EYAS could hold a conversation and forget it. Messages lived in
conversation_messages, background agent output lived in agent_events, and
the memory tiers underneath stayed at zero rows — nothing wrote to them, so
nothing could ever be recalled from them.
This release lays the floor. A raw layer now keeps every message verbatim,
compressed and content-addressed, and a deterministic pass turns each flush
into facts, a gist, entities, topics and tags — with zero model calls and no
API cost. It is a write path: recall from these layers arrives in the next
wave, and until then the vault index and related-work blocks serve the prompt
exactly as before.
The raw layer
- Every persisted message is kept a second time, verbatim. Each one becomes
a zstd blob keyed by the SHA-256 of its uncompressed bytes, plus a
memory_rawrow carrying actor, task, project, source type, timestamp and
trust tier, a contentless FTS5 entry, and structural tags. Two byte-identical
messages inside one task share one blob (ref_count 2); across two tasks they
get one blob each, so a future per-task erasure stays possible. - Capture sits at the persistence layer, not after the turn. Chat messages
are captured insideaddMessageitself, so interactive routes,executeAgent,
the orchestrator, God Mode's winner promotion and every channel adapter are
covered structurally rather than by remembering to call something. Background
agent output is captured from the event store'sLlmResponseappend and joined
to its conversation throughagent_sessions. - Trust is assigned at capture and never inherited from context. Your own
messages areowner, model-authored text isderived, tool output is
ingested. A derived row can never outrank the sources it came from. - Writes are buffered per task and flushed on four triggers: the task closes
(or moves into a closed stage), the buffer passeschunkTokens(8000
estimated tokens), a once-a-minute sweep finds it idle foridleFlushMinutes
(30), or EYAS stops — shutdown flushes everything still buffered, so a restart
loses nothing. A failed flush rolls back and returns its units to the buffer. - Idempotency is keyed on the capture-time id, not on content, so a retried
flush is a no-op while two genuinely identical replies stay two occurrences. A
reply that arrives twice through two different paths — once as an event, once
as the message that follows it — is suppressed once, within ten minutes and
within one task.
What EYAS derives from every flush
- A deterministic extraction pass runs after each committed flush, with no
model call. It reads the task's new rows above a per-task watermark and
derives: structural facts fromkey: valuelines (up to 20) plus up to four
board facts (title, project, project type, agent); entities by regex — dates,
@mentions,#tickets, code identifiers, backticked terms, capitalised
phrases (up to 50); topics from TF-IDF stems unioned with entity names; and a
gist of at most 280 characters built from the first and last message plus up
to three TF-IDF-picked sentences. This is a property of the import graph, not
a code path left untaken: nothing undermemory/v2imports a model, provider
or gateway. - An importance score is computed from the conversation itself — message
count, your own text volume, decision markers in five languages, whether the
task is closed and whether you pinned it. The weights are hand-set and
published in the source. - Facts are arbitrated, not appended. A fact whose content hash already
exists, still live and in the same project, is linked rather than duplicated.
The same(subject, predicate)with a different object supersedes the old
row: the old one gets avalid_untiland aninvalidated_by_fact_id, so
"deadline is Monday → Friday → Monday" ends with exactly one live fact and an
intact history. Nothing is updated in place and nothing is deleted. - Tags are inherited only when every source carries them. A fact or gist can
never carry a project or task its own sources lack; a violation is counted and
the tag withheld. - Every run is recorded, including the ones that do nothing.
memory_run
gets a row for each extraction with its trigger, counts, language, importance
and — always, in this release —model_used = NULLandmodel_calls_used = 0.
A skip writes a row too.
The poisoning gate
- Instruction-shaped text never becomes a fact or a gist at full trust.
Explicit override phrasing — "ignore all previous instructions", "hagyd
figyelmen kívül", "vergiss alles", "olvida todo", "oubliez tout", role
reassignment with "from now on" — is rejected outright. Imperatives aimed at
the assistant, tool-invocation directives and memory-wipe directives are
quarantined: stored, but at a trust tier that recall must exclude. Fake role
markup (<system>,[INST],SYSTEM:, shouted headings) is quarantined too. - A rejected gist degrades rather than disappearing: it falls back to the
heuristic gist, then to only the sentences that scan clean, then to a stub that
keeps the task addressable without carrying the text. Every rejection and
quarantine is counted in the run row. - Coverage is English, Hungarian, German, Spanish and French. It is a regex
gate — a filter, not a proof — and it will occasionally quarantine ordinary
engineering prose such asExecute the following command in the pod: ….
Configuration
Seven new keys, all under memory:
memory.l0.enabled(defaulttrue) — the master switch for raw capture.memory.l0.extractInLegacy(defaulttrue) — derive facts and gists even
thoughengineis stilllegacy. Set it tofalseto keep the raw text and
derive nothing.memory.engine(defaultlegacy) — selects the read/write engine. Today
it gates only extraction; it does not switch retrieval yet.memory.l0.chunkTokens(8000) andmemory.l0.idleFlushMinutes(30) —
the two size and time flush triggers.memory.l0.captureToolResults(defaultfalse) — read this before
turning it on. A captured tool result is the whole output, verbatim and
unredacted, plus 2048 characters of the call's arguments:run_commandstdout,
read_filecontents and a livebrowser_totpone-time code all land in the
raw layer as plain text. Nothing redacts them and nothing encrypts them at
rest —dek_idis NULL and zstd is compression, not confidentiality. With the
flag on, every boot prints a warning saying exactly that.memory.l0.toolResultMaxBytes(8192) — the byte cap applied to a captured
tool result, clipped on a UTF-8 boundary with a visible truncation marker.
Diagnostics and platform
eyas doctorreports two new lines. SQLite capabilities are probed live —
FTS5 is required and a missing one is a hard failure; sqlite-vec is checked by
actually loading it, inserting an int8 row and running a KNN query, with a
platform-specific remedy when it is absent. The zstd tier is reported too:
native is fine, the WASM fallback is a warning (about 2× slower), none is a
failure.- A three-tier zstd shim picks Bun's native compressor, then
node:zlib
(Node ≥ 22.15; 23.0–23.7 have none), then@bokuweb/zstd-wasm. If no tier
resolves, capture is disabled loudly and says so — never left buffering
silently. Level 3, measured ratio ≈ 2.7 on real text, about 32 µs per message. - One new dependency:
@bokuweb/zstd-wasm0.0.27 (MIT), used only when
neither runtime provides zstd natively. - Every EYAS database connection now runs
PRAGMA synchronous = NORMAL
instead of SQLite's defaultFULL. With WAL this stays durable across a
process crash, but not across an OS crash or power loss at the instant of
commit. This applies to all modules, not only to memory. - The capability probe no longer runs inside an open transaction. It used to
be able to swallow a caller's uncommitted rows; it now refuses, loudly, and a
failed probe is never cached.
What this release does not do yet
- Nothing reads the new layers. There is no retrieval path, no HTTP
endpoint, no UI page and noeyas memorycommand in this release: the raw
rows, facts and gists are written and then wait. Recall, embeddings and the
context assembler are the next wave. Settingmemory.engine: v2today changes
nothing. - The raw layer grows and nothing prunes it. There is no retention setting
and no cleanup job yet; measured, a captured row costs on the order of 5 KB
all-in including indexes. If you would rather not pay that yet, set
memory.l0.enabled: false. - Rows whose source timestamp predates the extraction watermark are not
extracted. They stay in the raw layer and are counted in the run row, and a
future rebuild recovers them.