Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ flow_doctor.db
# Fly deployment config — user-specific, must not be committed.
# Template lives at fly.toml.example; copy + edit for your own deploy.
fly.toml

# Capture-attention calibration output — contains operator vault content
# (real memory titles + snippets), must not be committed. Schema template
# lives at tests/fixtures/capture_attention_pairs.example.json.
tests/fixtures/capture_attention_pairs.json
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,57 @@
# Changelog

## [0.7.0rc4] - 2026-05-24

### Capture-attention Phase A — activation infrastructure

- **New `MNEMON_CAPTURE_ATTENTION_ENABLED` env-var override** on the
Phase A feature flag. Mirrors the standing-tier pattern
(`MNEMON_STANDING_TIER_ENABLED`) — operators can flip activation on
Fly via `flyctl secrets set` without a code change + redeploy, and
the next save picks it up without restarting the server. Accepts
`1`/`true`/`yes`/`on` (truthy) or `0`/`false`/`no`/`off` (falsy);
unset / unrecognized falls back to `config.CAPTURE_ATTENTION_ENABLED`
(still default `False` through soak). New
`store._capture_attention_enabled()` helper called at request time
from `Store.save` and `cli attention-status`. 5 new tests.
- **`mnemon attention-status` now reports the effective flag value**
with the env-var override applied — a Fly secret flip shows up here
immediately instead of misleading the operator with the unchanged
config default.

### Calibration fixture privacy hardening

- **`tests/fixtures/capture_attention_pairs.json` is now gitignored.**
PR #153 shipped this path tracked with a placeholder schema —
intended as a seed, but every operator calibration run overwrites
it with real vault titles + snippets (personal context, in-flight
work, etc.) that must not land in a public-repo commit. The
placeholder schema moves to
`tests/fixtures/capture_attention_pairs.example.json` (tracked) so
future contributors still see the format; the operator output stays
local-only.

### Calibration script fixes (`scripts/calibrate_capture_threshold.py`)

- **`VecStore.get(vec_id) -> np.ndarray | None`** added — mirrors the
`has` / `delete` single-id shape; returns a defensive copy. The
calibration script's `vs.get(vec_id)` call site failed on first
invocation because the method did not exist. 3 new tests (returns
vector, missing → None, defensive-copy invariant).
- **Near-neighbor pair sampling** replaces uniform-random. The previous
random sample across a 2510-memory vault produced pair cosines
clustered at 0.1-0.4 (clearly-different topics) — operator verdicts
carried no information about whether the threshold cut should be
0.80 or 0.85. New sampler picks anchors, takes each one's top
non-self neighbor above cosine 0.55 (well below the lowest
calibration threshold so edge-negatives survive), and sorts
descending so the operator tags high-confidence near-dupes first.
Verified against the 2026-05-24 prod snapshot: 20-pair sample spans
cosine 0.751-0.999, entirely in the calibration-relevant range.
Calibration on that snapshot recommended
`CAPTURE_ATTENTION_THRESHOLD = 0.85` — matches the existing default,
so no config change needed.

## [0.7.0rc3] - 2026-05-22

### Test coverage
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "mnemon-memory"
version = "0.7.0rc3"
version = "0.7.0rc4"
description = "Universal long-term memory layer for AI agents via MCP"
readme = "README.md"
license = "MIT"
Expand Down
85 changes: 68 additions & 17 deletions scripts/calibrate_capture_threshold.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,32 @@


def _load_pairs(db_path: Path, n: int) -> list[dict]:
"""Sample N random memory pairs + their pairwise cosine similarity."""
"""Sample N near-neighbor memory pairs from the threshold decision region.

The naive uniform-random sample over a 2510-memory vault produces pairs
whose cosines cluster at 0.1–0.4 (clearly-different topics) — operator
verdicts on those pairs carry no information about whether the
CAPTURE_ATTENTION_THRESHOLD cut should be 0.80 or 0.85. Every pair
a calibration operator tags should sit in the decision region (cosine
near the candidate thresholds).

Strategy: pick a random anchor, take its top non-self neighbor via
vector search, and accept the pair if cosine ≥ ``MIN_PAIR_COSINE``.
Repeat until ``n`` pairs are collected or the search budget is
exhausted. Bias toward higher-cosine pairs is the desired calibration
behavior — the threshold lives in the high-cosine tail.
"""
import numpy as np

src = REPO_ROOT / "src"
if str(src) not in sys.path:
sys.path.insert(0, str(src))
from mnemon.vecstore import VecStore

MIN_PAIR_COSINE = 0.55 # well below the lowest calibration threshold (0.70)
# — preserves edge-negatives the threshold should NOT flag
MAX_ATTEMPTS = max(n * 20, 200) # generous budget for the rejection loop

