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 +8 -0
- lethe/_lock.py +50 -0
- lethe/_registry.py +159 -0
- lethe/cli.py +687 -0
- lethe/db.py +262 -0
- lethe/dedup.py +30 -0
- lethe/encoders.py +112 -0
- lethe/enrichment.py +311 -0
- lethe/entry.py +67 -0
- lethe/markdown_store.py +263 -0
- lethe/memory_store.py +402 -0
- lethe/reranker.py +43 -0
- lethe/rif.py +249 -0
- lethe/union_store.py +220 -0
- lethe/vectors.py +124 -0
- lethe_memory-0.2.0.dist-info/METADATA +21 -0
- lethe_memory-0.2.0.dist-info/RECORD +19 -0
- lethe_memory-0.2.0.dist-info/WHEEL +4 -0
- lethe_memory-0.2.0.dist-info/entry_points.txt +2 -0
lethe/__init__.py
ADDED
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
|