Skip to content

v3.2.1

Choose a tag to compare

@github-actions github-actions released this 28 May 03:55
· 148 commits to main since this release

3.2.1 - 2026-05-28

Fixed

  • TOML back-compat shim for endorsement_debt_ratio_threshold (T9).
    3.2.0's T1 rename of the [behavior] endorsement_debt_ratio_threshold
    key to cold_endorsement_ratio_threshold shipped without an alias,
    so a user upgrading from 3.1.x with the old key in their config
    silently lost the threshold:
    behavior_raw.get("cold_endorsement_ratio_threshold", 0.0) fell
    through to the dataclass default and the bucket reverted to the
    strict "explicit == 0" check, dropping the loosened-ratio behaviour
    the user had configured. No warning fired — the misconfiguration
    was invisible. T9 adds a shim inside load_config (before
    BehaviorConfig construction) covering four cases: (1) old key
    only → populate the new key with the legacy value and emit a
    one-shot DEPRECATION warning naming both keys plus the resolved
    path; (2) new key only → no-op (post-3.2.0 happy path); (3) both
    present → the new key wins and a STRONGER one-shot warning instructs
    deletion rather than rename; (4) neither → no-op, dataclass default
    applies. The one-shot guard is keyed on (resolved_config_path, key_kind) and mirrors the _DIVERGENCE_WARNED_ROOTS discipline
    in store.py: a long-lived server that rereads config on signal
    doesn't spam the log, but two distinct configs in the same process
    each get their own warning. Path resolution falls back to the
    unresolved path on OSError so the guard still works if the config
    moved between open() and warn-time. This supersedes the
    "silently fall back to the default 0.0" guidance in the 3.2.0
    T1 entry above — the deprecation window is now explicit and
    loud-on-load instead of silent-on-rollup.
  • Store.restore under-lock recheck (W7). Mirror of the W1
    tombstone() recheck. Pre-W7 Store.restore walked
    _find_tombstone_path_for_id and _find_path_for_id unlocked,
    then acquired _locked(tombstone_path) and called
    frontmatter.load(tombstone_path) without a recheck. Two
    restorers of the same id, or a restore racing with a concurrent
    prune_tombstones, would both pass the unlocked finds; the loser
    hit a bare FileNotFoundError from inside frontmatter.load
    caught by an inline arm and re-raised as MemoryNotFoundError,
    but with no symmetric "raced with" message and no recheck for the
    case where the active record was re-created in the restore window
    (silent _atomic_write_post clobber). Fix adds two under-lock
    rechecks mirroring W1's discipline: (1) _id_still_at_path(tombstone_path, memory_id) — the tombstone still carries the expected id (catches
    the parallel-restore / prune race and the extremely-unlikely
    tombstone-stem-reuse edge); (2) _find_path_for_id(memory_id) is None
    — defensive against the narrow case where a parallel restore
    unlinked the tombstone AND a separate path re-created an active
    file at the predicted slug-suffix-determined active_path between
    the pre-lock active check and the lock acquisition. Either recheck
    failing raises a typed exception (MemoryNotFoundError /
    NotTombstonedError) with a "raced with concurrent restore [or
    prune]" hint, matching W1's find-time pre-lock fallback message
    shape. The memory_restore handler gains an OSError arm mirroring
    W1's memory_remove so genuine disk-level failures during the
    restore write / unlink (EIO, ENOSPC, EACCES, …) surface as
    structured ValueError instead of leaking as bare OSError.
  • config.load_config first-run default-config write is now atomic
    (Q29).
    The first-run writer used
    config_path.write_text(DEFAULT_CONFIG, encoding="utf-8"), which
    leaves a truncated TOML on power loss / process kill mid-write;
    the next run would see the malformed config and crash at
    tomllib.load, turning a single bad shutdown into a hard-block
    first-run experience. Now routes through _fsutil.atomic_write_bytes
    (the F5/F6 helper), same shape as the F5 init.py MCP-client-config
    and F6 sync.py .gitignore migrations: plain bytes payload, no
    special mode bits, no privacy bar — the helper's chmod-after-rename
    posture is the right fit.
  • bettermemory export -o PATH is now atomic (Q29). The CLI
    export writer used out_path.write_text(text + "\n", encoding="utf-8"),
    which leaves a truncated JSON on power loss / process kill
    mid-write. For a CLI intended for scripted backup
    (bettermemory export -o backup.json), a half-written file is the
    exact failure mode the user is trying to guard against. Now routes
    through _fsutil.atomic_write_bytes, completing Queue #29's
    simple-bytes Branch A targets (F5 init.py, F6 sync.py, Q29 config.py,
    Q29 cli/export.py). The remaining deferred sites
    (_atomic_write_post, episodes._write_path,
    semantic.flush_persistent_cache, events._compress_rotating)
    all need fchmod-before-rename, which the current helper contract
    doesn't cover.
  • Store.mark_verified race shape — attestation overwrite (W8).
    Mirror of the W2 Store.update CAS pattern, applied to the
    verification path. Pre-W8 two agents calling memory_verify on the
    same id with disjoint attestations (e.g. agent A spot-checking path
    #1, agent B spot-checking path #2 simultaneously) would silently
    last-write-wins on the verified_paths / verified_commits /
    verified_versions lists, because both mark_verified calls read
    the same on-disk snapshot and the second write clobbered the first
    without a CAS check. The REPLACE-not-append semantics on those
    lists (by design — the event log is the audit trail) made the
    silent loss especially nasty: one of the two agents' attestations
    vanished without surface. Post-W8 Store.mark_verified gains an
    optional expected_last_verified_at snapshot fingerprint plus a
    check_expected=True opt-in; under the lock, after the C2 recheck,
    the on-disk last_verified_at is compared to the caller's
    snapshot. On mismatch raise ConcurrentUpdateError carrying the
    on-disk updated (kept uniform with W2's exception contract — the
    caller's rebase action is the same: re-fetch via memory_show and
    retry). The memory_verify handler loads its snapshot first and
    opts in; legacy direct-store callers (the web UI verify form, the
    no-arg slide-the-timestamp-forward use cases, the existing tests)
    keep the back-compat check_expected=False default. MCP response
    shape on CAS failure exactly mirrors memory_update's W2 stale
    payload: {"status": "stale", "memory_id": ..., "current_updated": ..., "hint": ...}. Fingerprint choice: updated doesn't move on a
    verify (orthogonal to content edits) so checking it would never
    catch the verify-vs-verify race; last_verified_at is the field
    that always moves on a successful verify, so it's the cheapest
    correct fingerprint.
  • bettermemory export -o PATH raises on a missing parent
    directory again (Y1).
    The Q29 atomic-write migration above
    inadvertently changed user-facing semantics: pre-3.2.1 the CLI
    used a bare out_path.write_text(...), which raised
    FileNotFoundError when --output's parent directory didn't
    exist; routing through _fsutil.atomic_write_bytes (whose
    mkdir(parents=True, exist_ok=True) is intentional for
    fresh-install callers like init.py under ~/.config and
    sync.py under a fresh sync root) silently created the parent
    tree instead, burying a bettermemory export -o /typod/path/backup.json
    backup at an unintended location with no error. A pre-check in
    _cli_export restores the 3.2.0 loud-error contract for the
    export caller while leaving the helper's auto-mkdir intact for
    its other callers. A bare filename (-o backup.json, parent
    Path(".")) still works since the cwd always exists.

Internal

  • EndorsementDebtRowColdEndorsementMemoriesRow eval-module
    rename (T8).
    3.2.0's T1 renamed the public endorsement_debt
    field to cold_endorsement_memories across the health surface
    but explicitly deferred the eval-module internal classes
    (EndorsementDebtRow, endorsement_debt_rows,
    endorsement_debt_total) as a separate API. That left drift
    between the renamed public JSON key (already
    cold_endorsement_memories) and the still-old internal Python
    identifiers that filled it. T8 closes the drift:
    EndorsementDebtRowColdEndorsementMemoriesRow;
    EvalReport.endorsement_debt_rowscold_endorsement_memories_rows;
    EvalReport.endorsement_debt_totalcold_endorsement_memories_total;
    function-local debt_rows/debt_totalcold_rows/cold_total;
    comments/docstrings/render-text header ("Endorsement-debt memories"
    → "Cold-endorsement memories") plus CLI --min-retrievals help
    text; TestEndorsementDebtTestColdEndorsementMemories and
    test_endorsement_debt_section
    test_cold_endorsement_memories_section. Internal-only rename:
    the MCP wire shape and eval JSON output key were already renamed
    by T1; this only touches Python-level identifiers and CLI text.
    No alias — the eval module's only external consumers are its
    tests and the CLI wrapper, both migrated. Remaining
    endorsement_debt snake_case sightings are confined to T9's
    back-compat TOML alias in config.py and its tests, and
    explanatory DESC-string regression-guard assertions in
    test_server_v12_features.py / test_health.py.
  • flock_excl annotation tightened to Generator[None, None, None]
    (F15).
    The @contextlib.contextmanager-decorated flock_excl
    in _fsutil.py was typed as Iterator[None], which loses
    send() / return-T type information that Generator[YieldT, SendT, ReturnT] preserves. Explicit three-arg form for Python
    3.11+ compatibility (the single-arg Generator[T] shorthand is
    3.13+ only). Purely type-annotation; no runtime impact.
  • _flock_windows annotation matches F15 (F15-followup).
    Completes the F15 pattern sweep. _flock_windows is the generator
    helper used via yield from _flock_windows(...) inside
    flock_excl's Windows branch; same shape, same fix. Iterator
    is now unused in _fsutil.py and leaves the imports too. Purely
    type-annotation; no runtime impact.
  • docs/api.md documents the W2/W8 stale + W7 race-loss surfaces
    (R3).
    Three tools landed structured race-loss / stale responses
    in 3.2.0–3.2.1 that the API contract documented only the happy
    path of. Append a paragraph to each so multi-agent callers learn
    about the {"status": "stale", ...} shape on memory_update
    (W2) / memory_verify (W8) and the structured
    ValueError("raced with ...") translation on memory_restore
    (W7); W8 also calls out last_verified_at as the snapshot
    fingerprint (not updated, since verify is orthogonal to content
    edits). Documentation only.
  • _fsutil.atomic_write_bytes docstrings refreshed post-Q29
    (Y2).
    Module + function docstrings claimed two callsites and a
    forward-looking promise to migrate _atomic_write_post +
    episodes._write_path; reality at HEAD is that Q29 deferred those
    two privacy-critical writers (they need fchmod-before-rename,
    which the helper doesn't support) and instead migrated
    config.py's default-config writer and cli/export.py's -o
    output writer. Switched to caller-shape framing ("plain-bytes
    payload, no privacy-0o600 requirement") so the prose stops
    drifting on every new caller, and rewrote the Q29 caveat to
    reflect what shipped. Docstring only.
  • T1 rename completed in user-facing prose (Y10+Y11+Y12).
    3.2.0's T1 finished renaming endorsement_debt
    cold_endorsement_memories in code, MCP wire keys, DESC strings,
    and the eval render header, but prose docs still taught the old
    branding. Swept README.md, docs/ROADMAP.md, and docs/eval.md
    to the new vocabulary. Historical CHANGELOG / api.md references to
    the old key are preserved on purpose — they document the 3.1.x →
    3.2.0 wire-shape break and the T9 TOML back-compat shim. Docs only.
  • Raises: blocks added to Store.update / mark_verified /
    tombstone (Y9).
    Symmetric with W7's Store.restore.
    Documents the ConcurrentUpdateError / MemoryNotFoundError /
    TombstonedError race-loss paths in structured form so IDE
    introspection matches what the type checker already reads from the
    bodies. Docstring only.
  • Test-scaffold accuracy fixes (Y3+Y6+Y8). Three small
    test-only fixes batched: test_store_locking.py's traced_locked
    annotation upgraded Iterator[None]Generator[None, None, None] (completes the F15 sweep on the test side);
    test_concurrency.py's multi-process restore test gains a
    closed-set assertion on the three known loser-message shapes
    (the prior version was not a true W7 regression guard); and
    test_config.py gains test_load_config_falsy_old_key_emits_warning
    pinning that an explicit endorsement_debt_ratio_threshold = 0.0
    still fires the deprecation warning and migrates (presence
    triggers, value doesn't), with a corrected docstring on the
    absent-key sibling.
  • Test pinning the BOTH-keys legacy-alias one-shot guard (Y4).
    _apply_legacy_endorsement_debt_alias's +both guard tuple is
    logically distinct from the old-only one; a sibling test loads a
    both-keys TOML three times and asserts exactly one BOTH warning
    fires, guarding against a regression that collapses the suffix or
    cross-suppresses between branches. Test only.
  • Test covering Store.restore's active-recheck branch (Y7).
    The existing W7 monkeypatch test exercised the tombstone-recheck
    (which fires first) rather than the active-recheck
    (NotTombstonedError) branch it claimed to. Added a dedicated
    test that patches _find_path_for_id only on the under-lock call
    site so the active-recheck runs deterministically, and tightened
    the original test to the exception it actually raises
    (MemoryNotFoundError). Test only.
  • eval.py comment justifying the ENDORSEMENT vs
    COLD_ENDORSEMENT name choice (Y5).
    Expanded the
    literal-duplication comment to also explain why the eval-side
    identifier omits the cold_ bucket prefix that health carries:
    the eval output nests under cold_endorsement_memories, so the
    bucket scope is already supplied and the parameter prefix would be
    redundant — whereas health's flat-kwarg path needs it.
    Comment only.
  • Tests pinning cross-branch independence of the alias guards
    (Y4-fu).
    Y4 pinned same-branch repeat-suppression but reset the
    guard at entry, leaving the cross-branch transition (old-only on
    path P, then both-keys on the same path with no reset) untested.
    Added both directions, asserting each branch's warning fires fresh
    and both guard tuples are present, so a regression that collapsed
    the +both suffix into a shared tuple can't cross-suppress and
    still pass. Test only.
  • Multi-process W7 race test rendezvous (Y6-fu). Added a
    file-based rendezvous (per-worker ready-marker + a shared
    go-marker the parent touches once all workers are ready) so all
    losers route through the W7 under-lock recheck and carry the
    "raced with" hint, letting the strict assertion the threaded
    variant already uses hold for the multi-process variant too —
    upgrading Y6's pragmatic closed-set concession. Test only.

Full diff: v3.2.0...v3.2.1