Skip to content

EYAS v0.8.23-beta — Nothing said is lost

Latest

Choose a tag to compare

@eyssen eyssen released this 05 Sep 07:53

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_raw row 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 inside addMessage itself, 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's LlmResponse append and joined
    to its conversation through agent_sessions.
  • Trust is assigned at capture and never inherited from context. Your own
    messages are owner, model-authored text is derived, 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 passes chunkTokens (8000
    estimated tokens), a once-a-minute sweep finds it idle for idleFlushMinutes
    (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 from key: value lines (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 under memory/v2 imports 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 a valid_until and an invalidated_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 = NULL and model_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 as Execute the following command in the pod: ….

Configuration

Seven new keys, all under memory:

  • memory.l0.enabled (default true) — the master switch for raw capture.
  • memory.l0.extractInLegacy (default true) — derive facts and gists even
    though engine is still legacy. Set it to false to keep the raw text and
    derive nothing.
  • memory.engine (default legacy) — selects the read/write engine. Today
    it gates only extraction; it does not switch retrieval yet.
  • memory.l0.chunkTokens (8000) and memory.l0.idleFlushMinutes (30) —
    the two size and time flush triggers.
  • memory.l0.captureToolResults (default false) — 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_command stdout,
    read_file contents and a live browser_totp one-time code all land in the
    raw layer as plain text. Nothing redacts them and nothing encrypts them at
    rest — dek_id is 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 doctor reports 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-wasm 0.0.27 (MIT), used only when
    neither runtime provides zstd natively.
  • Every EYAS database connection now runs PRAGMA synchronous = NORMAL
    instead of SQLite's default FULL. 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 no eyas memory command in this release: the raw
    rows, facts and gists are written and then wait. Recall, embeddings and the
    context assembler are the next wave. Setting memory.engine: v2 today 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.