lethe-memory 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lethe/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.2.0" # x-release-please-version
4
+
5
+ from lethe.memory_store import MemoryStore
6
+ from lethe.rif import RIFConfig
7
+
8
+ __all__ = ["MemoryStore", "RIFConfig", "__version__"]
lethe/_lock.py ADDED
@@ -0,0 +1,50 @@
1
+ """fcntl-based file lock for serializing concurrent ``lethe`` CLI invocations.
2
+
3
+ Claude Code hooks (SessionStart, UserPromptSubmit, Stop, SessionEnd) fire in
4
+ quick succession and can overlap. DuckDB is single-writer-per-process across
5
+ the file, so two ``lethe index`` calls racing on ``lethe.duckdb`` would get a
6
+ lock error. This wrapper turns that race into well-mannered serialization.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ import time
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import Iterator
15
+
16
+
17
+ DEFAULT_TIMEOUT_S = 30.0
18
+
19
+
20
+ class LockTimeout(RuntimeError):
21
+ pass
22
+
23
+
24
+ @contextmanager
25
+ def acquire(lock_path: Path, timeout: float = DEFAULT_TIMEOUT_S) -> Iterator[None]:
26
+ """Blocking ``flock`` with a timeout. Creates parent dir + lockfile if missing."""
27
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
28
+ fd = open(lock_path, "w")
29
+ deadline = time.monotonic() + timeout
30
+ try:
31
+ while True:
32
+ try:
33
+ fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
34
+ break
35
+ except BlockingIOError:
36
+ if time.monotonic() > deadline:
37
+ raise LockTimeout(
38
+ f"could not acquire {lock_path} within {timeout}s"
39
+ )
40
+ time.sleep(0.05)
41
+ try:
42
+ yield
43
+ finally:
44
+ try:
45
+ fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
46
+ except OSError:
47
+ # Lock file may have been rmtree'd (e.g. during `lethe reset`).
48
+ pass
49
+ finally:
50
+ fd.close()
lethe/_registry.py ADDED
@@ -0,0 +1,159 @@
1
+ """Per-user project registry at ``~/.lethe/projects.json``.
2
+
3
+ ``lethe search --all`` reads this file to know which projects to ATTACH.
4
+ ``lethe index`` appends to it unless the user opts out via
5
+ ``--no-register``, ``auto_register = false`` in ``config.toml``, or
6
+ ``LETHE_DISABLE_GLOBAL_REGISTRY=1``.
7
+
8
+ Only absolute project root paths are stored — no contents, no hashes of
9
+ markdown. Privacy-sensitive users should disable via the env var.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime
14
+ import fcntl
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Iterable
22
+
23
+
24
+ REGISTRY_VERSION = 1
25
+
26
+
27
+ def _registry_dir() -> Path:
28
+ return Path(os.environ.get("HOME", "~")).expanduser() / ".lethe"
29
+
30
+
31
+ def _registry_path() -> Path:
32
+ return _registry_dir() / "projects.json"
33
+
34
+
35
+ def slugify(root: Path) -> str:
36
+ """Stable, FS + DuckDB-safe identifier for a project root.
37
+
38
+ ``p_<sanitized-basename>_<sha1[:8]>``. Hash of the absolute path avoids
39
+ collisions when two projects share a basename.
40
+ """
41
+ resolved = str(Path(root).resolve())
42
+ name = re.sub(r"[^A-Za-z0-9]", "_", Path(resolved).name).strip("_") or "proj"
43
+ h = hashlib.sha1(resolved.encode()).hexdigest()[:8]
44
+ return f"p_{name}_{h}"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ProjectEntry:
49
+ root: Path
50
+ slug: str
51
+ registered_at: str
52
+
53
+ def to_dict(self) -> dict[str, str]:
54
+ return {
55
+ "root": str(self.root),
56
+ "slug": self.slug,
57
+ "registered_at": self.registered_at,
58
+ }
59
+
60
+ @classmethod
61
+ def from_dict(cls, d: dict[str, str]) -> "ProjectEntry":
62
+ return cls(
63
+ root=Path(d["root"]),
64
+ slug=str(d.get("slug") or slugify(Path(d["root"]))),
65
+ registered_at=str(d.get("registered_at", "")),
66
+ )
67
+
68
+
69
+ def is_disabled() -> bool:
70
+ return os.environ.get("LETHE_DISABLE_GLOBAL_REGISTRY", "").strip() not in ("", "0", "false", "False")
71
+
72
+
73
+ def load() -> list[ProjectEntry]:
74
+ path = _registry_path()
75
+ if not path.exists():
76
+ return []
77
+ try:
78
+ data = json.loads(path.read_text(encoding="utf-8"))
79
+ except (OSError, json.JSONDecodeError):
80
+ return []
81
+ projects = data.get("projects", []) if isinstance(data, dict) else []
82
+ out: list[ProjectEntry] = []
83
+ for p in projects:
84
+ if isinstance(p, dict) and "root" in p:
85
+ out.append(ProjectEntry.from_dict(p))
86
+ return out
87
+
88
+
89
+ def _save(entries: Iterable[ProjectEntry]) -> None:
90
+ path = _registry_path()
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ payload = {
93
+ "version": REGISTRY_VERSION,
94
+ "projects": [e.to_dict() for e in entries],
95
+ }
96
+ tmp = path.with_suffix(".json.tmp")
97
+ tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
98
+ os.replace(tmp, path)
99
+
100
+
101
+ def _with_registry_lock():
102
+ """Return a file-lock context manager over the registry directory.
103
+
104
+ Serializes concurrent registry mutations so two ``lethe index`` calls
105
+ in different projects can't trample the JSON file.
106
+ """
107
+ from lethe._lock import acquire as _acquire
108
+
109
+ _registry_dir().mkdir(parents=True, exist_ok=True)
110
+ return _acquire(_registry_dir() / "registry.lock")
111
+
112
+
113
+ def register(root: Path) -> ProjectEntry:
114
+ """Idempotently add ``root`` to the registry. Returns the entry."""
115
+ resolved = Path(root).resolve()
116
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
117
+ with _with_registry_lock():
118
+ entries = load()
119
+ for existing in entries:
120
+ if Path(existing.root).resolve() == resolved:
121
+ return existing
122
+ entry = ProjectEntry(root=resolved, slug=slugify(resolved), registered_at=now)
123
+ entries.append(entry)
124
+ _save(entries)
125
+ return entry
126
+
127
+
128
+ def unregister(root_or_slug: str) -> bool:
129
+ """Remove a project by root path or slug. Returns True if removed."""
130
+ target = str(Path(root_or_slug).resolve()) if Path(root_or_slug).exists() else root_or_slug
131
+ with _with_registry_lock():
132
+ entries = load()
133
+ kept = [
134
+ e for e in entries
135
+ if str(Path(e.root).resolve()) != target and e.slug != root_or_slug
136
+ ]
137
+ if len(kept) == len(entries):
138
+ return False
139
+ _save(kept)
140
+ return True
141
+
142
+
143
+ def prune() -> list[ProjectEntry]:
144
+ """Drop entries whose root directories no longer exist. Returns the kept entries."""
145
+ with _with_registry_lock():
146
+ entries = load()
147
+ kept = [e for e in entries if Path(e.root).exists()]
148
+ if len(kept) != len(entries):
149
+ _save(kept)
150
+ return kept
151
+
152
+
153
+ def find(name: str) -> ProjectEntry | None:
154
+ """Find a project by slug or exact root path."""
155
+ entries = load()
156
+ for e in entries:
157
+ if e.slug == name or str(e.root) == name:
158
+ return e
159
+ return None