Skip to content

Releases: amirj4m/jamgate

0.20.0 — v2 part 2: the read path

Choose a tag to compare

@amirj4m amirj4m released this 11 Aug 09:10

Retrieval is the product. A memory that cannot be found does not exist, and every failure that cost this project trust was a retrieval failure, not a storage failure — the memories were there the whole time. Design flaw 4 was that the read path was never designed: thirteen checks on write, and on read a whole-store scan followed by one sort. This release makes it a designed subsystem, and fixes two things that were actively wrong.

Everything below is measured against a copy of my own store (58 real memories), migrated to the v2 layout in a scratch directory. The instrument is committed (scripts/eval-recall.mjs); the query set names real people and is not.

What you can do that you could not yesterday

  • Ask in the other script. رحمان now finds a memory that spells the name Rahman, and the other way round. This was the only recall failure I had ever measured at zero: 0 of 6 → 6 of 6. Both paths that already worked — Latin→Latin and Persian→Persian — return byte-identical ranks, because the bridge only ever fires between scripts. It is a lexical bridge: a name is findable, a paraphrase in the other script still is not.
  • Read a date and a topic on every line. Every result now carries when it was asserted, how old it is, its topic, whether it is that topic's current assertion, and its source — so your agent can say "as of 6 August" instead of stating a stale figure flat.
  • See the topic namespace when it matters. A recall that matched nothing prints every topic in your memory. That is the moment an agent invents a second name for a fact that already has one, which is how two contradictory values end up live at the same time.
  • jamgate index --rebuild — report or rebuild the derivable caches. Never required; its absence in 0.19.0 was a documentation lie.

Fixed: the freshness fix had become the freshness bug

D-078 made freshness a tier and called it "a demotion, never an exclusion". True of two records, false of a store: when twenty match and five are shown, demotion to the bottom removes it. Three days after shipping it I re-measured my own store — the record holding my current motorbike balance came back at rank 20 of 20, 28 of 28 and 27 of 27 across fourteen phrasings, never once inside a default reply, while the superseded figure led every one.

Relevance now decides which records the reply contains; freshness decides where they sit. The current figure appears in 9 of 14 phrasings, up from 0.

Also: a record past its window is no longer demoted below something older than it. A window is a guess about how fast a kind of fact changes; a newer assertion is evidence.

And one number that moved by itself

The labelled top-1 floor recorded as 14/17 read 16/17 against unmodified 0.19.0 — because two fixtures carried an absolute date and silently crossed their freshness window on 2 August. A regression net that drifts reports improvements nobody earned. Its dates are relative now, the honest baseline is 14/17, and the new floor is 15/17.

Not in this release

The review loop. review_memory does not exist, and contradiction detection across two different topics remains the measured limit it has been since D-077 — two live records can assert different numbers for the same thing and nothing notices. Cross-script semantic recall is unchanged: the bundled model is English-only.


701 tests. Full detail in CHANGELOG.md, the reasoning in D-080, and the seven places building this proved the design wrong in docs/DESIGN-v2.md §15b.

Install: npx jamgate setup · Claude Desktop: download the .mcpb below.

v0.19.0 — v2 part 1: a gate that cannot destroy anything

Choose a tag to compare

@amirj4m amirj4m released this 11 Aug 08:14

v2, part 1: the foundation. A gate that cannot destroy anything.

v1's gate made irreversible decisions at the moment it had the least information: a lifespan guessed at write time from a field the caller invented and enforced by a clock, and an identity guessed from a free-text string and enforced by ===. Both were enforced by destroying or hiding data. This release removes the gate's power to destroy anything.

forget_memory archives — the memory leaves recall, keeps its full text, and the reply tells the calling agent how to get it back. Supersession archives. Compaction is gone. There is no expiry clock. Exactly one command removes bytes — jamgate purge — and it is on the CLI and REST and deliberately not on the MCP surface, so no connected agent can reach it.

Nothing you already do breaks. type and subject are accepted forever and translate at the boundary. An existing store is never converted silently: it keeps working exactly as it is, with every v2 behaviour, until you run jamgate migrate deliberately.

Measured on a real 80-record store

before after
read on every save and recall 843.5 KB 75.1 KB (11.2× smaller)
total on disk 843.5 KB 233.9 KB — while adding an archive, an index and an event log

88.1% of that file was embedding vectors written out as pretty-printed JSON, and 26.5% of the whole thing was the whitespace inside those arrays.

