Skip to content

feat(memory): project-local default for the files-backend store (0.10.0)#23

Merged
OriNachum merged 16 commits into
mainfrom
feat/project-local-store
Jun 24, 2026
Merged

feat(memory): project-local default for the files-backend store (0.10.0)#23
OriNachum merged 16 commits into
mainfrom
feat/project-local-store

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

What

Make eidetic's files-backend memory store default to the git repo it's run in, instead of always using a single global $HOME/.eidetic/memory. Resolution is now per-operation, by visibility and cwd:

Record Inside a git repo Outside any repo
public <repo-root>/.eidetic/memory (committed, team-shared) $HOME/.eidetic/memory
private $HOME/.eidetic/memory (never committed) $HOME/.eidetic/memory

Precedence: explicit --data-dir / EIDETIC_DATA_DIR > (public-in-repo → repo store) > $HOME. Setting EIDETIC_DATA_DIR collapses to a single dir — byte-identical to prior behavior (the regression lock).

recall and sweep read and merge across both stores, so a private-scope query returns your own private records plus the public pool.

Why

Project work should recall that project's memory by default rather than a cross-project global soup, and team-relevant (public) memory should travel with the repo via git — without breaking the existing Claude↔colleague shared-memory story. Private records stay in the shared $HOME store (so the colleague backend in a throwaway worktree still shares them); public records travel via git worktree add checkout of the committed store. Sharing survives on both paths.

The no-leak invariant

The security-critical property — a public-scope recall must never return a private record, even though search now reads the home dir that holds private records — is doubly enforced: data_refinery.store.list() filters by can_serve, and eidetic re-applies its own defensive can_serve to the unioned candidates. can_serve fails closed (a private record serves only on exact same-scope match). Verified by unit tests, an end-to-end test (priv-merge is absent from a public query's results), an independent review, and a live CLI run.

Scope / boundaries

Files-backend default directory only. Unchanged: record schema, ranking modes, scoring/freshness, the can_serve no-leak semantics, the mongo/neo4j network backends, and the EIDETIC_DATA_DIR/--data-dir override path. The existing $HOME store is left intact (clean break — no migration).

How it was built

Worked the idea → spec (/think) → plan (/spec-to-plan) → 5 tasks across 4 dependency waves, then fanned out to the colleague backend (ask-colleague, Qwen3.6-27B) as the worker, with this agent integrating + TDD-gating each merge (tests green before and after) and reviewing the security-critical crux.

  • t1 resolver helpers · t2 wire upsert/search/all (the crux) · t3 e2e/regression tests · t4 docs · t5 version + gates
  • Independent ask-colleague review of the no-leak invariant (concurred) + a final whole-diff review and live-test.
  • Integrator caught & fixed a real defect colleague's tests shipped: the t2 fixture didn't isolate HOME, so private-record tests were polluting the real ~/.eidetic store. Fixed (HOME→tmp) and cleaned the pollution.

Tests & gates

346 passed, 2 skipped. New: test_store_resolver.py (17), test_backend_multi_store.py (17), test_store_routing_e2e.py (8). All hermetic (HOME-isolated). black / isort / flake8 / bandit / markdownlint / teken cli doctor --strict all green. Version 0.9.30.10.0.

Follow-ups (tracked, out of scope here)

  • The /remember & /recall wrapper docs are first-party here but fan out byte-verbatim to ~57 org repos — that re-sync is a separate rollout-cli job.
  • migrate store without --data-dir upgrades only the $HOME store; the repo store needs an explicit --data-dir (documented in --help).
  • Committing *__public.jsonl produces a git diff per public remember; data-refinery's atomic-per-file format's merge-friendliness across teammates is worth validating.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh

  • eidetic-cli (Claude)

OriNachum and others added 13 commits June 23, 2026 23:44
…-to-plan)

Converged spec and build plan for making eidetic's files-backend store default
to the git repo root (public->repo, private->$HOME, override wins). Two-store
read model; 5 tasks across 4 waves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
Add _home_store_dir, _override_dir, _git_toplevel, _resolve_write_dir,
and _candidate_read_dirs helpers to eidetic/memory/backend.py with
CWD-based caching for git lookups. Includes 17 unit tests covering
in-repo, subdir, outside-repo, git-absent, override, dedup, and
caching scenarios.
…mem...

TASK t1 (from docs/plans/2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.md):
Add visibility-aware store-path RESOLVER HELPERS to eidetic/memory/backend.py. THIS TASK IS ONLY THE PURE HELPERS + THEIR UNIT TESTS. Do NOT change upsert/search/all yet (that is task t2). Do NOT touch mongo/neo4j logic.

Work TEST-FIRST: write tests/test_store_resolver.py first, then implement until green.

