diff --git a/README.md b/README.md index bf26d89..48613e3 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ The SQLite index is a **derived cache** — always rebuildable from the Markdown | `memory/MEMORY.md` | **Evergreen** — never decays, write-protected during `flush()` | | `memory/2026-03-21.md` | **Dated** — subject to temporal decay (older memories rank lower) | | `memory/researcher_agent/` | **Agent-scoped** — isolated namespace per agent | -| `memory/episodes/event.md` | **Episodic** — named events, timestamped | +| `memory/episodes/known-facts.md` | **Evergreen** — non-dated file in a subdirectory, always full score | +| `memory/sessions/2026-04-01.md` | **Dated** — subdirectory dated file, decays by filename date | Evergreen files hold foundational facts that should always surface at full score. Dated files accumulate daily learning and fade naturally — recent memories rank higher. @@ -248,9 +249,10 @@ With the default `half_life_days=30`: | File | Age source | Decays? | |------|------------|---------| -| `memory/MEMORY.md`, `memory/architecture.md` (any non-dated file under `memory/`) | — | **No** — evergreen, always full score | +| `memory/MEMORY.md`, `memory/architecture.md` (any non-dated file directly under `memory/`) | — | **No** — evergreen, always full score | +| `memory/agents/notes.md` (non-dated file in any `memory/` subdirectory) | — | **No** — evergreen, same rule as root non-dated files | | `memory/2026-03-21.md` (dated daily log) | Date parsed from filename | **Yes** | -| `sessions/foo.md`, `memory/agents/notes.md` (undated, non-evergreen) | File `mtime` on disk | **Yes** | +| `memory/sessions/2026-03-21.md` (dated file in any `memory/` subdirectory) | Date parsed from filename | **Yes** — same rule as root dated files | Evergreen files hold foundational facts — stack choices, hard constraints, permanent preferences — that should always surface at full score regardless of when they were written. Daily logs capture evolving context and fade naturally as new sessions add fresher knowledge. diff --git a/memweave/search/temporal_decay.py b/memweave/search/temporal_decay.py index 517dd53..c13bd72 100644 --- a/memweave/search/temporal_decay.py +++ b/memweave/search/temporal_decay.py @@ -33,8 +33,9 @@ from memweave.search.strategy import RawSearchRow # Regex that matches dated memory paths: memory/YYYY-MM-DD.md -# Works with both ``memory/2026-03-28.md`` and ``./memory/2026-03-28.md`` -_DATED_PATH_RE = re.compile(r"(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$") +# Also matches one level of subdirectory: memory/sessions/YYYY-MM-DD.md +# Works with both ``memory/2026-03-28.md`` and ``./memory/sessions/2026-03-28.md`` +_DATED_PATH_RE = re.compile(r"(?:^|\/)memory\/(?:[^/]+\/)?(\d{4})-(\d{2})-(\d{2})\.md$") _DAY_SECONDS = 86_400.0 @@ -162,7 +163,7 @@ def is_evergreen_path(file_path: str) -> bool: return True if not normalized.startswith("memory/"): return False - # Under memory/ but not dated → evergreen + # Under memory/ but not dated → evergreen (applies at any depth) return _DATED_PATH_RE.search(file_path.replace("\\", "/")) is None @@ -190,12 +191,18 @@ async def _extract_date( """Extract the effective date for a file — from path first, then mtime. Steps: - 1. Parse date from filename (``memory/YYYY-MM-DD.md``). Return if found. + 1. Parse date from filename (``memory/YYYY-MM-DD.md`` or + ``memory//YYYY-MM-DD.md``). Return if found. 2. If the file is evergreen → return ``None`` (no decay applies). + All ``memory/`` files with non-dated filenames are evergreen at any depth. 3. If ``workspace_dir`` is ``None`` → return ``None`` (cannot resolve path). 4. Resolve the absolute path and ``stat()`` the file. 5. Return the mtime as a :class:`datetime.date`, or ``None`` on error. + Steps 3–5 (mtime fallback) are only reached for files outside ``memory/`` + (e.g. ``extra_paths`` external files with no date in their filename). + All ``memory/``-managed files are fully resolved by steps 1–2. + Args: file_path: Relative or absolute file path. workspace_dir: Root workspace directory used to resolve relative paths. diff --git a/tests/integration/test_temporal_decay.py b/tests/integration/test_temporal_decay.py index c78f2fd..85fbf92 100644 --- a/tests/integration/test_temporal_decay.py +++ b/tests/integration/test_temporal_decay.py @@ -86,3 +86,55 @@ async def test_temporal_decay(workspace: Path, embedding_model: str) -> None: old_long = long_scores.get(old_path, scores[old_path]) assert old_long > old_decay, "Longer half-life should preserve more of the old file's score" + + +_SESSION_OLD_DATE = "2026-01-01" # ~100 days before 2026-04-11 +_SESSION_NEW_DATE = "2026-04-01" # ~10 days before 2026-04-11 +_SESSION_CONTENT = ( + "We confirmed the rollback plan: revert the migration and restart the service.\n" + "The on-call engineer will monitor for 24 hours after deployment.\n" +) + + +@pytest.mark.asyncio +async def test_session_subdir_dated_decay(workspace: Path, embedding_model: str) -> None: + """Dated files under memory/sessions/ decay by filename date — same rule as memory/ root.""" + sessions_dir = workspace / "memory" / "sessions" + sessions_dir.mkdir() + + (sessions_dir / f"{_SESSION_OLD_DATE}.md").write_text(_SESSION_CONTENT) + (sessions_dir / f"{_SESSION_NEW_DATE}.md").write_text(_SESSION_CONTENT) + + config = MemoryConfig( + workspace_dir=workspace, + embedding=EmbeddingConfig(model=embedding_model), + query=QueryConfig(min_score=0.0, max_results=10), + ) + + query = "rollback plan on-call deployment" + old_path = f"memory/sessions/{_SESSION_OLD_DATE}.md" + new_path = f"memory/sessions/{_SESSION_NEW_DATE}.md" + + async with MemWeave(config) as mem: + await mem.index() + + # Without decay: identical content → nearly identical scores + r_no_decay = await mem.search(query, min_score=0.0) + scores = {r.path: r.score for r in r_no_decay} + assert old_path in scores, f"{old_path} not found in results" + assert new_path in scores, f"{new_path} not found in results" + diff = abs(scores[old_path] - scores[new_path]) + assert diff < 0.05, f"Identical content without decay should score similarly, diff={diff:.4f}" + + # With aggressive decay: ~100-day-old session file should drop sharply + # half_life=7d → 2^(-100/7) ≈ 0.000054 multiplier + r_decay = await mem.search(query, min_score=0.0, decay_half_life_days=7.0) + decay_scores = {r.path: r.score for r in r_decay} + + old_decay = decay_scores.get(old_path, 0.0) + new_decay = decay_scores.get(new_path, scores.get(new_path, 0.0)) + + assert new_decay > old_decay * 5, ( + f"With half_life=7d, new session should score >5× old. " + f"new={new_decay:.4f} old={old_decay:.6f}" + ) diff --git a/tests/unit/test_search_temporal_decay.py b/tests/unit/test_search_temporal_decay.py index 5f5c8ff..f56c3a6 100644 --- a/tests/unit/test_search_temporal_decay.py +++ b/tests/unit/test_search_temporal_decay.py @@ -156,6 +156,12 @@ def test_empty_string(self): def test_session_file(self): assert parse_date_from_path("sessions/2026-03-28.md") is None + def test_subdir_dated_path(self): + assert parse_date_from_path("memory/sessions/2026-03-28.md") == date(2026, 3, 28) + + def test_subdir_dated_path_researcher(self): + assert parse_date_from_path("memory/researcher/2026-04-01.md") == date(2026, 4, 1) + # ── is_evergreen_path ───────────────────────────────────────────────────────── @@ -185,6 +191,21 @@ def test_session_file_not_evergreen(self): def test_arbitrary_file_not_evergreen(self): assert is_evergreen_path("docs/readme.md") is False + def test_sessions_subdir_nondated_is_evergreen(self): + assert is_evergreen_path("memory/sessions/foo.md") is True + + def test_sessions_subdir_dated_not_evergreen(self): + assert is_evergreen_path("memory/sessions/2026-03-29.md") is False + + def test_researcher_subdir_nondated_is_evergreen(self): + assert is_evergreen_path("memory/researcher/analysis.md") is True + + def test_researcher_subdir_dated_not_evergreen(self): + assert is_evergreen_path("memory/researcher/2026-04-01.md") is False + + def test_custom_subdir_nondated_is_evergreen(self): + assert is_evergreen_path("memory/episodes/standup.md") is True + # ── age_in_days ─────────────────────────────────────────────────────────────── @@ -312,6 +333,26 @@ async def test_session_file_fresh_score_nearly_unchanged(self, tmp_path: Path): result = await apply_temporal_decay(rows, half_life_days=30, workspace_dir=tmp_path) assert result[0].score > 0.9 + async def test_memory_sessions_nondated_is_evergreen(self): + """Non-dated memory/sessions/ file is evergreen — score unchanged.""" + rows = [_row("s_nd", "memory/sessions/known-facts.md", 0.8)] + result = await apply_temporal_decay(rows, half_life_days=30, now=date(2026, 3, 28)) + assert result[0].score == pytest.approx(0.8) + + async def test_memory_sessions_dated_file_decays_by_date(self): + """Dated memory/sessions/YYYY-MM-DD.md decays by filename date, no mtime needed.""" + rows = [_row("s_d", "memory/sessions/2026-01-01.md", 1.0)] + result = await apply_temporal_decay(rows, half_life_days=30, now=date(2026, 3, 28)) + expected = apply_decay_to_score(1.0, 86.0, 30.0) + assert result[0].score == pytest.approx(expected, rel=1e-6) + + async def test_memory_researcher_dated_file_decays_by_date(self): + """Dated memory/researcher/YYYY-MM-DD.md decays by filename date.""" + rows = [_row("r_d", "memory/researcher/2026-01-01.md", 1.0)] + result = await apply_temporal_decay(rows, half_life_days=30, now=date(2026, 3, 28)) + expected = apply_decay_to_score(1.0, 86.0, 30.0) + assert result[0].score == pytest.approx(expected, rel=1e-6) + async def test_mtime_cache_deduplicates_stat_calls(self, tmp_path: Path, monkeypatch): """Multiple chunks from the same file path are stat-ted only once.""" import aiofiles.os as aio_os