New

  • jamgate archive / restore / purge — everything that leaves recall keeps its reason, provenance and timestamp, and comes back with its original assertion date.
  • jamgate topics — the namespace, so an agent can reuse a topic instead of inventing a second name for a fact that already has one. Save replies now list candidate topics when the gate was unsure. This is the cheapest thing in the release and the one that prevents the most damage.
  • jamgate events — an event log covering every operation, not just save decisions. A recall line carries the query and never the results; a purge line records that bytes went away and never what they said.
  • jamgate migrate — five gates, a verified backup, and the rollback executed and diffed before going forward.
  • volatility + category replace the two meanings type conflated. includeArchived on recall. GET /v1/archive, GET /v1/topics, POST /v1/memory/:id/restore, POST /v1/purge/:id.

Removed

  • The expiry clock. A record past its freshness window is returned, marked stale, and ranked below every fresh match. Nothing is hidden and nothing is deleted.
  • The write-time lifespan question. It asked you to confirm a lifespan that no longer exists, so the write path now interrupts you never.

Honest notes

The read path and the review loop are the phases after this one and are not started. Contradiction detection across two different topics still does not exist — four rule-based detectors were built and measured against a real store, and every one either drowned in false positives or missed the case it existed for.

docs/DESIGN-v2.md §15 and D-079 record the eight places building this proved the approved design wrong, four of them found by the migration's own gates on their first run against real data.

681 tests, Node 20 and 22.

Install: npx jamgate setup · one-click Claude Desktop bundle: the .mcpb below.

0.18.0 — assert the layer the user stands on

Choose a tag to compare

@amirj4m amirj4m released this 10 Aug 12:30

A defence built at the layer it was designed in, and never checked at the layer the user stands on.

An independent audit of the previous six releases found four user-visible defects that 644 tests could not have caught. They are one bug in four costumes: in every case a component was correct, its test asserted the component, and the thing the user touches was never asserted at all. See D-078.

Fixed

  • A stale memory could outrank the correction that replaced it. 0.17.0 returned expired human-sourced records marked STALE into a ranking with no notion of staleness. Freshness is now a tier applied before relevance. Measured against 0.17.0 on the same corpus, the "how much do I still owe" scenario went from 4 of 6 phrasings reading the stale figure first to 0 of 6, while the top-1 relevance baseline stayed at 14/17 with an identical miss list. Not "newest wins" — an identity fact never expires, so it is never stale. A demotion, never an exclusion.
  • A subject correction was refused as a duplicate, and a record with no type could never be given one — fourteen such records existed in a real store: no TTL, never expiring, never compacting, unrepairable through the API. Both are one fix, stated as a rule instead of per field: a re-save of identical text is a correction when the caller declares a classifying field the record does not already carry.
  • Every refusal reached a REST caller as internal server error. Domain errors are now a declared type and travel intact: isError tool content over MCP instead of a -32603 protocol error, and 409 store_retired / 500 store_unreadable with the message over REST. Undeclared exceptions stay exactly as opaque as before.
  • The reply for a correction described a no-op — identical text either side of "in favor of". It now names the change and the resulting lifespan.
  • jamgate import ignored its own injected environment, which is why the test suite kept writing into a real gate log after 0.16.0 "fixed" it.
  • Every save read and parsed the whole store twice. 4 readFile calls per save → 2.

Testing

The suite now asserts what reaches the user — reply text, isError, HTTP status, response bodies — through a real MCP handshake and a real HTTP server, and asserts relative rank rather than marker presence. It has a real-data path for the first time: invented records with the shapes that actually break things (untyped, subject-less, multi-topic, ~1,500 characters, mixed script). 644 tests, was 624.

Numbers corrected against their sources

The gate-log contamination was 528 of 553 lines (95.5%) including all 64 duplicate verdicts, not "264" and "63 of 66". The corpus is now split rather than blended. The lifespan question fires on 3 of 21 saves (14.3%), re-derived. Scale is measured to 10,000 records instead of "untested past ~100".


Claude Desktop: download jamgate-0.18.0.mcpb below and open it.
Everyone else: npx jamgate@0.18.0 setup

v0.17.0 — a memory you asked for is stale, never hidden

Choose a tag to compare

@amirj4m amirj4m released this 10 Aug 09:58