Add these helpers near the existing _bridge_env in eidetic/memory/backend.py (study that file's style/imports; Path and os are already imported):

  _home_store_dir() -> str
      return str(Path.home() / ".eidetic" / "memory")   # today's default

  _override_dir() -> str | None
      return os.environ.get("EIDETIC_DATA_DIR") or None  # explicit override

  _git_toplevel() -> str | None
      Return `git rev-parse --show-toplevel` (stripped) when the CURRENT WORKING DIR
      is inside a git repo; return None when outside a repo OR git is unavailable OR
      any error. Use subprocess.run([...], capture_output=True, text=True) with NO shell.
      Catch non-zero return code AND FileNotFoundError -> return None. Never raise, never
      print, never construct a CliError.
      CACHE per process keyed by os.getcwd(): use a module-level dict {cwd: result} so a
      batch ingest of N records (fixed cwd) spawns AT MOST ONE git subprocess, while a
      test that os.chdir()s to a different dir gets a fresh result. Do NOT use a bare
      lru_cache with no args (it would never refresh across chdir).

  _resolve_write_dir(visibility: str) -> str
      Precedence EXACTLY:
        1. if _override_dir(): return it
        2. if visibility == "public" and _git_toplevel(): return str(Path(top)/".eidetic"/"memory")
        3. return _home_store_dir()
      (So a PRIVATE record, or ANY record outside a repo, resolves to home.)

  _candidate_read_dirs() -> list[str]
      For multi-store reads. Precedence:
        - if _override_dir(): return [override]   # single dir, byte-identical to today
        - else: dirs = [_home_store_dir()]; if _git_toplevel(): repo = str(Path(top)/".eidetic"/"memory");
                if repo not in dirs: dirs.append(repo); return dirs
      Must contain NO duplicate when the repo toplevel store == home store.

CWD-based only. Do NOT call or reuse find_culture_yaml (that walks the installed module path; wrong target here).

tests/test_store_resolver.py MUST cover, using tmp_path + monkeypatch (monkeypatch HOME and EIDETIC_DATA_DIR; use subprocess to `git init` a temp repo and os.chdir into it / a subdir):
  - in-repo: _git_toplevel == repo root; _resolve_write_dir("public") == <repo>/.eidetic/memory
  - subdir-of-repo: still resolves to the repo root
  - _resolve_write_dir("private") == home even inside a repo; outside a repo public also == home
  - git-absent: monkeypatch subprocess.run to raise FileNotFoundError -> _git_toplevel() is None (no raise)
  - EIDETIC_DATA_DIR set: _resolve_write_dir(any) and _candidate_read_dirs() return exactly that dir (single)
  - _candidate_read_dirs() has no duplicate when toplevel store == home
  - caching: calling _git_toplevel() twice in the same cwd invokes the git subprocess at most once (assert via a mock/counter)

Run `uv run pytest tests/test_store_resolver.py -q` and make it pass. Keep style consistent with the file (black/isort/flake8 clean). Commit your work.

Implement the task above in this repository.

Rules:
- Make the SMALLEST change that correctly satisfies the task.
- Follow the repository's existing patterns, style, and conventions — read the
  neighbouring files first so your change reads like the surrounding code.
- Keep edits lint-clean: respect the project's maximum line length and end every
  text file with exactly one trailing newline.
- You may read, create, modify files, and run commands as needed.
- Don't widen the scope: do exactly what was asked, nothing more.

When you are done, call finish with a short summary of exactly what you changed
and why.
colleague-authored (drive colleague/b4d5e6b27b4b-task-t1-from-docs-plans-2026-06-23), TDD-gated: baseline 304 passed before,
321 passed after (+17 resolver unit tests). Pure additive helpers in backend.py;
existing code untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
…mem...

TASK t2 (from docs/plans/2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.md):
Wire upsert/search/all in eidetic/memory/backend.py onto the t1 resolver helpers
(_resolve_write_dir, _candidate_read_dirs, _git_toplevel, _override_dir, _home_store_dir),
which already exist and are tested. This is the CRUX — the public/private no-leak invariant
rides on it. Work TEST-FIRST where practical; keep all existing tests green.

ALL new directory logic MUST be gated behind `self._name == "files"`. The mongo and neo4j
branches of _bridge_env and their single-store reads MUST be byte-unchanged.

1) _bridge_env: replace ONLY the files branch's hardcoded
     os.environ["DR_DATA_DIR"] = EIDETIC_DATA_DIR or str(Path.home()/".eidetic"/"memory")
   Keep _bridge_env able to set DR_DATA_DIR to an EXPLICIT directory. Simplest: add a
   parameter `data_dir: str | None = None`; when name=="files", set DR_DATA_DIR to
   `data_dir` if given else `_resolve_write_dir("private")` (the safe home default for any
   caller that doesn't pass a dir). mongo/neo4j branches unchanged.

2) upsert(record): for the files backend, set DR_DATA_DIR via the resolver using the
   RECORD's own visibility -> _bridge_env("files", data_dir=_resolve_write_dir(record.scope.visibility)).
   So a public record lands in the repo store, a private record in the home store. For
   mongo/neo4j keep calling _bridge_env(self._name) exactly as today.