vec_path = str(db_path).replace(".sqlite", ".vec")
if not Path(vec_path + ".npz").exists():
sys.exit(
Expand All @@ -72,39 +90,72 @@ def _load_pairs(db_path: Path, n: int) -> list[dict]:
ORDER BY id"""
).fetchall()

# Build hash → embedding map (seq=0 only — that's the full-doc fragment)
embs: dict[str, "np.ndarray"] = {}
# Build hash → (id, title, embedding) map (seq=0 only — full-doc fragment).
by_hash: dict[str, dict] = {}
for r in rows:
vec_id = f"{r['hash']}_0"
vec = vs.get(vec_id)
if vec is not None:
embs[r["hash"]] = vec
by_hash[r["hash"]] = {"id": r["id"], "title": r["title"], "vec": vec}

eligible = [r for r in rows if r["hash"] in embs]
if len(eligible) < 2 * n:
if len(by_hash) < n + 5:
sys.exit(
f"ERROR: only {len(eligible)} eligible memories in vault "
f"(need ≥{2 * n} for {n} pairs)"
f"ERROR: only {len(by_hash)} eligible memories in vault "
f"(need at least {n + 5} for {n} near-neighbor pairs)"
)

random.seed(42)
chosen = random.sample(eligible, 2 * n)
pairs = []
for i in range(0, 2 * n, 2):
a, b = chosen[i], chosen[i + 1]
va, vb = embs[a["hash"]], embs[b["hash"]]
cos = float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb)))
# Pull content snippets for review
ac = db.execute("SELECT doc FROM content WHERE hash = ?", (a["hash"],)).fetchone()
bc = db.execute("SELECT doc FROM content WHERE hash = ?", (b["hash"],)).fetchone()
candidate_hashes = list(by_hash.keys())
pairs: list[dict] = []
seen_pair_keys: set[tuple[str, str]] = set()
attempts = 0

while len(pairs) < n and attempts < MAX_ATTEMPTS:
attempts += 1
anchor_hash = random.choice(candidate_hashes)
anchor = by_hash[anchor_hash]
# k=3 → first hit is the anchor itself (cosine 1.0); take the next
# distinct hash. Occasionally a vault has duplicate-content fragments
# so we filter by hash, not just rank.
results = vs.search(anchor["vec"], k=3)
neighbor = None
for res in results:
res_hash = res["id"].rsplit("_", 1)[0]
if res_hash == anchor_hash or res_hash not in by_hash:
continue
neighbor = (res_hash, float(res["similarity"]))
break
if neighbor is None:
continue
nhash, cos = neighbor
if cos < MIN_PAIR_COSINE:
continue
pair_key = tuple(sorted([anchor_hash, nhash]))
if pair_key in seen_pair_keys:
continue
seen_pair_keys.add(pair_key)

a, b = by_hash[anchor_hash], by_hash[nhash]
ac = db.execute("SELECT doc FROM content WHERE hash = ?", (anchor_hash,)).fetchone()
bc = db.execute("SELECT doc FROM content WHERE hash = ?", (nhash,)).fetchone()
pairs.append({
"id_a": a["id"], "id_b": b["id"],
"title_a": a["title"], "title_b": b["title"],
"snippet_a": (ac["doc"] if ac else "")[:200],
"snippet_b": (bc["doc"] if bc else "")[:200],
"cosine": cos,
})

db.close()
if len(pairs) < n:
print(
f"WARNING: only {len(pairs)} pairs found above cosine "
f"{MIN_PAIR_COSINE} in {attempts} attempts — vault may lack "
f"semantic clusters. Proceeding with what we have."
)
# Sort by cosine descending so the operator tags high-confidence
# near-dupes first (catches the calibration intuition early).
pairs.sort(key=lambda p: -p["cosine"])
return pairs


Expand Down
2 changes: 1 addition & 1 deletion src/mnemon/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""mnemon — Universal long-term memory layer for AI agents via MCP."""

__version__ = "0.7.0rc3"
__version__ = "0.7.0rc4"
6 changes: 4 additions & 2 deletions src/mnemon/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,10 +413,10 @@ def _print_attention_status(store) -> None:
2. precision floor (operator-judged via --review, not auto-checked)
"""
from .config import (
CAPTURE_ATTENTION_ENABLED,
CAPTURE_ATTENTION_THRESHOLD,
CAPTURE_ATTENTION_SOAK_BOOST_RATE_MAX,
)
from .store import _capture_attention_enabled

# Boost rate over 7d (boosts = restates relations created)
boosts_7d = store.db.execute(
Expand All @@ -431,8 +431,10 @@ def _print_attention_status(store) -> None:
rate = (boosts_7d / saves_7d) if saves_7d else 0.0
rate_ok = "✓" if rate <= CAPTURE_ATTENTION_SOAK_BOOST_RATE_MAX else "⚠"

# Effective flag value reflects MNEMON_CAPTURE_ATTENTION_ENABLED env-var
# override; a Fly secret flip shows up here without restarting the server.
print(f"Capture attention — soak status")
print(f" Flag enabled : {CAPTURE_ATTENTION_ENABLED}")
print(f" Flag enabled : {_capture_attention_enabled()}")
print(f" Threshold (cosine) : {CAPTURE_ATTENTION_THRESHOLD}")
print(f" Boost-rate 7d : {boosts_7d} / {saves_7d} = "
f"{rate:.3f} {rate_ok} (ceiling {CAPTURE_ATTENTION_SOAK_BOOST_RATE_MAX})")
Expand Down
27 changes: 25 additions & 2 deletions src/mnemon/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import hashlib
import os
import sqlite3
import time
import uuid
Expand All @@ -19,7 +20,6 @@

from .config import (
CAPTURE_ATTENTION_BOOST,
CAPTURE_ATTENTION_ENABLED,
CAPTURE_ATTENTION_MIN_HITS,
CAPTURE_ATTENTION_REQUIRE_DISTINCT_SESSIONS,
CAPTURE_ATTENTION_THRESHOLD,
Expand All @@ -39,6 +39,29 @@
from .vecstore import VecStore


def _capture_attention_enabled() -> bool:
"""Resolve the capture-attention feature flag (env-var override).

Truth sources, in order:
1. ``MNEMON_CAPTURE_ATTENTION_ENABLED`` env var (operator override) —
lets the operator flip activation on Fly via ``flyctl secrets
set`` without a code change + redeploy.
2. ``config.CAPTURE_ATTENTION_ENABLED`` (default-off through soak).

Mirrors the standing-tier helper in
``hooks/context_surfacing.py:_standing_tier_enabled``. Called at
request time (in ``Store.save``), so secret flips take effect on
the next save without restarting the server.
"""
env = os.environ.get("MNEMON_CAPTURE_ATTENTION_ENABLED", "").strip().lower()
if env in ("1", "true", "yes", "on"):
return True
if env in ("0", "false", "no", "off"):
return False
from .config import CAPTURE_ATTENTION_ENABLED
return CAPTURE_ATTENTION_ENABLED


class CaptureAttentionUnavailableError(RuntimeError):
"""Raised when the capture-attention path can't complete its check.

Expand Down Expand Up @@ -459,7 +482,7 @@ def save(
# secondary observability hung off a primary path (the save
# itself) that survives independently. Mirrors the existing
# embed_document() WARN pattern in server.py:memory_save.
if CAPTURE_ATTENTION_ENABLED and correction_of is None:
if _capture_attention_enabled() and correction_of is None:
try:
self.apply_capture_attention(
new_doc_id=doc_id, content=content,
Expand Down
11 changes: 11 additions & 0 deletions src/mnemon/vecstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@ def size(self) -> int:
def has(self, vec_id: str) -> bool:
return vec_id in self._ids

def get(self, vec_id: str) -> np.ndarray | None:
"""Return the vector for ``vec_id``, or ``None`` if not present.

Returns a defensive copy — callers can mutate freely without
affecting the in-memory store (matches ``export_all``'s contract).
"""
if vec_id not in self._ids or self._vectors is None:
return None
idx = self._ids.index(vec_id)
return self._vectors[idx].copy()

def delete(self, vec_id: str) -> bool:
if vec_id not in self._ids:
return False
Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures/capture_attention_pairs.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[
{
"_comment": "Schema template for capture-attention threshold calibration. The real fixture at tests/fixtures/capture_attention_pairs.json is gitignored because it contains real operator vault snippets (titles + content) — never commit operator output. Generate yours by running `python scripts/calibrate_capture_threshold.py --db <vault.sqlite>` against a fresh snapshot. Schema: each entry needs cosine + verdict; recommend() tolerates 'unclear' verdicts.",
"id_a": 0,
"id_b": 0,
"title_a": "placeholder",
"title_b": "placeholder",
"snippet_a": "synthetic seed — operator-tagged output is gitignored",
"snippet_b": "synthetic seed — operator-tagged output is gitignored",
"cosine": 0.0,
"verdict": "unclear"
}
]
13 changes: 0 additions & 13 deletions tests/fixtures/capture_attention_pairs.json

This file was deleted.

Loading
Loading