0.14.0's D-072 protected the bytes and not the behaviour. It stopped TTL destroying anything human-sourced and stopped there: the record stayed on disk, listExpired reported it could never be compacted — and recall returned nothing. From the user's chair that is indistinguishable from deletion.

Measured, not argued. A user-confirmed record backdated one hour past its TTL:

recall BEFORE expiry : 1 hit
recall AFTER expiry  : 0 hits      <-- the answer
listExpired          : 1 record, compactableAt = null   (safe forever)
still on disk        : 1 record

Safe forever and unreachable forever.

A human-sourced memory past its window is STALE, not hidden

user-explicit and user-confirmed records stay recallable indefinitely, marked [STALE — asserted N days ago …], so an agent can say "as of 8 August" instead of silently answering from an older record that never expires. Agent-inferred records are unchanged — hidden on schedule, compacted after grace.

What this was found by, and what it was NOT

The maintainer asked the same question on two devices minutes apart and got two different answers about money he owes. The obvious explanation — the newer record had expired — was wrong: it expires 11:30 UTC and the screenshot was 11:21 local, three hours earlier. The record was live.

The real cause is recall ranking. Across six natural phrasings of "how much do I still owe on the motorbike", the stale 6 August figure won four, and the correct 8 August one was missing from the top five on three of them. That is the project's known weakest point now demonstrably producing wrong answers about someone's finances, and it is recorded as a defect rather than a limitation.

Also found: the two figures were both live under different subjects, so subject-keyed supersession never fired. Cross-subject contradiction detection is scoped in D-077, not guessed at here.

624 tests.

v0.16.0 — the gate refused the repair it had just recommended

Choose a tag to compare

@amirj4m amirj4m released this 10 Aug 09:28

Four defects, all from one real save.

You could not correct a memory's type

A durable fact was saved as type: "state" (2-day TTL). The expiry warning fired correctly, and the calling agent re-saved the identical text as identity six seconds later — and the gate answered "Already known (no duplicate added)", leaving the expiry in place. The exact-duplicate check compared text alone and ran before anything looked at type, so it refused the one operation that repairs a mis-typed memory. It was only stored durably after the text was reworded until it slipped past.

A re-save with the same text and a different, explicitly stated type now supersedes. Identical text and identical type is still a duplicate, so repeated saves cannot churn the store; a lower-trust source still gets a conflict. Measured on 672 real gate-log entries: 66 duplicate verdicts, exactly one deliberate type correction — the fix fires almost never, by design.

The credential layer let a password through

A save reading … PDF password <six digits>. was stored verbatim. ASSIGNMENT needs a :/=/is/was between keyword and value and there was none; a six-digit value is far below the entropy rule's 20-character, three-class floor. The most compact way a human states a password was the one shape the detector could not see.

A juxtaposition rule now scans the clause after the keyword, while an explicit head-redirect list keeps password manager, password policy, password reset and password protected storable — a credential layer that eats real memories gets switched off, and then it protects nothing.

Two disclosure paths, both worse than the original gap

forget wrote the deleted record's full text to the gate log, so removing a secret copied it into the audit trail. And jamgate import echoed whole records, so running an import to redact a password printed that password. Both fixed.

The test suite was contaminating the corpus

npm test inherited the default gate-log path and wrote 8 synthetic decisions into the real ~/.jamgate/gate.log every run — 264 accumulated, and 63 of the 66 duplicate verdicts in it were fixtures. Now runs with JAMGATE_GATE_LOG=off.

619 tests. Full reasoning in D-076.

v0.15.1 — the cross-script name gap, measured and documented

Choose a tag to compare

@amirj4m amirj4m released this 09 Aug 23:46

Investigating why an agent didn't know who "Rahman" or "Iraj" was turned up the only recall failure in the project measured at 0%.

Both names were saved on 2026-08-06, both are still active and never-expiring in the same record, and the gate never refused either. What fails is finding them:

Query script Rahman Iraj
Latin 5/6 at rank 1, 1 at rank 2 6/6 at rank 1
Persian 0 of 2 0 of 2

Persian recall itself is healthy — Persian queries hit Persian-script records at rank 1. What has no bridge is a name transliterated between scripts: رحمان cannot reach a record spelling it Rahman, in either direction. For a bilingual user the memory is present, correct, and unreachable in half the languages they type.