3) search(...): for the files backend, gather candidates from EVERY dir in
   _candidate_read_dirs():
       seen = {}            # id -> Record, dedup by id (first dir wins is fine)
       for d in _candidate_read_dirs():
           _bridge_env("files", data_dir=d)
           for env in drstore.list(scope=DRScope(name=scope.name, visibility=scope.visibility),
                                    backend="files", **self._kwargs):
               r = record_from_envelope(env)
               seen.setdefault(r.id, r)
       candidates = list(seen.values())
   Then apply the EXISTING pipeline UNCHANGED: the defensive
   `[r for r in candidates if can_serve(scope, r.scope)]`, then facet filters, then
   rank(mode, query, candidates, self._embed, top_k, alpha=alpha, case_sensitive=...).
   For mongo/neo4j keep today's single drstore.list call exactly.
   INVARIANT TO PRESERVE: reading the home dir for a PUBLIC query must NOT leak private
   records — can_serve already fails closed (private served only on exact scope match), so
   do not weaken or bypass it. A private-scope query returns its own private records (home)
   PLUS public records (repo); a public-scope query returns ONLY public records from any dir.

4) all(): for the files backend, enumerate across EVERY dir in _candidate_read_dirs(),
   union by id:
       seen = {}
       for d in _candidate_read_dirs():
           _bridge_env("files", data_dir=d)
           backend = drstore.get_backend("files", **self._kwargs)
           for env in backend.all():
               r = record_from_envelope(env); seen.setdefault(r.id, r)
       return list(seen.values())
   For mongo/neo4j keep today's single-store all().
   (sweep then re-upserts each changed record via upsert(), which step 2 routes back to the
   dir matching that record's OWN visibility — no extra work needed here, but DO confirm it.)

5) migrate_store: unchanged behavior is acceptable for this task (it still resolves the home
   default via _bridge_env("files")); do NOT regress it.

Acceptance (all must hold; add/keep tests):
  - files upsert writes public -> _resolve_write_dir("public"), private -> _resolve_write_dir("private")
    (verify which <scope>__<visibility>.jsonl appears under which dir)
  - files search unions candidates across _candidate_read_dirs() with no duplicate records;
    private-scope query returns own-private (home) + public (repo); public-scope query never
    returns private records even though home is read (no-leak holds)
  - files all() spans both dirs; sweep re-upsert lands each record back in its visibility's dir
  - new logic gated behind self._name=="files"; mongo/neo4j paths untouched; old hardcoded
    Path.home()/.eidetic/memory line is the only path logic replaced
  - with EIDETIC_DATA_DIR set, _candidate_read_dirs() is a single dir so search/all/upsert all
    use exactly that dir -> behavior byte-identical to before (run the full existing suite green)

Run `uv run pytest -q` (whole suite) and make it pass. Keep black/isort/flake8 clean. Commit.

Implement the task above in this repository.

Rules:
- Make the SMALLEST change that correctly satisfies the task.
- Follow the repository's existing patterns, style, and conventions — read the
  neighbouring files first so your change reads like the surrounding code.
- Keep edits lint-clean: respect the project's maximum line length and end every
  text file with exactly one trailing newline.
- You may read, create, modify files, and run commands as needed.
- Don't widen the scope: do exactly what was asked, nothing more.

When you are done, call finish with a short summary of exactly what you changed
and why.
…al ~/.eidetic store

