diff --git a/README.md b/README.md index 54ab9ef..c143939 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Agent Memory Engine -[![CI](https://github.com/Ninadnj/ai-agent-memory-scaffold/actions/workflows/ci.yml/badge.svg)](https://github.com/Ninadnj/ai-agent-memory-scaffold/actions/workflows/ci.yml) +[![CI](https://github.com/Ninadnj/agent-memory-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/Ninadnj/agent-memory-engine/actions/workflows/ci.yml) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) @@ -17,8 +17,8 @@ This engine sits in between. Agents write short, atomic memories to one store; a ## Quick start ```bash -git clone https://github.com/Ninadnj/ai-agent-memory-scaffold.git -cd ai-agent-memory-scaffold +git clone https://github.com/Ninadnj/agent-memory-engine.git +cd agent-memory-engine pip install -e ".[mcp,real]" ``` @@ -273,6 +273,17 @@ Budget packing is greedy, not all-or-nothing: an entry that would overflow the r Override with `AGENT_MEMORY_MIN_SCORE` or `--min-score`. The honest consequence: when a query is genuinely ambiguous, recall returns *nothing* rather than a coin flip the agent would read as fact. +**Memories fade, but only the ones that should.** A similarity score is multiplied by `0.5 ** (age / half-life)`, so a memory at its half-life must be twice the match to rank where it did when fresh: + +| Type | Half-life | Why | +| --- | --- | --- | +| `state` | 7 days | "Currently implementing X" is usually false a fortnight later. | +| `handoff` | 7 days | Next steps are done or abandoned by then. | +| `worklog` | 21 days | What happened still orients you, but fades. | +| `decision` `project` `issue` `fact` | never | True until explicitly superseded. Fading these would lose the memories most worth keeping. | + +Combined with the floor, a stale status note eventually drops out of recall on its own — no cleanup required. Durable facts never do; correct those with `memory_update` or drop them with `memory_forget`. `agent-memory list` shows each memory's age and how far it has faded, and `--no-decay` ranks on similarity alone. + ### The write path The store is a single JSON file, but writes are careful, because the whole point is that several agents share it. @@ -312,7 +323,7 @@ agent-memory write "" --type decision # save a memory agent-memory recall "" -k 3 --budget 200 # find relevant memories agent-memory boot "" --budget 300 # handoff + relevant memories agent-memory handoff --done "..." --next "..." # leave a note for the next session -agent-memory list --type decision # ids, so you can fix mistakes +agent-memory list --type decision # ids, ages and how far each has faded agent-memory update mem_0003 "" # revise a memory agent-memory forget mem_0007 # delete a stale memory agent-memory stats # store path, counts, embedder @@ -363,7 +374,7 @@ One memory per `##` section, with long sections split so every ingested memory s ## Limits - **Automatic writes are deterministic, not insightful.** With hooks installed, every session saves an accurate git-derived note, and reads need no prompting at all. But a handoff explaining *why* something was done still depends on the model choosing to write one — the server's instructions push for it, and this engine makes no LLM call of its own. -- **Memories never expire.** `state` and `worklog` entries go stale but keep being recalled as confidently as a fresh decision. Correct them with `memory_update` / `memory_forget` until decay lands. +- **Fading is time-based, not truth-based.** A `state` note fades on a schedule; it has no idea whether it is still true. A `decision` that quietly stopped being true stays at full strength until someone corrects it. - **Retrieval is lexical unless you install the `real` extra.** See [the comparison](#which-embedder-should-you-use). - **Memories are replayed verbatim into other agents' context.** Anything an agent writes — including text it read from a webpage, an issue tracker or a dependency — later reads as trusted project knowledge. Don't point a shared store at untrusted input, and skim `agent-memory list` occasionally. - **The store is a local file.** No auth, no encryption, no server. It belongs next to your code, not on a shared host. @@ -380,7 +391,6 @@ pytest -q ## Roadmap -- Recency- and type-aware ranking (decay old `state`, never drop `decision`). - LLM-based compaction: summarise and dedup `state`/`worklog`, extract durable facts from a session transcript. - Optional FAISS backend for large stores. diff --git a/pyproject.toml b/pyproject.toml index 7c4d04f..1c63ded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,8 @@ mcp = ["mcp>=1.0"] dev = ["pytest>=7.0", "mcp>=1.0", "tiktoken>=0.5"] [project.urls] -Repository = "https://github.com/Ninadnj/ai-agent-memory-scaffold" -Issues = "https://github.com/Ninadnj/ai-agent-memory-scaffold/issues" +Repository = "https://github.com/Ninadnj/agent-memory-engine" +Issues = "https://github.com/Ninadnj/agent-memory-engine/issues" [project.scripts] agent-memory = "agent_memory.cli:main" diff --git a/scaffold/README.md b/scaffold/README.md index c8173bb..34bf4d3 100644 --- a/scaffold/README.md +++ b/scaffold/README.md @@ -169,13 +169,13 @@ available. ## Copy Into Existing Repo ```bash -git clone https://github.com/Ninadnj/ai-agent-memory-scaffold.git +git clone https://github.com/Ninadnj/agent-memory-engine.git -cp ai-agent-memory-scaffold/AGENTS.md ./AGENTS.md -cp ai-agent-memory-scaffold/CLAUDE.md ./CLAUDE.md -cp -R ai-agent-memory-scaffold/agent-memory ./agent-memory -cp -R ai-agent-memory-scaffold/commands ./commands -cp -R ai-agent-memory-scaffold/workflows ./workflows +cp agent-memory-engine/AGENTS.md ./AGENTS.md +cp agent-memory-engine/CLAUDE.md ./CLAUDE.md +cp -R agent-memory-engine/agent-memory ./agent-memory +cp -R agent-memory-engine/commands ./commands +cp -R agent-memory-engine/workflows ./workflows ``` After copying, fill in `agent-memory/PROJECT.md`, clear or rewrite diff --git a/scripts/ingest_markdown.py b/scripts/ingest_markdown.py index 15a357b..7a35cc1 100644 --- a/scripts/ingest_markdown.py +++ b/scripts/ingest_markdown.py @@ -1,7 +1,7 @@ """Migrate an existing Markdown memory scaffold into the engine. Reads a directory of `.md` files (e.g. the agent-memory/ folder from the -ai-agent-memory-scaffold convention) and writes one memory per `##` section, +Markdown scaffold convention in scaffold/) and writes one memory per `##` section, guessing the memory type from the filename. python scripts/ingest_markdown.py path/to/agent-memory/ [--path store.json] diff --git a/src/agent_memory/__init__.py b/src/agent_memory/__init__.py index 3527914..4f2280f 100644 --- a/src/agent_memory/__init__.py +++ b/src/agent_memory/__init__.py @@ -9,11 +9,14 @@ ) from .store import ( GLOBAL_STORE, + HALF_LIFE_DAYS, MEMORY_TYPES, STORE_FORMAT, MemoryEntry, MemoryStore, RecallHit, + age_in_days, + decay_factor, default_store_path, find_project_root, ) @@ -28,6 +31,9 @@ "MEMORY_TYPES", "STORE_FORMAT", "GLOBAL_STORE", + "HALF_LIFE_DAYS", + "age_in_days", + "decay_factor", "default_store_path", "find_project_root", "Embedder", diff --git a/src/agent_memory/cli.py b/src/agent_memory/cli.py index bf89188..7016d89 100644 --- a/src/agent_memory/cli.py +++ b/src/agent_memory/cli.py @@ -81,6 +81,7 @@ def cmd_recall(args) -> None: k=args.k, budget_tokens=args.budget, min_score=_min_score(args, store), + decay=not args.no_decay, ) if not hits: print("No relevant memories.") @@ -114,12 +115,19 @@ def cmd_boot(args) -> None: def cmd_list(args) -> None: + from .store import age_in_days, decay_factor + entries = [e for e in reversed(_store(args).all()) if not args.type or e.type == args.type] if not entries: print("No memories stored.") return for e in entries[: args.limit]: - print(f"{e.id} [{_tag(e)}] {e.text}") + age = age_in_days(e) + # Surface staleness here: this is the command you run to decide what to + # correct or forget. + faded = decay_factor(e) + note = f"{age:.0f}d" + (f", faded to {faded:.0%}" if faded < 0.95 else "") + print(f"{e.id} [{_tag(e)} · {note}] {e.text}") def cmd_update(args) -> None: @@ -205,6 +213,11 @@ def build_parser() -> argparse.ArgumentParser: help="drop matches weaker than this cosine score (0 disables; " "default is calibrated per embedder)", ) + r.add_argument( + "--no-decay", + action="store_true", + help="rank purely by similarity, without fading time-sensitive memories", + ) r.set_defaults(func=cmd_recall) h = sub.add_parser("handoff", help="save a handoff for the next agent") diff --git a/src/agent_memory/store.py b/src/agent_memory/store.py index df3a7f8..ce84612 100644 --- a/src/agent_memory/store.py +++ b/src/agent_memory/store.py @@ -47,6 +47,26 @@ _ID_RE = re.compile(r"^mem_(\d+)$") +# How fast a memory's relevance fades, in days, per type. A memory's similarity +# score is multiplied by 0.5 ** (age / half_life), so an entry at its half-life +# needs to be twice as good a match to rank where it did when fresh. +# +# Not everything should fade. "Bookings are stored in UTC" is as true in a year +# as it was on the day it was written, and decaying it would quietly lose the +# facts most worth keeping. What goes stale is the record of a moment: +# "currently implementing X" is usually false a fortnight later, and recalling +# it with full confidence actively misleads. Correct a decision with +# memory_update; let a status note fade on its own. +HALF_LIFE_DAYS: dict[str, Optional[float]] = { + "state": 7.0, # "currently working on…" — stale fastest + "handoff": 7.0, # next steps are usually done or abandoned by then + "worklog": 21.0, # what happened still orients, but fades + "decision": None, # durable until explicitly superseded + "project": None, + "issue": None, # true until someone fixes it; forget it then + "fact": None, +} + # Where memories live when nothing is configured. One store per project, not one # store for everything you have ever worked on: recall matches on similarity # alone, so a single global file lets one project's deploy notes surface while @@ -131,6 +151,26 @@ class RecallHit: score: float +def age_in_days(entry: MemoryEntry, now: Optional[datetime] = None) -> float: + """How old a memory is. 0.0 when the timestamp is unreadable or in the future.""" + try: + written = datetime.fromisoformat(entry.created_at) + except (TypeError, ValueError): + return 0.0 # an unparseable timestamp must not silently bury the memory + if written.tzinfo is None: + written = written.replace(tzinfo=timezone.utc) + delta = (now or datetime.now(timezone.utc)) - written + return max(0.0, delta.total_seconds() / 86400.0) # clock skew must not boost + + +def decay_factor(entry: MemoryEntry, now: Optional[datetime] = None) -> float: + """Multiplier applied to a memory's similarity score, in (0, 1].""" + half_life = HALF_LIFE_DAYS.get(entry.type) + if not half_life: + return 1.0 + return float(0.5 ** (age_in_days(entry, now) / half_life)) + + @contextmanager def _file_lock(target: Path, timeout: float = 10.0, stale_after: float = 60.0) -> Iterator[None]: """Cross-process advisory lock for one store file. @@ -364,6 +404,7 @@ def recall( budget_tokens: Optional[int] = None, exclude_ids: Optional[set[str]] = None, min_score: float = 0.0, + decay: bool = True, ) -> list[RecallHit]: """Top-k most relevant memories, optionally under a hard token budget. @@ -375,12 +416,24 @@ def recall( `min_score` drops weak matches entirely. Without it a query unrelated to anything in the store still returns k memories, and the agent reading them has no way to tell they are noise. + + `decay` fades time-sensitive memories (see HALF_LIFE_DAYS) so that a + month-old "currently implementing X" ranks below a fresh fact instead of + alongside it. Durable types are unaffected. Combined with `min_score`, + stale status notes eventually drop out of recall on their own. """ self._reload_if_changed() if not self._entries: return [] qvec = self.embedder.embed([query])[0] sims = self._matrix @ qvec # cosine: both sides are unit-norm + if decay: + factors = np.array( + [decay_factor(e) for e in self._entries], dtype=np.float32 + ) + # Only fade positive scores: scaling a negative one moves it toward + # zero, which would promote an unrelated old memory rather than bury it. + sims = np.where(sims > 0, sims * factors, sims) order = np.argsort(-sims) hits: list[RecallHit] = [] remaining = budget_tokens @@ -409,6 +462,7 @@ def boot( k: int = 5, budget_tokens: Optional[int] = 300, min_score: float = 0.0, + decay: bool = True, ) -> tuple[Optional[MemoryEntry], list[RecallHit]]: """Return the latest handoff plus relevant memories for a new session. @@ -434,6 +488,7 @@ def boot( budget_tokens=remaining, exclude_ids=excluded_ids, min_score=min_score, + decay=decay, ) return included_handoff, hits diff --git a/tests/test_decay.py b/tests/test_decay.py new file mode 100644 index 0000000..0426e9c --- /dev/null +++ b/tests/test_decay.py @@ -0,0 +1,142 @@ +"""Time-sensitive memories fade; durable ones do not. + +A month-old "currently implementing X" is usually false, and recalling it with +the same confidence as a fresh fact actively misleads the next session. A +decision, by contrast, stays true until someone supersedes it — fading it would +quietly lose the memories most worth keeping. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from agent_memory import HashingEmbedder, MemoryStore, age_in_days, decay_factor +from agent_memory.store import HALF_LIFE_DAYS, MemoryEntry + + +def aged(entry_type: str, text: str, days: float) -> MemoryEntry: + written = datetime.now(timezone.utc) - timedelta(days=days) + return MemoryEntry( + id="mem_test", + type=entry_type, + text=text, + created_at=written.isoformat(timespec="seconds"), + ) + + +def backdate(store: MemoryStore, entry_id: str, days: float) -> None: + """Rewrite an entry's timestamp, as if it had been written `days` ago.""" + written = datetime.now(timezone.utc) - timedelta(days=days) + for entry in store.all(): + if entry.id == entry_id: + entry.created_at = written.isoformat(timespec="seconds") + return + raise AssertionError(f"no entry {entry_id}") + + +# ---- the decay curve ------------------------------------------------------- +def test_a_fresh_memory_is_not_faded(): + # Timestamps are stored to the second, so "now" can already be a second old. + # Anything written today is unfaded for practical purposes. + assert decay_factor(aged("state", "x", 0)) == pytest.approx(1.0, abs=1e-4) + + +@pytest.mark.parametrize("entry_type", [t for t, hl in HALF_LIFE_DAYS.items() if hl]) +def test_a_memory_at_its_half_life_is_worth_half(entry_type): + half_life = HALF_LIFE_DAYS[entry_type] + assert decay_factor(aged(entry_type, "x", half_life)) == pytest.approx(0.5, abs=0.01) + assert decay_factor(aged(entry_type, "x", half_life * 2)) == pytest.approx(0.25, abs=0.01) + + +@pytest.mark.parametrize("entry_type", [t for t, hl in HALF_LIFE_DAYS.items() if not hl]) +def test_durable_types_never_fade(entry_type): + assert decay_factor(aged(entry_type, "x", 3650)) == 1.0 + + +def test_decay_never_reaches_zero(): + """A very old memory should rank last, not become unreachable.""" + assert 0 < decay_factor(aged("state", "x", 3650)) < 1e-6 + + +# ---- robustness ------------------------------------------------------------ +def test_an_unreadable_timestamp_does_not_bury_a_memory(): + entry = MemoryEntry(id="m", type="state", text="x", created_at="not a date") + assert age_in_days(entry) == 0.0 + assert decay_factor(entry) == 1.0 + + +def test_a_future_timestamp_cannot_boost_a_memory(): + """Clock skew must not let a memory outrank a genuinely fresh one.""" + assert decay_factor(aged("state", "x", -30)) == pytest.approx(1.0) + + +def test_a_naive_timestamp_is_treated_as_utc(): + naive = (datetime.now(timezone.utc) - timedelta(days=7)).replace(tzinfo=None) + entry = MemoryEntry(id="m", type="state", text="x", created_at=naive.isoformat()) + assert age_in_days(entry) == pytest.approx(7, abs=0.1) + + +# ---- effect on recall ------------------------------------------------------ +@pytest.fixture +def store(): + return MemoryStore(embedder=HashingEmbedder()) + + +def test_a_stale_status_note_ranks_below_a_fresh_one(store): + old = store.write("Currently implementing multilingual chatbot support.", type="state") + backdate(store, old.id, 60) + store.write("Currently implementing the rate limiter for the chat endpoint.", type="state") + + top = store.recall("what are we currently implementing", k=1)[0] + assert "rate limiter" in top.entry.text, "the 60-day-old note should not win" + + +def test_an_old_decision_still_outranks_a_stale_note(store): + decision = store.write( + "Bookings are stored in UTC and converted in the UI layer.", type="decision" + ) + backdate(store, decision.id, 400) + note = store.write("Currently looking at how bookings store UTC timezones.", type="state") + backdate(store, note.id, 90) + + top = store.recall("how are booking timezones handled", k=1)[0] + assert top.entry.type == "decision" + + +def test_decay_can_be_switched_off(store): + old = store.write("Currently implementing multilingual chatbot support.", type="state") + backdate(store, old.id, 365) + + faded = store.recall("multilingual chatbot support", k=1)[0].score + raw = store.recall("multilingual chatbot support", k=1, decay=False)[0].score + assert raw > faded + assert raw == pytest.approx(store.recall("multilingual chatbot support", k=1, decay=False)[0].score) + + +def test_a_long_stale_note_falls_below_the_relevance_floor(store): + """Combined with the floor, stale status notes leave recall on their own.""" + old = store.write("Currently implementing multilingual chatbot support.", type="state") + backdate(store, old.id, 180) + + floor = HashingEmbedder.recommended_min_score + assert store.recall("multilingual chatbot support", k=3, min_score=floor, decay=False) + assert store.recall("multilingual chatbot support", k=3, min_score=floor) == [] + + +def test_decay_does_not_promote_unrelated_old_memories(store): + """Scaling a negative similarity moves it toward zero — it must not rank up.""" + old = store.write("Deployment runs from GitHub Actions on every push.", type="worklog") + backdate(store, old.id, 300) + store.write("The chatbot uses Google Gemini for customer questions.", type="decision") + + hits = store.recall("which model answers customer questions", k=2) + assert hits[0].entry.type == "decision" + + +def test_boot_applies_decay_to_its_recall(store): + old = store.write("Currently implementing multilingual chatbot support.", type="state") + backdate(store, old.id, 365) + store.write("Currently implementing the chatbot rate limiter.", type="state") + + _, hits = store.boot("what are we currently implementing", k=1, budget_tokens=None) + assert "rate limiter" in hits[0].entry.text