Skip to content
This repository was archived by the owner on Jan 2, 2026. It is now read-only.

Commit 1ae92c0

Browse files
zircoteclaude
andcommitted
feat(index): implement per-project database isolation
Store memory index in project-local .memory/index.db instead of a global database. This ensures complete isolation between repositories: - Each repo clone gets its own SQLite index - Sync/reindex operations only affect current project - No cross-contamination or orphaned databases - Deleting a project removes its memory index Changes: - Add get_project_index_path() and get_project_memory_dir() to config - Update SyncService, RecallService, and hooks to use project index - Add .memory/ to .gitignore - Bump schema version to 2 with repo_path column for metadata 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0ce2502 commit 1ae92c0

9 files changed

Lines changed: 173 additions & 19 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,6 @@ dmypy.json
7878
.cs-session-state.json
7979
.prompt-log.json
8080
.prompt-log-enabled
81+
82+
# Memory plugin local index
83+
.memory/

src/git_notes_memory/config.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,12 @@
3939
"INDEX_DB_NAME",
4040
"MODELS_DIR_NAME",
4141
"LOCK_FILE_NAME",
42+
"MEMORY_DIR_NAME",
4243
"get_data_path",
4344
"get_index_path",
45+
"get_project_index_path",
46+
"get_project_identifier",
47+
"get_project_memory_dir",
4448
"get_models_path",
4549
"get_lock_path",
4650
# Embedding Configuration
@@ -177,14 +181,96 @@ def get_data_path() -> Path:
177181

178182

179183
def get_index_path() -> Path:
180-
"""Get the path to the SQLite index database.
184+
"""Get the path to the global SQLite index database.
185+
186+
Note: For per-project isolation, use get_project_index_path() instead.
181187
182188
Returns:
183189
Path to index.db file.
184190
"""
185191
return get_data_path() / INDEX_DB_NAME
186192

187193

194+
MEMORY_DIR_NAME = ".memory"
195+
196+
197+
def get_project_identifier(repo_path: Path | str | None = None) -> str:
198+
"""Get a unique identifier for a repository.
199+
200+
Uses the repository's git remote URL if available, otherwise falls back
201+
to the canonical path. The identifier is a short hash for filesystem safety.
202+
203+
Args:
204+
repo_path: Path to the repository. If None, uses current directory.
205+
206+
Returns:
207+
A short identifier string (e.g., "a1b2c3d4") unique to this repository.
208+
"""
209+
import hashlib
210+
import subprocess
211+
212+
if repo_path is None:
213+
repo_path = Path.cwd()
214+
else:
215+
repo_path = Path(repo_path).resolve()
216+
217+
# Try to get the remote URL for a stable identifier across machines
218+
try:
219+
result = subprocess.run( # noqa: S603
220+
["git", "-C", str(repo_path), "config", "--get", "remote.origin.url"], # noqa: S607
221+
capture_output=True,
222+
text=True,
223+
timeout=5,
224+
)
225+
if result.returncode == 0 and result.stdout.strip():
226+
identifier_source = result.stdout.strip()
227+
else:
228+
# Fall back to canonical path
229+
identifier_source = str(repo_path)
230+
except (subprocess.TimeoutExpired, FileNotFoundError):
231+
identifier_source = str(repo_path)
232+
233+
# Create a short hash for filesystem-safe naming
234+
hash_digest = hashlib.sha256(identifier_source.encode()).hexdigest()[:12]
235+
return hash_digest
236+
237+
238+
def get_project_memory_dir(repo_path: Path | str | None = None) -> Path:
239+
"""Get the path to the project's .memory directory.
240+
241+
The .memory directory stores project-specific memory data including
242+
the SQLite index. This directory should be added to .gitignore.
243+
244+
Args:
245+
repo_path: Path to the repository. If None, uses current directory.
246+
247+
Returns:
248+
Path to .memory/ directory in the repository root.
249+
"""
250+
if repo_path is None:
251+
repo_path = Path.cwd()
252+
else:
253+
repo_path = Path(repo_path).resolve()
254+
255+
return repo_path / MEMORY_DIR_NAME
256+
257+
258+
def get_project_index_path(repo_path: Path | str | None = None) -> Path:
259+
"""Get the path to a project-specific SQLite index database.
260+
261+
Each repository gets its own index database stored in <repo>/.memory/index.db.
262+
This ensures sync/reindex operations only affect the current project.
263+
The .memory directory should be added to .gitignore.
264+
265+
Args:
266+
repo_path: Path to the repository. If None, uses current directory.
267+
268+
Returns:
269+
Path to project-specific index.db file (e.g., <repo>/.memory/index.db).
270+
"""
271+
return get_project_memory_dir(repo_path) / INDEX_DB_NAME
272+
273+
188274
def get_models_path() -> Path:
189275
"""Get the path to the embedding models directory.
190276

src/git_notes_memory/hooks/context_builder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from datetime import datetime, timedelta
1919
from typing import TYPE_CHECKING
2020

21-
from git_notes_memory.config import TOKENS_PER_CHAR, get_index_path
21+
from git_notes_memory.config import TOKENS_PER_CHAR, get_project_index_path
2222
from git_notes_memory.hooks.config_loader import (
2323
BudgetMode,
2424
HookConfig,
@@ -105,7 +105,7 @@ def _get_index_service(self) -> IndexService:
105105
if self._index_service is None:
106106
from git_notes_memory.index import IndexService
107107

108-
self._index_service = IndexService(get_index_path())
108+
self._index_service = IndexService(get_project_index_path())
109109
return self._index_service
110110

111111
# -------------------------------------------------------------------------

src/git_notes_memory/hooks/session_start_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
import sys
3535
from typing import Any
3636

37-
from git_notes_memory.config import HOOK_SESSION_START_TIMEOUT, get_index_path
37+
from git_notes_memory.config import HOOK_SESSION_START_TIMEOUT, get_project_index_path
3838
from git_notes_memory.hooks.config_loader import load_hook_config
3939
from git_notes_memory.hooks.context_builder import ContextBuilder
4040
from git_notes_memory.hooks.guidance_builder import GuidanceBuilder
@@ -125,7 +125,7 @@ def _get_memory_count() -> int:
125125
Number of memories indexed, or 0 if index doesn't exist.
126126
"""
127127
try:
128-
index_path = get_index_path()
128+
index_path = get_project_index_path()
129129
if not index_path.exists():
130130
return 0
131131
index = IndexService(index_path)

src/git_notes_memory/index.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from __future__ import annotations
2323

24+
import contextlib
2425
import sqlite3
2526
from contextlib import contextmanager
2627
from datetime import UTC, datetime
@@ -47,7 +48,7 @@
4748
# =============================================================================
4849

4950
# Schema version for migrations
50-
SCHEMA_VERSION = 1
51+
SCHEMA_VERSION = 2
5152

5253
# SQL statements for schema creation
5354
_CREATE_MEMORIES_TABLE = """
@@ -58,6 +59,7 @@
5859
summary TEXT NOT NULL,
5960
content TEXT NOT NULL,
6061
timestamp TEXT NOT NULL,
62+
repo_path TEXT,
6163
spec TEXT,
6264
phase TEXT,
6365
tags TEXT,
@@ -74,8 +76,18 @@
7476
"CREATE INDEX IF NOT EXISTS idx_memories_commit ON memories(commit_sha)",
7577
"CREATE INDEX IF NOT EXISTS idx_memories_timestamp ON memories(timestamp)",
7678
"CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status)",
79+
"CREATE INDEX IF NOT EXISTS idx_memories_repo_path ON memories(repo_path)",
7780
]
7881

82+
# Migration SQL for schema version upgrades
83+
_MIGRATIONS = {
84+
2: [
85+
# Add repo_path column for per-repository memory isolation
86+
"ALTER TABLE memories ADD COLUMN repo_path TEXT",
87+
"CREATE INDEX IF NOT EXISTS idx_memories_repo_path ON memories(repo_path)",
88+
],
89+
}
90+
7991
_CREATE_VEC_TABLE = f"""
8092
CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(
8193
id TEXT PRIMARY KEY,
@@ -199,8 +211,48 @@ def _load_vec_extension(self) -> None:
199211
"Install sqlite-vec: pip install sqlite-vec",
200212
) from e
201213

214+
def _get_current_schema_version(self) -> int:
215+
"""Get the current schema version from the database.
216+
217+
Returns:
218+
Current schema version, or 0 if metadata table doesn't exist.
219+
"""
220+
if self._conn is None:
221+
return 0
222+
223+
cursor = self._conn.cursor()
224+
try:
225+
cursor.execute("SELECT value FROM metadata WHERE key = 'schema_version'")
226+
row = cursor.fetchone()
227+
return int(row[0]) if row else 1 # Default to v1 for existing DBs
228+
except sqlite3.OperationalError:
229+
# Metadata table doesn't exist - new database
230+
return 0
231+
232+
def _run_migrations(self, from_version: int, to_version: int) -> None:
233+
"""Run schema migrations from one version to another.
234+
235+
Args:
236+
from_version: Current schema version.
237+
to_version: Target schema version.
238+
"""
239+
if self._conn is None:
240+
return
241+
242+
cursor = self._conn.cursor()
243+
for version in range(from_version + 1, to_version + 1):
244+
if version in _MIGRATIONS:
245+
for sql in _MIGRATIONS[version]:
246+
try:
247+
cursor.execute(sql)
248+
except sqlite3.OperationalError as e:
249+
# Column may already exist from a partial migration
250+
if "duplicate column" not in str(e).lower():
251+
raise
252+
self._conn.commit()
253+
202254
def _create_schema(self) -> None:
203-
"""Create database tables and indices."""
255+
"""Create database tables and indices, running migrations if needed."""
204256
if self._conn is None:
205257
raise MemoryIndexError(
206258
"Database connection not established",
@@ -209,28 +261,36 @@ def _create_schema(self) -> None:
209261

210262
cursor = self._conn.cursor()
211263
try:
264+
# Check current schema version before creating tables
265+
current_version = self._get_current_schema_version()
266+
212267
# Create memories table
213268
cursor.execute(_CREATE_MEMORIES_TABLE)
214269

215-
# Create indices
270+
# Create indices (ignore if they already exist)
216271
for index_sql in _CREATE_INDICES:
217-
cursor.execute(index_sql)
272+
with contextlib.suppress(sqlite3.OperationalError):
273+
cursor.execute(index_sql)
218274

219275
# Create vector table
220276
cursor.execute(_CREATE_VEC_TABLE)
221277

222278
# Create metadata table
223279
cursor.execute(_CREATE_METADATA_TABLE)
224280

281+
# Run migrations if needed
282+
if 0 < current_version < SCHEMA_VERSION:
283+
self._run_migrations(current_version, SCHEMA_VERSION)
284+
225285
# Set schema version
226286
cursor.execute(
227287
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
228288
("schema_version", str(SCHEMA_VERSION)),
229289
)
230290

231-
# Set last sync to now
291+
# Set last sync to now (only if not already set)
232292
cursor.execute(
233-
"INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)",
293+
"INSERT OR IGNORE INTO metadata (key, value) VALUES (?, ?)",
234294
("last_sync", datetime.now(UTC).isoformat()),
235295
)
236296

@@ -309,9 +369,9 @@ def insert(
309369
"""
310370
INSERT INTO memories (
311371
id, commit_sha, namespace, summary, content,
312-
timestamp, spec, phase, tags, status, relates_to,
372+
timestamp, repo_path, spec, phase, tags, status, relates_to,
313373
created_at, updated_at
314-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
374+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
315375
""",
316376
(
317377
memory.id,
@@ -320,6 +380,7 @@ def insert(
320380
memory.summary,
321381
memory.content,
322382
memory.timestamp.isoformat(),
383+
memory.repo_path,
323384
memory.spec,
324385
memory.phase,
325386
",".join(memory.tags) if memory.tags else None,

src/git_notes_memory/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ class Memory:
105105
summary: One-line summary (max 100 chars)
106106
content: Full markdown content of the note
107107
timestamp: When the memory was captured
108+
repo_path: Absolute path to the git repository containing this memory
108109
spec: Specification slug this memory belongs to (may be None for global)
109110
phase: Lifecycle phase (planning, implementation, review, etc.)
110111
tags: Categorization tags
@@ -118,6 +119,7 @@ class Memory:
118119
summary: str
119120
content: str
120121
timestamp: datetime
122+
repo_path: str | None = None
121123
spec: str | None = None
122124
phase: str | None = None
123125
tags: tuple[str, ...] = field(default_factory=tuple)

src/git_notes_memory/recall.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from collections.abc import Sequence
1818
from typing import TYPE_CHECKING
1919

20-
from git_notes_memory.config import TOKENS_PER_CHAR, get_index_path
20+
from git_notes_memory.config import TOKENS_PER_CHAR, get_project_index_path
2121
from git_notes_memory.exceptions import RecallError
2222
from git_notes_memory.models import (
2323
CommitInfo,
@@ -89,7 +89,8 @@ def __init__(
8989
git_ops: Optional pre-configured GitOps instance.
9090
If not provided, one will be created lazily.
9191
"""
92-
self._index_path = index_path or get_index_path()
92+
# Use project-specific index for per-repository isolation
93+
self._index_path = index_path or get_project_index_path()
9394
self._index_service = index_service
9495
self._embedding_service = embedding_service
9596
self._git_ops = git_ops

src/git_notes_memory/sync.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from pathlib import Path
1818
from typing import TYPE_CHECKING
1919

20-
from git_notes_memory.config import NAMESPACES, get_data_path
20+
from git_notes_memory.config import NAMESPACES, get_project_index_path
2121
from git_notes_memory.exceptions import RecallError
2222
from git_notes_memory.models import Memory, NoteRecord, VerificationResult
2323

@@ -74,11 +74,12 @@ def __init__(
7474
self._note_parser = note_parser
7575

7676
def _get_index(self) -> IndexService:
77-
"""Get or create IndexService instance."""
77+
"""Get or create IndexService instance using project-specific database."""
7878
if self._index is None:
7979
from git_notes_memory.index import IndexService
8080

81-
self._index = IndexService(get_data_path() / "index.db")
81+
# Use project-specific index for per-repository isolation
82+
self._index = IndexService(get_project_index_path(self.repo_path))
8283
self._index.initialize()
8384
return self._index
8485

tests/test_index.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def test_initialize_sets_schema_version(self, db_path: Path) -> None:
153153
cursor.execute("SELECT value FROM metadata WHERE key = 'schema_version'")
154154
row = cursor.fetchone()
155155
assert row is not None
156-
assert row[0] == "1"
156+
assert row[0] == "2" # Schema v2 adds repo_path column
157157

158158
service.close()
159159

0 commit comments

Comments
 (0)