The autouse fixture cleared EIDETIC_DATA_DIR + the git cache but not HOME, so private
and outside-repo upserts wrote real records into the developer's live ~/.eidetic/memory.
Redirect HOME to an isolated tmp dir. (integrator fix on colleague's drive branch.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
colleague-authored crux (drive colleague/066538dfdbb7-task-t2-from-docs-plans-2026-06-23) + integrator HOME-isolation fix.
TDD-gated: 321 passed before, 338 after (+17 multi-store tests). No-leak invariant
doubly-enforced (drstore can_serve + eidetic defensive can_serve); mongo/neo4j untouched;
EIDETIC_DATA_DIR override short-circuits to single-dir (regression lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
…eview follow-up)

Addresses the independent no-leak review's top suggestion: clarify that union-by-id
prefers the home copy and can only under-serve a duplicate id, never leak (the survivor
is still can_serve-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
…mem...

TASK t3 (from docs/plans/2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.md):
Add END-TO-END + regression tests for the two-store routing in a NEW file
tests/test_store_routing_e2e.py. Do NOT modify production code (t1/t2 already did it).
Tests must pass: `uv run pytest tests/test_store_routing_e2e.py -q`.

Use the same pattern as tests/test_added_by_e2e.py: import and call
eidetic.cli._commands.remember.cmd_remember and recall.cmd_recall with a constructed
argparse.Namespace, OR call eidetic.cli.main([...]). Capture stdout with capsys when you
need the recall JSON (pass json=True / --json).

CRITICAL test-isolation rules (the routing depends on cwd + HOME + git, so set them up real):
  - monkeypatch.setenv("HOME", str(tmp_home))  so Path.home() points at an isolated dir
  - monkeypatch.delenv("EIDETIC_DATA_DIR", raising=False) for the REPO-ROUTING cases
    (an override would short-circuit the very behavior under test)
  - create a real git repo: subprocess.run(["git","init",str(repo)], capture_output=True, check=True)
  - os.chdir into the repo (restore in finally), and CLEAR the resolver cache each time:
        import eidetic.memory.backend as be; be._GIT_CACHE.clear()
  - in recall, use mode="exact" or "keyword" (lexical) so tests never depend on the live
    embed endpoint (model-gear). Follow how existing recall tests pass mode.

Cases (each its own test):
  1. public-in-repo: remember a record with --visibility public while cwd is inside the temp
     git repo. Assert the file <repo>/.eidetic/memory/<scope>__public.jsonl EXISTS and the
     record is in it; assert NOTHING was written under tmp_home/.eidetic/memory. Then recall
     the same query (public scope) from the repo and assert the record is returned.
  2. private-to-home: remember --visibility private inside the repo. Assert it lands in
     tmp_home/.eidetic/memory/<scope>__private.jsonl and NOT under <repo>/.eidetic/memory.
  3. private-recall-merges: with a private record (home) AND a public record (repo) both
     present for the same scope name, a recall with a PRIVATE query scope returns BOTH
     (own private + the public pool). A recall with a PUBLIC query scope returns ONLY the
     public record (no private leak).
  4. override-byte-identical: set EIDETIC_DATA_DIR=tmp/override. remember (public AND private)
     + recall all touch ONLY that dir; nothing under repo or home. (Locks the override regression.)
  5. outside-repo: cwd in a NON-git tmp dir. remember (public or private) lands under
     tmp_home/.eidetic/memory. _git_toplevel is None.
  6. clean-break: pre-populate tmp_home/.eidetic/memory with a record (write a record there
     first, via override or by remembering outside a repo). Then remember a public record
     while inside the repo. Assert the pre-existing home file is byte-UNCHANGED (read bytes
     before/after) — the repo write never touches home.
  7. sweep-both-dirs: put one record in the repo public store and one in the home private
     store such that lifecycle transitions would apply (e.g. force via supersedes or an old
     created date — reuse helpers/patterns from the existing sweep tests). Run sweep and
     assert records in BOTH dirs are transitioned, and each transitioned record is written
     back to the dir it came from (public->repo, private->home).
  8. mongo/neo4j unaffected: a quick assertion that get_backend("mongo")/("neo4j") path
     resolution is unchanged (no repo/home dir logic applied) — or assert _bridge_env for
     mongo/neo4j only sets DR_MONGO_URI/DR_NEO4J_URI as before. Keep it light; skip if it
     needs a live server (use the existing skip pattern).

Keep black/isort/flake8 clean. Commit.

Implement the task above in this repository.

Rules:
- Make the SMALLEST change that correctly satisfies the task.
- Follow the repository's existing patterns, style, and conventions — read the
  neighbouring files first so your change reads like the surrounding code.
- Keep edits lint-clean: respect the project's maximum line length and end every
  text file with exactly one trailing newline.
- You may read, create, modify files, and run commands as needed.
- Don't widen the scope: do exactly what was asked, nothing more.

When you are done, call finish with a short summary of exactly what you changed
and why.
colleague-authored (drive colleague/ae1d41412fda-task-t3-from-docs-plans-2026-06-23), hermetic (HOME-isolated). 8 e2e tests across the CLI:
public-in-repo, private-to-home, private-recall merges (public query never leaks private),
override byte-identical, outside-repo, clean-break (byte-compare), sweep across both dirs,
mongo/neo4j unaffected. TDD-gated: 338 -> 346.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
Update header comments in remember.sh and recall.sh, SKILL.md descriptions
for both remember and recall, migrate --data-dir help text, catalog.py
store-location prose, and CLAUDE.md to describe the new default resolution:
PUBLIC records inside a git repo go to <repo-root>/.eidetic/memory
(committed), PRIVATE/outside-repo records go to $HOME/.eidetic/memory
(never committed), EIDETIC_DATA_DIR override wins, recall merges both.

No executable behavior changed — docs/comments only.
colleague-authored (drive colleague/dcde193859ad-task-t4-from-docs-plans-2026-06-23), docs/comments only (no executable change):
remember.sh/recall.sh headers, /remember + /recall SKILL.md descriptions, CLAUDE.md
store-location subsection, migrate.py + catalog.py help. markdownlint + teken rubric green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
- version 0.9.3 -> 0.10.0 + CHANGELOG entry for the project-local store default
- bandit: # nosec B607 on the intentional git-on-PATH subprocess in _git_toplevel
- markdownlint: backtick <repo-root> paths in CHANGELOG (MD033 inline-HTML)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

feat(memory): route files-backend store by repo+visibility (default 0.10.0)
✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Description

• Make files-backend default storage location depend on git repo + visibility.
• Read/merge across repo+home stores while preserving the public/private no-leak invariant.
• Add hermetic unit + e2e coverage, update docs/help text, and bump to 0.10.0.
Diagram

graph TD
  BE["eidetic StoreBackend (files)"] --> RES["Store-path resolver"] --> DEC{"Dir resolution"}
  RES -->|"git rev-parse"| GIT{{"git"}}
  DEC -->|"EIDETIC_DATA_DIR"| OVR[("Override store")]
  DEC -->|"public + in repo"| REPO[("Repo .eidetic/memory")]
  DEC -->|"else"| HOME[("Home ~/.eidetic/memory")]
  OVR --> DR["data_refinery.store"] --> MERGE["Union + can_serve + rank"] --> BE
  REPO --> DR
  HOME --> DR
  subgraph Legend
    direction LR
    _cmp["Component"] ~~~ _dec{"Decision"} ~~~ _db[("Store") ] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep one physical store; add project isolation only via scope naming
  • ➕ No multi-directory reads or DR_DATA_DIR switching
  • ➕ Simpler mental model for on-disk layout
  • ➖ Doesn’t enable repo-committed, team-shared public memory
  • ➖ Still mixes all projects into the same physical store unless every caller is disciplined about scope
2. Use git common-dir/worktree metadata to share a single repo-level store (no commits)
  • ➕ Avoids committing memory files while still being repo-associated
  • ➕ Could share across worktrees on the same machine
  • ➖ Doesn’t solve cross-machine/team sharing via git
  • ➖ More complex and git-implementation-dependent; harder to explain and test
3. Upstream multi-root support into data_refinery (first-class multi-store reads)
  • ➕ Eliminates repeated env-bridging and per-dir loops in eidetic
  • ➕ Centralizes multi-root semantics in the storage layer
  • ➖ Requires coordinating changes across repositories and releases
  • ➖ Larger blast radius for a security-sensitive behavior change

Recommendation: The PR’s approach is the best fit for the stated boundaries: it localizes the behavior change to the existing single decision point (backend.py), preserves the override path byte-for-byte, and maintains defense-in-depth on the no-leak invariant while enabling repo-committed public memory. Alternatives either fail to achieve team-shared repo travel, increase git coupling, or expand scope into upstream dependencies.

Files changed (19) +2001 / -45

Enhancement (1) +146 / -20
backend.pyImplement visibility+git-aware routing with multi-store reads +146/-20

Implement visibility+git-aware routing with multi-store reads

• Adds resolver helpers to pick the write directory per operation (override > public-in-repo > home) and to compute candidate read directories (home + repo, deduped, override short-circuits). Wires files-backend upsert/search/all to set DR_DATA_DIR per directory, union candidates by id, and re-apply can_serve defensively to preserve the public/private no-leak invariant.

eidetic/memory/backend.py

Tests (3) +1302 / -0
test_backend_multi_store.pyAdd unit tests for files-backend multi-store routing and no-leak +499/-0

Add unit tests for files-backend multi-store routing and no-leak

• Introduces targeted tests for data-dir bridging, visibility-based write routing, multi-store read union/dedup, override single-dir regression, and sweep re-upsert landing in the correct store. Enforces hermeticity by isolating HOME and clearing the git cache per test.

tests/test_backend_multi_store.py

test_store_resolver.pyAdd unit tests for resolver helpers (git toplevel, override, dir lists) +288/-0

Add unit tests for resolver helpers (git toplevel, override, dir lists)

• Covers _home_store_dir, _override_dir, _git_toplevel behavior (including caching and git-absent), _resolve_write_dir precedence, and _candidate_read_dirs dedup/override semantics.

tests/test_store_resolver.py

test_store_routing_e2e.pyAdd CLI end-to-end regression tests for two-store routing +515/-0

Add CLI end-to-end regression tests for two-store routing

• Exercises remember/recall/sweep through CLI command entrypoints to validate on-disk placement, merged reads for private scope, public no-leak, override behavior, outside-repo fallback, and clean-break home-store invariance. Ensures hermetic execution via HOME isolation and git cache resets.

tests/test_store_routing_e2e.py

Documentation (12) +550 / -22
SKILL.mdDocument repo-local vs home store routing for recall +11/-7

Document repo-local vs home store routing for recall

• Updates the skill description to reflect visibility-aware routing (public-in-repo to repo store; private/outside-repo to home). Notes that recall reads both stores and merges results under the existing no-leak policy.

.claude/skills/recall/SKILL.md

recall.shUpdate recall wrapper header comment for new default store rules +5/-4

Update recall wrapper header comment for new default store rules

• Replaces the prior '$HOME-only' default description with per-operation resolution rules and the override precedence. No functional wrapper behavior changes (still forwards flags verbatim).

.claude/skills/recall/scripts/recall.sh

SKILL.mdDocument visibility-aware routing for remember +7/-4

Document visibility-aware routing for remember

• Updates the skill description to explain that public writes in a git repo land in <repo-root>/.eidetic/memory while private writes remain in $HOME. Clarifies that EIDETIC_DATA_DIR short-circuits to a single directory.

.claude/skills/remember/SKILL.md

remember.shUpdate remember wrapper header comment for new default store rules +5/-4

Update remember wrapper header comment for new default store rules

• Aligns the wrapper’s documentation with the new routing (public-in-repo → repo store; otherwise → home; override wins). Wrapper functionality remains unchanged.

.claude/skills/remember/scripts/remember.sh

eidetic-cli-s-default-memory-store-becomes-project.jsonAdd exported devague frame capturing the spec and decisions +172/-0

Add exported devague frame capturing the spec and decisions

• Adds a structured frame describing the before/after behavior, precedence rules, audiences, and security constraints (notably the no-leak invariant) for the project-local default store.

.devague/frames/eidetic-cli-s-default-memory-store-becomes-project.json

eidetic-cli-s-default-memory-store-becomes-project.jsonAdd exported devague build plan with tasks and risks +212/-0

Add exported devague build plan with tasks and risks

• Introduces a structured plan enumerating tasks t1–t5 (helpers, wiring, e2e tests, docs, version bump) and tracks known risks/follow-ups (e.g., migrate behavior and cross-org doc fanout).

.devague/plans/eidetic-cli-s-default-memory-store-becomes-project.json

CHANGELOG.mdAdd 0.10.0 changelog entry for project-local store default +10/-0

Add 0.10.0 changelog entry for project-local store default

• Documents the new default routing semantics, override precedence, multi-store read/merge behavior, and that mongo/neo4j backends are unaffected.

CHANGELOG.md

CLAUDE.mdExplain default files-backend store location and merge semantics +15/-1

Explain default files-backend store location and merge semantics

• Adds a dedicated section describing public-in-repo vs private/home routing, override precedence, and the fact that recall merges across stores while preserving the no-leak invariant.

CLAUDE.md

2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.mdAdd build plan markdown for project-local store default +65/-0

Add build plan markdown for project-local store default

• Adds the human-readable plan document enumerating tasks, acceptance criteria, and explicit risks/follow-ups for the change.

docs/plans/2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.md

2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.mdAdd spec markdown describing routing, invariants, and boundaries +46/-0

Add spec markdown describing routing, invariants, and boundaries

• Adds a concise spec capturing audience, before/after behavior, precedence, security invariants, and explicit out-of-scope items.

docs/specs/2026-06-23-eidetic-cli-s-default-memory-store-becomes-project.md

migrate.pyClarify migrate help: repo store requires explicit --data-dir +1/-1

Clarify migrate help: repo store requires explicit --data-dir

• Updates CLI help text to note that migrating the repo-local store is not automatic and must be targeted via --data-dir (while the default remains EIDETIC_DATA_DIR else ~/.eidetic/memory).

eidetic/cli/_commands/migrate.py

catalog.pyUpdate catalog docs to mention repo store needs explicit --data-dir +1/-1

Update catalog docs to mention repo store needs explicit --data-dir

• Adjusts the catalog explanation of migrate behavior to reflect that the repo-local store isn’t migrated unless explicitly selected via --data-dir.

eidetic/explain/catalog.py

Other (3) +3 / -3
currentPoint devague current frame to project-local store work +1/-1

Point devague current frame to project-local store work

• Updates the active devague slug pointer to the new project-local default memory store frame.

.devague/current

current_planPoint devague current plan to project-local store work +1/-1

Point devague current plan to project-local store work

• Updates the active devague plan slug pointer to the new exported plan.

.devague/current_plan

pyproject.tomlBump version to 0.10.0 +1/-1

Bump version to 0.10.0

• Updates the project version from 0.9.3 to 0.10.0 to reflect the new default storage behavior.

pyproject.toml

Resolves SonarCloud S1192 (literal ".eidetic" duplicated 4x). Adds _STORE_SUBPATH
+ _store_dir(base) and routes _home_store_dir / _resolve_write_dir /
_candidate_read_dirs / _bridge_env through it. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
@qodo-code-review

qodo-code-review Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 29 rules
✅ Skills: version-bump

Grey Divider


Action required

1. Dedup hides serveable records ✓ Resolved 🐞 Bug ≡ Correctness
Description
StoreBackend.search() unions multi-store candidates by id before applying can_serve, so a
private home copy can win dedup and then be filtered out, causing the eligible public repo copy to
be dropped and recall to miss real records. This is likely if a record id is first written private
(home) and later re-upserted public (repo), leaving both copies on disk.
Code

eidetic/memory/backend.py[R339-370]

+        if self._name == "files":
+            # Gather candidates from every dir in _candidate_read_dirs(), union by id.
+            # Dedup is first-dir-wins (home is listed before repo): if the SAME id
+            # exists in both stores at different visibilities (an ill-defined state —
+            # upsert routes a given id to exactly one store by its visibility), the
+            # home copy wins. This can only ever *under*-serve such a duplicate (the
+            # later copy is dropped); it can never leak, because the surviving copy is
+            # still gated by can_serve below.
+            seen: dict[str, Record] = {}
+            for d in _candidate_read_dirs():
+                _bridge_env("files", data_dir=d)
+                with _translate_errors():
+                    for env in drstore.list(
+                        scope=DRScope(name=scope.name, visibility=scope.visibility),
+                        backend="files",
+                        **self._kwargs,
+                    ):
+                        r = record_from_envelope(env)
+                        seen.setdefault(r.id, r)
+            candidates = list(seen.values())
+        else:
+            _bridge_env(self._name)
+            with _translate_errors():
+                envs = drstore.list(
+                    scope=DRScope(name=scope.name, visibility=scope.visibility),
+                    backend=self._name,
+                    **self._kwargs,
+                )
+            candidates = [record_from_envelope(e) for e in envs]
        # Defense in depth: data-refinery's list() already enforces scope
        # visibility via its own can_serve, but re-applying eidetic's policy here
        # makes the public/private no-leak invariant hold *in eidetic* regardless
Relevance

⭐⭐ Medium

No clear historical precedent for cross-store dedup-before-can_serve correctness; could be deemed
“ill-defined state” and ignored.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The merge loop uses seen.setdefault(r.id, r) before can_serve filtering; since can_serve
rejects private records for public queries, a private-first duplicate id can be filtered out and the
later public record is never considered. The CLI allows caller-chosen ids and can write the same id
under different visibilities over time, making this duplicate-id state plausible.

eidetic/memory/backend.py[339-373]
eidetic/memory/scope.py[22-31]
eidetic/cli/_commands/remember.py[83-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`StoreBackend.search()` for the files backend currently deduplicates by `Record.id` while loading candidates from multiple stores, *before* applying `can_serve()`. If the same id exists in both stores (e.g., visibility changed over time), a non-servable record (private) can block a servable one (public), causing missing results.

## Issue Context
- `can_serve()` allows public records to serve any scope, but private records only serve exact-scope matches.
- Multi-store reads are new in this PR, so this ordering bug is introduced here.

## Fix Focus Areas
- eidetic/memory/backend.py[339-373]

### Implementation guidance
- Collect candidates across all `_candidate_read_dirs()` without id-dedup *or* dedup with replacement logic that prefers a record that passes `can_serve(scope, record.scope)` for the current query.
- After applying `can_serve` (and filters), perform a final dedup-by-id to ensure stable output for ranking.
- Consider explicitly handling the “duplicate id across stores” case (e.g., choose the newest `created`/`last_recall`, or prefer `record.scope.visibility == scope.visibility`, or emit a warning) so behavior is deterministic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. _git_toplevel can raise ✓ Resolved 🐞 Bug ☼ Reliability
Description
_git_toplevel() claims it “Never raises” and returns None on “any error”, but os.getcwd() is
executed outside the try and only FileNotFoundError is caught. If the CWD becomes unavailable
(or other OS-level errors occur), store resolution can crash with an uncaught exception.
Code

eidetic/memory/backend.py[R148-176]

+def _git_toplevel() -> str | None:
+    """Return the git repo toplevel for the current working directory.
+
+    Returns ``None`` when outside a repo, git is unavailable, or any error
+    occurs. Never raises. Results are cached per-cwd so a batch ingest
+    spawns at most one git subprocess, while ``os.chdir`` to a different
+    directory gets a fresh result.
+    """
+    cwd = os.getcwd()
+    if cwd in _GIT_CACHE:
+        return _GIT_CACHE[cwd]
+    try:
+        # `git` is intentionally resolved from PATH (a hard-coded absolute path
+        # would not be portable across dev/CI/install environments); the argv is
+        # a fixed literal with no user input, so there is no injection surface.
+        result = subprocess.run(  # nosec B607
+            ["git", "rev-parse", "--show-toplevel"],
+            capture_output=True,
+            text=True,
+        )
+        if result.returncode == 0:
+            _GIT_CACHE[cwd] = result.stdout.strip()
+            return _GIT_CACHE[cwd]
+        _GIT_CACHE[cwd] = None
+        return None
+    except FileNotFoundError:
+        _GIT_CACHE[cwd] = None
+        return None
+
Relevance

⭐⭐⭐ High

Team often enforces “never raises” CLI safety contracts; accepts defensive exception handling (e.g.,
PR#9, PR#14).

PR-#9
PR-#14

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The docstring promises “Never raises” on “any error”, but the implementation calls os.getcwd()
before the guarded block and only catches FileNotFoundError, so other failures propagate.

eidetic/memory/backend.py[148-176]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_git_toplevel()` is documented to fail-closed (return `None`) and never raise, but it can still raise due to `os.getcwd()` being outside the `try:` and due to only catching `FileNotFoundError`.

## Issue Context
This helper runs before every files-backend operation (directly or indirectly), so an unexpected exception here becomes a CLI crash.

## Fix Focus Areas
- eidetic/memory/backend.py[148-176]

### Implementation guidance
- Wrap `os.getcwd()` in the `try:` block.
- Broaden exception handling to include `OSError` (and possibly a final `except Exception:`) so the function truly fails closed.
- If `getcwd()` fails, return `None` without attempting to cache by cwd (since there is no stable key).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Recall mutates committed store 🐞 Bug ⚙ Maintainability
Description
cmd_recall reinforces every hit by upserting a bumped copy, and the new routing makes public
records inside a git repo write into <repo-root>/.eidetic/memory. As a result, running recall
now dirties the repo working tree (and can create merge conflicts) even though the user is
performing a read operation.
Code

eidetic/memory/backend.py[R312-316]

    def upsert(self, record: Record) -> None:
        """Idempotently upsert *record* into the store (by id; dedup by hash within scope)."""
-        _bridge_env(self._name)
+        if self._name == "files":
+            _bridge_env(self._name, data_dir=_resolve_write_dir(record.scope.visibility))
+        else:
Relevance

⭐ Low

Recall’s passive reinforcement via upsert is an explicit, previously accepted design;
writes-on-recall already endorsed (PR#7).

PR-#7

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
cmd_recall always persists reinforcement via backend.upsert(bumped), and StoreBackend.upsert
now selects the write directory from record.scope.visibility, which routes public records in-repo
into the repo store.

eidetic/cli/_commands/recall.py[123-139]
eidetic/memory/backend.py[312-320]
eidetic/memory/backend.py[178-193]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`recall` performs passive reinforcement (updates `last_recall`/`recall_count`) by calling `backend.upsert()` on every hit. With the new per-visibility routing, reinforcing a PUBLIC hit in a git repo writes to the repo store, turning recall into a repo-mutating operation.

## Issue Context
This can cause constant working-tree dirtiness and accidental commits of “recall metadata” into a team-shared store.

## Fix Focus Areas
- eidetic/memory/backend.py[312-316]
- eidetic/cli/_commands/recall.py[123-139]

### Implementation guidance
Choose one:
- Skip reinforcement for PUBLIC records when the resolved store dir is repo-local, or
- Add a flag/env (default off for repo store) controlling reinforcement writes, or
- Persist reinforcement metadata to a user-private location (home store) even when the record content is public.
Ensure tests cover the chosen policy (e.g., recall in repo does/doesn’t modify `<repo-root>/.eidetic/memory`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread eidetic/memory/backend.py
…oplevel

Addresses two Qodo review findings on PR #23 (inline comment 3463350770 +
review 4557593486).

#1 (correctness, defensive): StoreBackend.search() deduped multi-store
candidates by id (seen.setdefault, home-before-repo) BEFORE eidetic's own
can_serve filter ran. eidetic re-applies can_serve as defense-in-depth so the
no-leak invariant holds "regardless of the store's behavior" — but the ordering
left that defensive layer non-self-sufficient: a non-serveable private copy
could win the id slot and then be dropped, hiding a serveable public copy of the
same id. Move can_serve INTO the read loop, before setdefault, so only serveable
copies enter the dedup map. (Not currently exploitable through the real
data_refinery.store.list(), which pre-filters by can_serve at the source — but
this makes eidetic's own layer correct on its own terms.)

#2 (reliability): _git_toplevel() promised "Never raises" but os.getcwd() ran
outside the try and only FileNotFoundError was caught. Wrap getcwd (return None
without caching on failure) and broaden the subprocess except to OSError so the
helper truly fails closed before every files-backend op.

Tests: both regressions reproduce at eidetic's layer (fail pre-fix, pass post-).
The dedup test simulates an unfiltered store via monkeypatch — a test through the
real list() cannot reproduce it (DR pre-filters), which is why the first
colleague-written test passed on old code. 348 passed, 2 skipped.

#3 (recall mutates committed store) deferred to a follow-up issue — it changes
documented recall-reinforcement semantics, out of scope for this routing PR.

Co-Authored-By: colleague (Qwen3.6-27B via ask-colleague)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WR1vQvraNdkhuYNpy68fzh
@OriNachum

Copy link
Copy Markdown
Contributor Author

Qodo review triage — addressed in 0.10.1 (3d1a562)

Thanks for the review. Outcome per finding:

1. Dedup hides serveable records (Correctness) — FIXED (3d1a562). can_serve now runs inside the multi-store read loop, before id-dedup, so a non-serveable copy can't shadow a serveable one. Detail + the "defensive, not currently exploitable through DR's pre-filtering list()" nuance is on the inline thread.

2. _git_toplevel can raise (Reliability) — FIXED (3d1a562). os.getcwd() is now inside the try (returns None without caching on failure), and the subprocess guard catches OSError rather than only FileNotFoundError. The helper now honors its "Never raises" docstring before every files-backend op. Regression test (os.getcwdOSError) fails pre-fix, passes post-fix.

3. Recall mutates committed store (Maintainability) — DEFERRED to #24. Real and well-spotted: with per-visibility routing, passive reinforcement on a public hit inside a repo writes to <repo>/.eidetic/memory, so recall dirties the git tree. But changing whether/where recall writes alters documented recall-reinforcement semantics and is broader than this PR's "files-backend default directory only" scope (it overlaps the merge-friendliness follow-up already noted in the PR body). Tracked in #24 with the three candidate policies for a design decision + tests, rather than rushed in here.

Gates: 348 passed / 2 skipped; black, isort, flake8, bandit, markdownlint, and teken cli doctor --strict all green.

  • eidetic-cli (Claude)

@sonarqubecloud

Copy link
Copy Markdown

@OriNachum OriNachum merged commit 625b014 into main Jun 24, 2026
8 checks passed
@OriNachum OriNachum deleted the feat/project-local-store branch June 24, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant