Skip to content

Releases: fjwood69/mori

v2.3.12

Choose a tag to compare

@github-actions github-actions released this 03 Oct 19:53
69174a8

v2.3.12 — Security and correctness hardening

Upgrade check for clients. Send the API key in the X-Api-Key header only: a key in the
URL (?api-key= / ?api_key=) is now refused with 400. Every request on an MCP session must
carry the key that opened the session. Claude Code, Cursor, Antigravity, Cline and opencode
configured with a header already do both.

Measured before this release, in api mode: an MCP session opened with a read key, followed by
a request with no key at all, wrote a file on the server through memory_export. A session id
was a bearer credential that never expired and skipped the rate limiter. This release closes that
and the related file-system and authority gaps found while auditing it.

Behaviour changes

  • Export never writes a server-side file for a single memory. memory_export(name) returns the
    content; the output_path argument is gone (passing it is an error, not a silent ignore).
    memory_export_all() takes no directory: it writes to the server's fixed <data dir>/exports/
    and needs the dreamer role. Exports contain active memories only — deleted ones are excluded.
  • Import reads only <data dir>/imports/ and needs the dreamer role; the source_dir
    argument is gone. Each imported write is attributed to the calling key (previously the generic
    actor import), and a tier: in the frontmatter is judged by the tier rules like any other
    write — with MORI_TIER_ENFORCE=enforce an import can no longer land canonical memories.
  • API keys in the URL are refused (400) on every path, including the open ones. The access log
    redacts such values.
  • MCP sessions are bound to the key that opened them. Every request on a session must carry
    that key (else 401); identity and role are decided per request; the rate limiter counts session
    requests. Sessions expire after 12 hours idle or 7 days in total (MORI_SESSION_IDLE_TTL_S,
    MORI_SESSION_MAX_AGE_S); an expired or unknown session gets 404 and the client re-initialises.
    In open mode (no keys configured) sessions bind to anonymous and need no key.
  • memory_protect sets rather than toggles. memory_protect(name) protects,
    memory_protect(name, protected=false) unprotects; a repeated call no longer flips the flag (a
    change on SQLite). Unprotecting clears the protected domains; domains=[] clears them; omitting
    domains keeps them. A name with no active memory is reported not found (Postgres previously
    reported success). Both are audited (op = protect / unprotect) with the calling key.
  • Rolling back a protected memory is refused on both backends, with the steps to follow. In
    v2.3.11 SQLite queued it instead. "Protected" means the flag or a tag matching
    protected_tag_prefixes — on the live memory or on the version being restored. A tag-prefix
    protection cannot be lifted with memory_protect; restore the content with a reviewed write.
  • Postgres honours protected_tag_prefixes on ordinary writes, as SQLite always has: a write
    whose tags match one is queued for review instead of applied. In api mode only a dreamer-role
    key bypasses this (and the same rule now applies on SQLite, where a key merely named like a
    trusted_clients entry used to bypass it). Host mode keeps the trusted_clients list. The dream
    pipeline and standards import are unaffected. Check your prefixes: a prefix such as infra
    also matches infrastructure.
  • Memory names are validated at the write chokepoint: ^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$ and
    no ... Anything else is refused and counted (mori_write_rejections_total{reason}). Names the
    dream and ingestion derive from free text are normalised to this form; a name that was already
    valid is unchanged.
  • Roles on every state-changing surface. write: POST /api/events, /api/events/raw,
    /api/precompact, /api/dream/run, /api/git/ingest, and the mori_ingest, dream_run,
    consult_advisor, nats_pub and msg_send tools. dreamer: key_generate and
    standards_reload. memory_export is explicitly read.
  • Consult calls are not retried by the OpenAI SDK (max_retries=0 for the advisor in both
    provider modes). A retried consult re-sent a call its caller had already abandoned — one ran for
    43 minutes over three attempts. Set MORI_ADVISOR_MAX_RETRIES to restore retries. Fast and dream
    calls keep the SDK default.
  • SQLite approve (#71, #76). A pending write is marked approved only when the memory was
    actually written, and an approved change to a protected memory is applied rather than queued
    again — as on Postgres. Under MORI_ANATOMY_ENFORCE=enforce an incomplete pending write stays
    pending and the message says so. An approved write now lands at the tier recorded on the pending
    row. A second queued write for the same name updates the open pending row instead of failing.

Fixes

  • The four Postgres variants of the soft-delete/restore tests never awaited the store, and CI's
    Postgres job did not run that file (#70). Both fixed.
  • The dream's per-run write outcomes (accepted / rejected / downgraded / skipped / error) are
    persisted and exported as mori_dream_last_run_writes{result}, so a rejected write from a
    scheduled run is visible on /metrics.

Notes

  • No database migration. /metrics carries no key names (tested).
  • New metrics: mori_mcp_sessions, mori_session_rejections_total{reason},
    mori_url_key_rejections_total, mori_write_rejections_total{reason,actor},
    mori_dream_last_run_writes{result}.

v2.3.11

Choose a tag to compare

@github-actions github-actions released this 03 Oct 17:42
ceb43ba

v2.3.11 — Restore the recovery path; supersession is report-only by default

SQLite users: the dream has written no memories since v2.3.0 (22 June 2026). On the default
SQLite backend every dream write failed on an argument the SQLite store did not accept, and each
failure was logged and skipped, so dreams ran but produced nothing. This release fixes it; your
next dream run writes again.

Version history, rollback and undo were dead on the Postgres backend, and rollback was broken on
both backends. A database whose rows were imported from SQLite could also be left with sequences
behind the imported ids, which made every supersession write fail silently. This release restores
the recovery path, repairs that class of sequence automatically, and makes supersession
propose rather than apply by default until a reviewer has the tools to judge it.

Why report-only: in one long-running deployment, three of the five supersession verdicts the
scanner produced after its last working period named a "new" memory that had never been written —
a candidate still waiting for human review. The scanner was mostly acting on memories that did not
exist. This release fixes that (below) and makes supersession a reviewed decision by default.

Behaviour changes

  • Supersession is report-only by default. A SUPERSEDES verdict from the contradiction scan
    now queues an eviction_queue row (reason supersession_proposed) for review and does not
    hide the older memory. Set MORI_SUPERSESSION_MODE=write to apply supersessions automatically;
    any other value (including true, yes, 1) means report-only. The effective mode is logged at
    start-up and by each scheduled dream run.
  • Rollback goes through the write path. It now records a version snapshot of the state it
    replaces, writes an audit row (op = rollback), and obeys protection, tier and anatomy rules.
    Rollback restores content only: it never changes a memory's tier.
    • Rolling back a canonical memory requires the dreamer role.
    • Rolling back a protected memory differs by backend: Postgres rejects it with the steps to
      follow (unprotect, roll back, re-protect — each audited); SQLite queues it as a pending write.
    • With MORI_ANATOMY_ENFORCE=enforce, a rollback whose content fails the anatomy check is queued
      for review, except when a dreamer restores an exact stored version of the active memory; that
      bypass is audited (reason_code = anatomy_bypass_rollback).
    • A version from an earlier incarnation of a reused name is refused where the version records
      which incarnation it belongs to (memory_id, set for every version written from v2.3.11).
      Older versions with no memory_id — those of a name that was deleted and re-created before
      this release — are not checked and can still be rolled onto the live memory.
  • A Postgres write is one transaction. Version snapshot, upsert and audit row commit together;
    if the audit row cannot be written, the write fails instead of succeeding unaudited.
  • Deletes and restores are audited in the same transaction as the change, on both backends.
    The memory_delete MCP tool was previously unaudited.
  • Canon export excludes superseded memories.

Fixes

  • Postgres now writes memory_versions on every update (it wrote none before), pruned to the
    newest 20 per memory. memory_history, memory_diff and memory_rollback work again.
  • Rollback no longer crashes: a KeyError on Postgres and a TypeError on SQLite.
  • The contradiction scan only considers memories that were actually written. A candidate that was
    skipped, downgraded, or routed to human review (ingestion of canonical candidates) can no longer
    supersede existing memories.
  • On the SQLite backend the dream wrote no memories since v2.3.0 (a provenance argument the
    SQLite store did not accept); it writes again.
  • approve under MORI_ANATOMY_ENFORCE=enforce could hang indefinitely on Postgres; it no longer
    does.
  • A restore that renames the row (name already taken) now carries the row's version history with
    it, and tells you if the restored memory is still superseded.
  • A failed supersession write is no longer counted as a classifier error.

New

  • Boot-time sequence repair (Postgres). On every start, after migrations and under the
    migration lock, any column-owned sequence whose next value is not above its column's maximum is
    raised to it (never lowered), logged at WARNING and counted in mori_sequence_repairs_total.
  • Review tools (role dreamer, MCP and REST):
    • memory_unsupersede(name, note) / POST /api/memories/{name}/unsupersede — clears the flag,
      resolves the queue row, audits it. An undone pair can be proposed again but never applied
      automatically.
    • memory_supersession_decide(queue_id, decision, note) /
      POST /api/eviction/{queue_id}/decide — apply or dismiss a proposal. A dismissed pair is
      never re-classified.
    • memory_review lists open proposals with their queue ids and counts pairs by state.
  • Migration 17 (both backends): memory_versions.memory_id (versions keyed to the row; filled
    for existing versions only where the name has a single incarnation — others stay name-keyed),
    and eviction_queue.counterpart / resolution with a unique index on open supersession pairs.
  • Metrics: mori_supersessions_total{mode,result},
    mori_dream_last_run_supersessions{result} (persisted per run, so scheduled dreams in a
    separate process are visible), mori_sequence_repairs_total{table}.

Upgrade notes

  • Supersessions stop being applied automatically after upgrading. Review proposals with
    memory_review and memory_supersession_decide, or set MORI_SUPERSESSION_MODE=write.
  • If you imported a SQLite database into Postgres, the first start may log SEQUENCE-REPAIR
    warnings; that is the repair working. A repair on any later start means another out-of-band
    import or restore has happened.

v2.3.10

Choose a tag to compare

@github-actions github-actions released this 03 Oct 01:01
3ba0ecb

v2.3.10 — Postgres write path: stop re-encoding JSONB; orphan scan never deletes

A memory UPDATE on the Postgres backend JSON-encoded protected_domains as it came back from
asyncpg — a str, since no JSONB codec is registered — so every update nested the array one
JSON-string level deeper, roughly doubling it. A row updated on every deployment reached a ~1 GiB
bind parameter and exhausted a 2 GB host's memory. This release fixes the encoding, repairs
existing data and makes the bad state impossible to store.

  • One decoder for JSONB arrays read back on the write path (protected_domains,
    origin_session_ids, origin_clients). Legacy nesting is unwrapped (up to 64 levels, at most
    1 MB decoded), logged at WARNING with memory, column and depth, and counted in
    mori_jsonb_unwrapped_total{column} (pre-initialised at zero). A value that does not decode to
    a list returns a REJECTED write result instead of raising. Any row that gets updated is
    rewritten in the correct form.

  • Migration 16 (Postgres only).

    1. A protected_domains value stored larger than 4 KB is replaced with [] without being read —
      reading it is what exhausts memory.
    2. String-nested values in protected_domains, tags, origin_session_ids and
      origin_clients are unwrapped; anything that does not end as an array becomes [].
    3. CHECK (jsonb_typeof(col) = 'array') constraints are added to those four columns (added
      NOT VALID, then validated; lock_timeout 5 s).

    Fresh installs get the constraints from the schema. Once the constraints exist, an encoding bug
    fails the write instead of silently growing it.

  • scan_orphans on Postgres no longer deletes (behaviour change). It matches the SQLite
    backend: non-canonical (tier IS NULL OR tier != 'canonical'), unprotected, active memories
    that have been retrieved before but not within the window are queued in eviction_queue
    (reason orphan) for review. Never-retrieved memories are not orphans. Previously a
    non-dry-run scan permanently deleted every unprotected working memory not retrieved in the
    window, including ones never retrieved. Like SQLite, repeated scans do not de-duplicate: a
    still-stale memory is queued again on each non-dry-run scan.

  • approve (Postgres) marks a pending write approved only if the canon write was accepted;
    a rejected or downgraded write leaves it pending.

  • Deployment contract: the probe memory's write must succeed (a non-2xx write now fails the
    contract instead of skipping), and on Postgres the store must show that write landed in this
    run (updated_at at or after the store clock read just before it) with protected_domains
    exactly [].

  • Start-up refuses to serve if migration 16 fails: the migration rolls back and the server
    does not start on an unmigrated schema.

v2.3.9

Choose a tag to compare

@fjwood69 fjwood69 released this 02 Oct 23:24
2468353

v2.3.9 — Fail-closed one-word classifiers

The gateway route behind the fast VK began serving a reasoning model. With the
classifiers' small max_tokens it spent the whole budget thinking and returned empty
content
with finish_reason=length. The freshness check mapped any reply other than
exactly NO / STALE to fresh, so it stamped canonical memories fresh on zero answer
tokens; the contradiction scan went silently blind (an unparseable verdict logged nothing).
Model routing stays in the gateway by design, so the fix makes the code correct for any
served model.

  • Fail-closed parser (utils.parse_one_word_verdict), shared by the freshness check
    and the contradiction scan. A reply counts only if it is exactly one allowed word after
    end-only normalisation (whitespace, one trailing ., surrounding quotes / backticks /
    asterisks), with any remaining non-ASCII character rejected before case comparison
    (str.upper folds look-alikes such as ſ, st into allowed tokens). Anything else —
    empty, a reasoning leak, two words — is an error: freshness writes neither
    freshness_status nor freshness_checked_at (the row stays immediately re-eligible), and
    the scan writes nothing. The intake assessor was already fail-closed (structured output).
  • Reasoning off for classifier calls only. The three classifier sites send
    reasoning_effort from MORI_CLASSIFIER_REASONING_EFFORT: unset → "none" (a reasoning
    model answers in a few tokens, at the answer's own length, within the existing budgets);
    set but empty → field omitted (escape hatch for a routed
    model that rejects it); anything else → that value. BifrostClient.consult gains a
    reasoning_effort argument with no environment default, so advisor, dream and vision
    calls are unaffected. No automatic retry without the field — a failing route shows up in
    the metrics instead.
  • MORI_FRESHNESS_ON_BRIEF now defaults to false. The check asks "is this still
    accurate?" with no evidence; with reasoning off its verdict on one memory was measured as
    a coin flip at temperature 0. A wrong stale is terminal (never re-checked) and a wrong
    fresh is surfaced to API consumers, so unknown is the honest default until freshness
    is grounded in evidence. Opt back in with exactly MORI_FRESHNESS_ON_BRIEF=true
    (case-insensitive) — any other value, including yes and 1, now means off. Re-enabling it
    before that redesign re-introduces guessed verdicts and re-poisons rows the release reset.
  • Contradiction scan hardening. A memory is never compared with — or superseded by —
    itself: its own active row is excluded by primary key (an ingest written straight to the
    canonical tier matched its own name prefix; the blind scan had masked this).
    memories.name is unique only among non-deleted rows, so candidates now exclude
    soft-deleted rows and the supersession UPDATE is keyed by id. Each supersession (the
    UPDATE and its eviction-queue row) is atomic: a savepoint on Postgres — so one failed write
    no longer aborts the scan's transaction and silently rolls back supersessions already
    counted — and a rollback on SQLite, so a half-applied pair is never committed by the next
    one. Candidates are ordered by id (deterministic under LIMIT 5). Scan failures log at
    WARNING instead of DEBUG.
  • Metrics: mori_classifier_verdicts_total{site,outcome} — site ∈
    freshness / contradiction / intake; outcome is the parsed verdict (lower-case),
    unparseable or error — and mori_llm_truncated_total{vk} for replies that stopped at
    max_tokens (a dashboard signal; routine for some advisor / dream calls). Every series is
    created at zero on import, so the first burst of failures after a restart is visible to
    increase(). A parsed SUPERSEDES whose write then fails is counted twice — once as the
    verdict and once as error — so the unparseable+error share is not "the fraction of calls
    that failed".

v2.3.8

Choose a tag to compare

@github-actions github-actions released this 27 Sep 23:22
1ae6fe3

v2.3.8 — /consult and dream: observability

A consult sat pending for 25+ minutes with nothing in the advisor to say whether it
was queued, in flight, or already answered — the only evidence was in the gateway's own
log. Every outbound LLM call, the consult slot queue and each dream run are now logged
and measured.

  • Lifecycle logs (key=value, greppable by id): consult.created → llm.slot_queued
    (only when waiting) → llm.slot_acquired → llm.send → llm.recv / llm.fail →
    consult.done / consult.error, plus llm.slot_timeout, llm.call_timeout and
    dream.run outcome=… written=… elapsed_s=…. Consult lines carry the job_id; LLM
    lines carry a per-call call_id, the VK, the serving provider, latency, output tokens
    and finish reason. The model is logged twice on purpose: model_requested on send (in
    Bifrost mode only a routing key — gateway rules may rewrite it) and model_served on
    receive, from the response.
  • Gateway join key. Each HTTP attempt sends x-request-id: <call_id> (retries
    <call_id>.r<n>). Bifrost stores it as logs.id and logs its own server-side
    fallback hops with parent_request_id set to it, so
    WHERE id LIKE '<call_id>%' OR parent_request_id LIKE '<call_id>%' returns the whole
    chain. Ids are per attempt because Bifrost keeps one row per id: the OpenAI client's
    automatic retries resend identical headers and would otherwise lose their rows
    (measured).
  • Metrics on /metrics: mori_llm_call_duration_seconds{vk,outcome,provider}
    (histogram, buckets to 900s), mori_llm_output_tokens_total{vk},
    mori_llm_inflight{vk}, mori_llm_oldest_inflight_seconds, mori_llm_slots_active,
    mori_llm_slots_queued, mori_llm_slot_wait_seconds, and a dream last-run set
    (mori_dream_last_run_duration_seconds, …_timestamp_seconds,
    …_memories_written, …_outcome{outcome}). The dream summary is persisted in
    dream_state because the scheduled dream runs in a separate process
    (python -m mori_advisor.dream_job) whose in-memory metrics would never reach the
    server. Labelled by vk, not feature: dream and ingest share the dream VK, so
    per-feature dream timing comes from the last-run summary.
  • Bounded slot wait. A consult now waits at most MORI_LLM_SLOT_WAIT_TIMEOUT
    (default 600s) for one of the 6 LLM slots, then fails with
    LLMSlotUnavailable: no LLM slot free after 600s (6 of 6 in use) instead of reporting
    pending. The wait was already bounded by the slot-holders' own LLM_CALL_TIMEOUT,
    but it was indistinguishable from a normal in-flight job.
  • Caller-timeout message now reads TimeoutError: no response within 900s; the call may still complete at the gateway (was a bare TimeoutError).
  • Fix — mori_dream_undreamed was event row count − watermark id, which reads 0
    once events are pruned (row count ≪ max id). It now uses
    count_events_since(watermark), as /dream --status already did. Measured in UAT:
    gauge 0 while 20,804 events were waiting.
  • Fix — deploy examples. deploy/solo and deploy/team .env.example set
    MORI_BIFROST_BASE_URL, which the client never reads (it reads MORI_BASE_URL), so
    a non-default gateway URL was silently ignored. Renamed.
  • Removed the OpenTelemetry mori_consult_duration_ms / mori_dream_duration_ms /
    mori_consult_tokens instruments: defined, never recorded, and not in the scraped
    registry.
  • Known, not changed here: the OpenAI client retries twice by default, so a hung
    provider holds an executor thread for up to ~3 × MORI_BIFROST_TIMEOUT — after the
    caller has already given up at LLM_CALL_TIMEOUT. Now visible as
    mori_llm_oldest_inflight_seconds exceeding LLM_CALL_TIMEOUT.

v2.3.7

Choose a tag to compare

@github-actions github-actions released this 03 Aug 19:46

v2.3.7 — /consult: raise file-attachment truncation limits

  • MAX_FILE_SIZE 50KB → 125KB, MAX_TOTAL_FILE_SIZE 200KB → 500KB
    (mori_advisor/main.py). Same 25% per-file/total ratio as before; sized so
    4 files at the per-file cap fit exactly in the total budget. Prompted by
    --file consults on the production advisor model (kimi-k2.7-code,
    large context window) needing more than 200KB of combined attached source
    for realistic multi-file reviews.
  • Applies identically to both file-read paths (_read_files for server-local
    paths, _blocks_from_file_contents for the client-supplied bodies the
    /consult skill actually uses) — they share the same two constants.

v2.3.6

Choose a tag to compare

@github-actions github-actions released this 29 Jul 21:21

v2.3.6 — /consult: stop discarding completed answers

  • _run_llm's abandonment backstop raised from a hardcoded 330s to an
    env-backed LLM_CALL_TIMEOUT (default 900s, MORI_LLM_CALL_TIMEOUT).
    Measured 2026-07-29: successful deep consults land at 132–590s; the 330s cap
    was below the normal working range, not a safety margin — five complete
    answers (up to 32,768 tokens) were generated successfully and then discarded
    client-side in one day. Cancelling wait_for does not stop the underlying
    thread, so the work and its cost were incurred either way; raising the cap
    stops throwing the result away, it does not create new load.
  • BIFROST_TIMEOUT default raised 300s → 900s in both main.py and
    ingestion_server.py (kept identical on purpose — one-of-two-sites
    divergence is the defect class this change avoids).
  • Job-failure messages now include the exception type
    (f"{type(e).__name__}: {e}") instead of just str(e). Every discarded
    answer today surfaced as an empty "Advisor call failed: " message and was
    twice misdiagnosed as a swallowed provider error — it was
    asyncio.TimeoutError, which has no args, so str(e) is empty by design.
    The type name alone would have said "timeout" in one word.
  • Deployment note: the GCE container sets MORI_BIFROST_TIMEOUT=600
    explicitly in its Quadlet unit, which overrides the new code default. The
    unit env was updated to 900 alongside this release — the code change alone
    would not have taken effect otherwise.
  • Explicitly not done: capping deep max_tokens (32768→8192) to cut
    latency — rejected on the measurement, since observed completions already
    reach up to 32,768 tokens and capping would silently truncate answers
    rather than speed them up.

v2.3.5

Choose a tag to compare

@github-actions github-actions released this 29 Jul 11:33

v2.3.5 — /consult: stop sending a fixed temperature

  • bifrost_client.py:consult() no longer sends temperature unless the caller
    explicitly passes one — default changed from 0.3 to None, and the field is
    now omitted from the request entirely when unset. Some provider endpoints
    (Nebius/Kimi-K3) reject any client-supplied temperature outright, causing
    /consult to fail in ~600ms. Blanking rather than pegging to 1.0 avoids
    tying the fix to one endpoint's requirement, since the routing rule fans out
    across multiple vendors.
  • main.py's /consult call site no longer passes temperature=0.3 — the one
    behavioural change; every other caller (ingestion, dream, extraction) still
    passes its own value explicitly and is unaffected.
  • Removed a dead max_tokens < 200 guard in consult() left over from a
    K2.6-specific constraint on a client that now routes to K3; verified
    unreachable (the one call site using that VK always sends max_tokens in the
    thousands).
  • Added tests/test_bifrost_client_temperature.py — no prior test coverage
    existed for bifrost_client.py.

v2.3.4

Choose a tag to compare

@fjwood69 fjwood69 released this 24 Jul 16:15

v2.3.4 — raise /consult deep max_tokens 16 K → 32 K

  • deep depth ceiling lifted from 16 384 to 32 768 tokens — the previous limit
    was causing truncation on long architectural reviews.

v2.3.3

Choose a tag to compare

@github-actions github-actions released this 23 Jul 09:36

v2.3.3 — /consult async jobs + remote file_contents

Breaking change: consult_advisor now returns a JSON job_id immediately instead of the
advice string. Poll the new consult_status tool until done / error. Deep consults can
take ~300s of Bifrost inference; holding the MCP HTTP request open was causing LB 504s.

  • Async submit + poll: in-memory _consult_jobs (single Quadlet process; wiped on
    restart/CD). Background task runs prompt build + _run_llm + capture.
  • file_contents parameter: client-supplied [{name, content}, ...] with the same
    truncation budgets as _read_files. Preferred for remote GCE; keep files= for
    co-located path reads.
  • Skill update: both skills/consult and plugin mirror — pass file_contents, poll
    consult_status (5–10s, ~10 min deep deadline).
  • Tests: tests/test_consult_async.py; hardening/nonblocking/MCP stubs updated for poll.