Not fixed here, deliberately. Persian romanization is many-to-many, so a bridge generates candidate keys rather than one key, widening the lexical net on a store that already ranks imperfectly. It needs its own corpus and measurement; doing it badly would degrade the paths that work today. Scoped in D-075 as the next recall work.

Also recorded: the gate log truncates text at 500 characters, so a name past the cut is absent from the log even when the save was accepted. Searching the log and finding nothing is not evidence a fact was never offered.

v0.15.0 — a retirement marker an older Jamgate cannot strip

Choose a tag to compare

@amirj4m amirj4m released this 09 Aug 23:22

The retirement marker 0.14.0 shipped could be silently erased by an older Jamgate.

Found while investigating why an agent didn't know who "Rahman" was. A jamgate@0.9.2 stdio server — spawned on 2026-08-05 and still running four days later, serving a live client whose config entry had already been removed — accepted a write to the retired store and, serializing the envelope the way 0.9.2 does, stripped retiredTo in the same operation. No error, no log line. The defence deleted itself the first time it was tested against the thing it defends against.

A marker old builds cannot remove

jamgate retire now also writes <store>.retired, a sidecar no version older than the feature has heard of. The sidecar is authoritative; the in-file marker is kept because it travels when a store is copied.

Sidecar present + in-file marker missing is not an inconsistency to repair quietly — it is proof that a pre-0.14.0 build is still writing. jamgate status and jamgate retire --status report it, and re-running retire repairs rather than no-ops.

A config entry is not the process

Removing a client's stdio entry does not kill a server it already started. jamgate status reads config files, so it reported "no split" while a four-day-old process was still serving a live client from the retired store. Stop the process as well as the entry — ps aux | grep jamgate.

The general rule: when you add a guard, ask what the old version of your own software does to it — old versions are the ones running on the machines that need the guard.

600 tests. Full reasoning in D-074.

v0.14.1 — stop the release count from rotting

Choose a tag to compare

@amirj4m amirj4m released this 09 Aug 18:09

The README's release count was corrected to "Twenty-seven" in 0.13.2 and was wrong by two three releases later.

An exact count of your own releases, written inside a release, is guaranteed to rot — and under RULES §8 each correction is itself a release, which makes it rot again. Rephrased to a form that cannot be off by one.

v0.14.0 — a memory that lives in two places must say so

Choose a tag to compare

@amirj4m amirj4m released this 09 Aug 18:02

Same class of failure as 0.13.0's silent expiry, one layer up.

My laptop was wired back to a local stdio store while my real memory lived on a remote instance. Every save succeeded, nothing reported anything, and seven memories accumulated on the wrong machine over four days — including my decision to write the account of my eight illegal border crossings. A scheduled check found it. The product did not.

Root cause

D-047's transport-downgrade guard is per-file: it only refuses to overwrite a remote entry with a local one in the same config. When the entry has been deliberately deleted, there is nothing in that file to guard — so a plain jamgate setup adds a fresh local stdio entry, even though other clients on the same machine point at a remote instance. From inside one config, "deliberately retired" and "never configured" look identical.

Three defences, because the failure has three moments

It must not be creatable. jamgate setup now scans every client for an existing remote wiring and refuses to add a local store when it finds one. --remote is unaffected; --force still allows a deliberate second store.

It must not be writable. jamgate retire --to <url> marks the store file — the only artifact that travels with the memory — and a retired store refuses every write with an error naming the real instance. Reads keep working and nothing is deleted.

An existing split must announce itself. jamgate status leads with ⚠ SPLIT MEMORY, naming which clients are remote, which are local, and the three commands that reconcile them.

Refusing the write is the opposite of 0.13.0's fail-open, deliberately: fail-open is right when the alternative is losing data, fail-loud is right when the alternative is losing track of data.

Also

  • jamgate import warns when an imported record came from the user but expires within days — an import has no client to ask, so it reports rather than re-typing.
  • Claude Desktop has no verified HTTP transport in setup, so --remote skips it. The correct end state there is no jamgate entry at all, reaching shared memory through the claude.ai connector.

596 tests. Full reasoning in D-073.

v0.13.2 — correct the release count in Honest limits

Choose a tag to compare

@amirj4m amirj4m released this 09 Aug 17:26

The README said "Twenty-three releases" against 27 published on npm.

Trivial on its own, and exactly the class of stale self-description RULES §8 exists to stop — the sentence lives in Honest limits, where being precise is the whole point of the section.