compartment 1.15.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.
- compartment/__init__.py +7 -0
- compartment/acl.py +92 -0
- compartment/audit.py +54 -0
- compartment/bench.py +94 -0
- compartment/claude_hooks.py +207 -0
- compartment/claude_memory.py +183 -0
- compartment/cli.py +1195 -0
- compartment/crypto.py +229 -0
- compartment/dash.py +672 -0
- compartment/data/hermes-plugin/__init__.py +321 -0
- compartment/data/hermes-plugin/plugin.yaml +7 -0
- compartment/data/menubar.png +0 -0
- compartment/data/menubar@2x.png +0 -0
- compartment/data/starter.mpack +0 -0
- compartment/embed.py +140 -0
- compartment/home.py +53 -0
- compartment/longmemeval.py +286 -0
- compartment/menubar.py +746 -0
- compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
- compartment/models/bge-small-en-v1.5-int8/tokenizer.json +30672 -0
- compartment/offline_guard.py +53 -0
- compartment/packs.py +240 -0
- compartment/platforms.py +189 -0
- compartment/salience.py +128 -0
- compartment/selftest.py +74 -0
- compartment/server.py +327 -0
- compartment/session.py +118 -0
- compartment/store.py +287 -0
- compartment/vault.py +866 -0
- compartment/vaultfile.py +284 -0
- compartment/vindex.py +124 -0
- compartment-1.15.0.dist-info/METADATA +396 -0
- compartment-1.15.0.dist-info/RECORD +37 -0
- compartment-1.15.0.dist-info/WHEEL +5 -0
- compartment-1.15.0.dist-info/entry_points.txt +4 -0
- compartment-1.15.0.dist-info/licenses/LICENSE +21 -0
- compartment-1.15.0.dist-info/top_level.txt +1 -0
compartment/__init__.py
ADDED
compartment/acl.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Per-caller namespace access control + vault-adjacent settings.
|
|
2
|
+
|
|
3
|
+
Config lives NEXT to the vault as `<vault>.config.json` (it contains no
|
|
4
|
+
secrets - only ACLs and preferences). `packs/*` namespaces are ALWAYS
|
|
5
|
+
read-only for every caller, including "*". Violations are hard errors.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from .crypto import CryptoError
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = {
|
|
16
|
+
"callers": {
|
|
17
|
+
"*": {"default_namespace": "main", "grants": {"*": "rw"}},
|
|
18
|
+
},
|
|
19
|
+
"settings": {
|
|
20
|
+
"auto_lock_minutes": 30,
|
|
21
|
+
"include_packs_in_search": True,
|
|
22
|
+
"unlock_tool_enabled": False,
|
|
23
|
+
"duplicate_threshold": 0.97,
|
|
24
|
+
"index_precision": "f32",
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AclError(CryptoError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class VaultConfig:
|
|
35
|
+
callers: dict = field(default_factory=lambda: DEFAULT_CONFIG["callers"].copy())
|
|
36
|
+
settings: dict = field(default_factory=lambda: DEFAULT_CONFIG["settings"].copy())
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def path_for(vault_path: str) -> str:
|
|
40
|
+
return vault_path + ".config.json"
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, vault_path: str) -> "VaultConfig":
|
|
44
|
+
p = cls.path_for(vault_path)
|
|
45
|
+
if not os.path.exists(p):
|
|
46
|
+
return cls()
|
|
47
|
+
with open(p, encoding="utf-8") as f:
|
|
48
|
+
data = json.load(f)
|
|
49
|
+
cfg = cls()
|
|
50
|
+
cfg.callers = data.get("callers", cfg.callers)
|
|
51
|
+
cfg.settings = {**cfg.settings, **data.get("settings", {})}
|
|
52
|
+
return cfg
|
|
53
|
+
|
|
54
|
+
def save(self, vault_path: str) -> None:
|
|
55
|
+
with open(self.path_for(vault_path), "w", encoding="utf-8") as f:
|
|
56
|
+
json.dump({"callers": self.callers, "settings": self.settings}, f, indent=2)
|
|
57
|
+
|
|
58
|
+
# -- ACL ---------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _caller_entry(self, caller: str) -> dict:
|
|
61
|
+
entry = self.callers.get(caller) or self.callers.get("*")
|
|
62
|
+
if entry is None:
|
|
63
|
+
raise AclError(f"Caller {caller!r} has no access to this vault")
|
|
64
|
+
return entry
|
|
65
|
+
|
|
66
|
+
def default_namespace(self, caller: str) -> str:
|
|
67
|
+
return self._caller_entry(caller).get("default_namespace", "main")
|
|
68
|
+
|
|
69
|
+
def grant_for(self, caller: str, namespace: str) -> str:
|
|
70
|
+
"""Returns 'rw', 'ro', or raises AclError. packs/* is always ro."""
|
|
71
|
+
entry = self._caller_entry(caller)
|
|
72
|
+
grants = entry.get("grants", {})
|
|
73
|
+
grant = grants.get(namespace)
|
|
74
|
+
if grant is None:
|
|
75
|
+
for pattern, g in grants.items():
|
|
76
|
+
if pattern.endswith("*") and namespace.startswith(pattern[:-1]):
|
|
77
|
+
grant = g
|
|
78
|
+
break
|
|
79
|
+
if grant is None:
|
|
80
|
+
grant = grants.get("*")
|
|
81
|
+
if grant is None:
|
|
82
|
+
raise AclError(
|
|
83
|
+
f"Caller {caller!r} is not granted access to namespace {namespace!r}")
|
|
84
|
+
if namespace.startswith("packs/"):
|
|
85
|
+
return "ro" # pack namespaces are immutable for everyone
|
|
86
|
+
return grant
|
|
87
|
+
|
|
88
|
+
def check(self, caller: str, namespace: str, write: bool) -> None:
|
|
89
|
+
grant = self.grant_for(caller, namespace)
|
|
90
|
+
if write and grant != "rw":
|
|
91
|
+
raise AclError(
|
|
92
|
+
f"Caller {caller!r} has read-only access to namespace {namespace!r}")
|
compartment/audit.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Hash-chained, tamper-evident audit log (stored inside the sealed payload).
|
|
2
|
+
|
|
3
|
+
Each entry's hash covers the previous entry's hash, so any edit, deletion,
|
|
4
|
+
or reordering of history breaks the chain at a detectable point.
|
|
5
|
+
A failure to write the audit entry fails the operation (fail-fast), never
|
|
6
|
+
the other way around.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
GENESIS = "GENESIS"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _entry_hash(prev_hash: str, ts: float, caller: str, op: str, detail: str) -> str:
|
|
18
|
+
body = json.dumps(
|
|
19
|
+
{"prev": prev_hash, "ts": ts, "caller": caller, "op": op, "detail": detail},
|
|
20
|
+
sort_keys=True, separators=(",", ":"),
|
|
21
|
+
).encode()
|
|
22
|
+
return hashlib.sha256(body).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def append(conn, caller: str, op: str, detail: str) -> str:
|
|
26
|
+
row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
|
|
27
|
+
prev = row["hash"] if row else GENESIS
|
|
28
|
+
ts = time.time()
|
|
29
|
+
h = _entry_hash(prev, ts, caller, op, detail)
|
|
30
|
+
conn.execute(
|
|
31
|
+
"INSERT INTO audit (ts, caller, op, detail, prev_hash, hash) VALUES (?,?,?,?,?,?)",
|
|
32
|
+
(ts, caller, op, detail, prev, h),
|
|
33
|
+
)
|
|
34
|
+
return h
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def head(conn) -> str:
|
|
38
|
+
row = conn.execute("SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
|
|
39
|
+
return row["hash"] if row else GENESIS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def verify(conn) -> tuple[bool, int, str]:
|
|
43
|
+
"""Walk the chain. Returns (ok, entries_checked, message)."""
|
|
44
|
+
prev = GENESIS
|
|
45
|
+
n = 0
|
|
46
|
+
for row in conn.execute("SELECT * FROM audit ORDER BY seq"):
|
|
47
|
+
if row["prev_hash"] != prev:
|
|
48
|
+
return False, n, f"chain break at seq {row['seq']}: prev_hash mismatch"
|
|
49
|
+
want = _entry_hash(prev, row["ts"], row["caller"], row["op"], row["detail"])
|
|
50
|
+
if row["hash"] != want:
|
|
51
|
+
return False, n, f"chain break at seq {row['seq']}: entry hash mismatch"
|
|
52
|
+
prev = row["hash"]
|
|
53
|
+
n += 1
|
|
54
|
+
return True, n, f"audit chain intact ({n} entries)"
|
compartment/bench.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Performance + RAM benchmark against the seed pack and a synthetic corpus.
|
|
2
|
+
|
|
3
|
+
Reports embed/store/search latencies (p50/p95) and peak RSS, and checks the
|
|
4
|
+
8GB-baseline budgets: search p95 < 100ms at the synthetic scale, total RSS
|
|
5
|
+
< 1GB at 100k records with default settings.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import resource # POSIX only; absent on Windows
|
|
17
|
+
except ImportError: # pragma: no cover - Windows
|
|
18
|
+
resource = None
|
|
19
|
+
|
|
20
|
+
from .vindex import build_index
|
|
21
|
+
|
|
22
|
+
WORDS = ("report vault memory agent record office data schedule market key "
|
|
23
|
+
"index search secure backup ledger review batch upload form note").split()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _rss_mb() -> float:
|
|
27
|
+
if resource is None: # Windows: peak-RSS via getrusage is unavailable
|
|
28
|
+
return 0.0
|
|
29
|
+
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
30
|
+
return rss / (1024 * 1024) if sys.platform == "darwin" else rss / 1024
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pct(xs: list[float], p: float) -> float:
|
|
34
|
+
xs = sorted(xs)
|
|
35
|
+
return xs[min(len(xs) - 1, int(len(xs) * p))]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run(vault, synthetic_n: int = 20_000, queries: int = 50) -> dict:
|
|
39
|
+
rng = random.Random(42)
|
|
40
|
+
out: dict = {"synthetic_records": synthetic_n}
|
|
41
|
+
|
|
42
|
+
# 1) real embed+store latency on a small sample
|
|
43
|
+
t_store = []
|
|
44
|
+
for i in range(20):
|
|
45
|
+
text = f"benchmark memory {i}: " + " ".join(rng.choices(WORDS, k=10))
|
|
46
|
+
t0 = time.perf_counter()
|
|
47
|
+
vault.store(text, caller="bench", namespace="bench", tags=["bench"])
|
|
48
|
+
t_store.append((time.perf_counter() - t0) * 1000)
|
|
49
|
+
out["store_ms_p50"] = round(_pct(t_store, 0.50), 1)
|
|
50
|
+
out["store_ms_p95"] = round(_pct(t_store, 0.95), 1)
|
|
51
|
+
|
|
52
|
+
# 2) synthetic vector corpus at scale (index-only: isolates search speed)
|
|
53
|
+
dim = int(vault.header.model["dim"])
|
|
54
|
+
mat = np.random.default_rng(42).standard_normal((synthetic_n, dim)).astype(np.float32)
|
|
55
|
+
mat /= np.linalg.norm(mat, axis=1, keepdims=True)
|
|
56
|
+
t0 = time.perf_counter()
|
|
57
|
+
idx = build_index(dim, list(range(1, synthetic_n + 1)), mat,
|
|
58
|
+
precision=vault.config.settings.get("index_precision", "f32"),
|
|
59
|
+
force=None)
|
|
60
|
+
out["index_build_s"] = round(time.perf_counter() - t0, 2)
|
|
61
|
+
out["index_kind"] = idx.kind
|
|
62
|
+
|
|
63
|
+
t_search = []
|
|
64
|
+
for _ in range(queries):
|
|
65
|
+
q = mat[rng.randrange(synthetic_n)]
|
|
66
|
+
t0 = time.perf_counter()
|
|
67
|
+
idx.search(q, 10)
|
|
68
|
+
t_search.append((time.perf_counter() - t0) * 1000)
|
|
69
|
+
out["vector_search_ms_p50"] = round(_pct(t_search, 0.50), 2)
|
|
70
|
+
out["vector_search_ms_p95"] = round(_pct(t_search, 0.95), 2)
|
|
71
|
+
|
|
72
|
+
# 3) full hybrid search on the live vault (embed + vector + FTS + fuse)
|
|
73
|
+
t_hybrid = []
|
|
74
|
+
for _ in range(20):
|
|
75
|
+
t0 = time.perf_counter()
|
|
76
|
+
vault.search("benchmark memory " + rng.choice(WORDS), caller="bench", top_k=8)
|
|
77
|
+
t_hybrid.append((time.perf_counter() - t0) * 1000)
|
|
78
|
+
out["hybrid_search_ms_p50"] = round(_pct(t_hybrid, 0.50), 1)
|
|
79
|
+
out["hybrid_search_ms_p95"] = round(_pct(t_hybrid, 0.95), 1)
|
|
80
|
+
|
|
81
|
+
out["peak_rss_mb"] = round(_rss_mb(), 0)
|
|
82
|
+
out["budgets"] = {
|
|
83
|
+
"vector_search_p95_under_100ms": out["vector_search_ms_p95"] < 100,
|
|
84
|
+
"rss_under_1gb": out["peak_rss_mb"] < 1024,
|
|
85
|
+
}
|
|
86
|
+
# clean up bench records
|
|
87
|
+
rows = vault.db.conn.execute(
|
|
88
|
+
"SELECT id, ikey FROM records WHERE ns = 'bench'").fetchall()
|
|
89
|
+
for r in rows:
|
|
90
|
+
vault.db.delete(r["id"], shred=False)
|
|
91
|
+
vault.index.remove(r["ikey"])
|
|
92
|
+
vault._id_by_ikey.pop(r["ikey"], None)
|
|
93
|
+
vault.save()
|
|
94
|
+
return out
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Deterministic capture: mirror Claude Code memory writes into the vault.
|
|
2
|
+
|
|
3
|
+
Telling a model to prefer compartment is a request, and a host that declares its
|
|
4
|
+
own memory in the system prompt outranks anything a tool says. A hook does
|
|
5
|
+
not ask. When Claude Code writes a file into its memory directory, this hook
|
|
6
|
+
runs and the fact lands in the vault whether or not the model ever thought
|
|
7
|
+
about compartment.
|
|
8
|
+
|
|
9
|
+
Two halves:
|
|
10
|
+
|
|
11
|
+
* `install()` merges an compartment entry into ~/.claude/settings.json - additive,
|
|
12
|
+
idempotent, and it backs the file up first. Other people's hooks are left
|
|
13
|
+
exactly as they are; only compartment's own entries are replaced on re-install.
|
|
14
|
+
* `capture()` is what the hook command runs. It reads Claude Code's hook JSON
|
|
15
|
+
on stdin, and if the written file is a memory file, stores it. It exits 0
|
|
16
|
+
no matter what: a memory tool must never break the user's editor.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .home import env, home
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import claude_memory
|
|
28
|
+
|
|
29
|
+
SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
30
|
+
# Identifies OUR entries. Both tokens, not one literal string: the command
|
|
31
|
+
# carries `--vault ...` between them when a vault is pinned, so a single
|
|
32
|
+
# substring would stop matching and re-installing would duplicate the hook.
|
|
33
|
+
MARKER = "hook capture"
|
|
34
|
+
_OWNER = "compartment"
|
|
35
|
+
EVENT = "PostToolUse"
|
|
36
|
+
MATCHER = "Write|Edit|MultiEdit|NotebookEdit"
|
|
37
|
+
TIMEOUT = 15
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _entry(command: str) -> dict:
|
|
41
|
+
return {"matcher": MATCHER,
|
|
42
|
+
"hooks": [{"type": "command", "command": command,
|
|
43
|
+
"timeout": TIMEOUT}]}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def hook_command(compartment_bin: str | None = None, vault: str | None = None) -> str:
|
|
47
|
+
"""The exact shell command the hook runs."""
|
|
48
|
+
exe = compartment_bin or shutil.which("compartment") or "compartment"
|
|
49
|
+
if " " in exe:
|
|
50
|
+
exe = f'"{exe}"'
|
|
51
|
+
vault_part = f' --vault "{vault}"' if vault else ""
|
|
52
|
+
return f"{exe}{vault_part} hook capture"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_ours(command: object) -> bool:
|
|
56
|
+
"""Whether a configured hook command belongs to compartment."""
|
|
57
|
+
c = str(command or "")
|
|
58
|
+
return MARKER in c and _OWNER in c
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_installed(settings: Path | None = None) -> bool:
|
|
62
|
+
data = _read(settings or SETTINGS)
|
|
63
|
+
for group in (data.get("hooks", {}) or {}).get(EVENT, []) or []:
|
|
64
|
+
for h in group.get("hooks", []) or []:
|
|
65
|
+
if is_ours(h.get("command")):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read(path: Path) -> dict:
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return {}
|
|
73
|
+
try:
|
|
74
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
75
|
+
return json.loads(text) if text else {}
|
|
76
|
+
except (json.JSONDecodeError, OSError):
|
|
77
|
+
raise ValueError(f"{path} is not valid JSON; refusing to touch it")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def install(compartment_bin: str | None = None, vault: str | None = None,
|
|
81
|
+
settings: Path | None = None) -> dict:
|
|
82
|
+
"""Add (or refresh) compartment's capture hook. Never removes anyone else's."""
|
|
83
|
+
path = Path(settings) if settings else SETTINGS
|
|
84
|
+
data = _read(path) # raises on malformed
|
|
85
|
+
backup = None
|
|
86
|
+
if path.exists():
|
|
87
|
+
backup = path.with_suffix(path.suffix + ".compartment-backup")
|
|
88
|
+
shutil.copy2(path, backup)
|
|
89
|
+
|
|
90
|
+
hooks = data.setdefault("hooks", {})
|
|
91
|
+
groups = hooks.setdefault(EVENT, [])
|
|
92
|
+
# drop only our own previous entries, so re-installing updates in place
|
|
93
|
+
kept = []
|
|
94
|
+
for group in groups:
|
|
95
|
+
inner = [h for h in (group.get("hooks") or [])
|
|
96
|
+
if not is_ours(h.get("command"))]
|
|
97
|
+
if inner:
|
|
98
|
+
kept.append({**group, "hooks": inner})
|
|
99
|
+
elif not group.get("hooks"):
|
|
100
|
+
kept.append(group)
|
|
101
|
+
kept.append(_entry(hook_command(compartment_bin, vault)))
|
|
102
|
+
hooks[EVENT] = kept
|
|
103
|
+
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
106
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
107
|
+
os.replace(tmp, path)
|
|
108
|
+
return {"settings": str(path), "backup": str(backup) if backup else None,
|
|
109
|
+
"event": EVENT, "matcher": MATCHER}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def uninstall(settings: Path | None = None) -> bool:
|
|
113
|
+
path = Path(settings) if settings else SETTINGS
|
|
114
|
+
data = _read(path)
|
|
115
|
+
groups = (data.get("hooks", {}) or {}).get(EVENT)
|
|
116
|
+
if not groups:
|
|
117
|
+
return False
|
|
118
|
+
kept, removed = [], False
|
|
119
|
+
for group in groups:
|
|
120
|
+
inner = [h for h in (group.get("hooks") or [])
|
|
121
|
+
if not is_ours(h.get("command"))]
|
|
122
|
+
if len(inner) != len(group.get("hooks") or []):
|
|
123
|
+
removed = True
|
|
124
|
+
if inner or not group.get("hooks"):
|
|
125
|
+
kept.append({**group, "hooks": inner})
|
|
126
|
+
if not removed:
|
|
127
|
+
return False
|
|
128
|
+
data["hooks"][EVENT] = kept
|
|
129
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------ capture
|
|
134
|
+
|
|
135
|
+
def _file_from_payload(payload: dict) -> str | None:
|
|
136
|
+
"""Claude Code puts the edited path under tool_input; tolerate variants
|
|
137
|
+
rather than silently capturing nothing if a field is renamed."""
|
|
138
|
+
for key in ("tool_input", "toolInput", "input"):
|
|
139
|
+
block = payload.get(key)
|
|
140
|
+
if isinstance(block, dict):
|
|
141
|
+
for f in ("file_path", "filePath", "path", "notebook_path"):
|
|
142
|
+
if block.get(f):
|
|
143
|
+
return str(block[f])
|
|
144
|
+
for f in ("file_path", "filePath", "path"):
|
|
145
|
+
if payload.get(f):
|
|
146
|
+
return str(payload[f])
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_memory_file(path: str | Path) -> bool:
|
|
151
|
+
"""True for Claude Code's own memory notes - and nothing else. The index
|
|
152
|
+
is a table of contents, so it is skipped like the importer skips it."""
|
|
153
|
+
p = Path(path)
|
|
154
|
+
if p.suffix.lower() != ".md" or p.name == claude_memory.INDEX_NAME:
|
|
155
|
+
return False
|
|
156
|
+
parts = [x.lower() for x in p.parts]
|
|
157
|
+
return "memory" in parts and ".claude" in parts
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def capture(stream=None, vault_path: str | None = None) -> dict:
|
|
161
|
+
"""Run by the hook. Returns a result dict; never raises, never blocks."""
|
|
162
|
+
stream = stream or sys.stdin
|
|
163
|
+
try:
|
|
164
|
+
raw = stream.read()
|
|
165
|
+
except Exception: # noqa: BLE001
|
|
166
|
+
return {"stored": False, "reason": "unreadable stdin"}
|
|
167
|
+
if not raw or not raw.strip():
|
|
168
|
+
return {"stored": False, "reason": "empty payload"}
|
|
169
|
+
try:
|
|
170
|
+
payload = json.loads(raw)
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
return {"stored": False, "reason": "payload is not JSON"}
|
|
173
|
+
|
|
174
|
+
target = _file_from_payload(payload)
|
|
175
|
+
if not target:
|
|
176
|
+
return {"stored": False, "reason": "no file path in payload"}
|
|
177
|
+
if not is_memory_file(target):
|
|
178
|
+
return {"stored": False, "reason": "not a memory file"}
|
|
179
|
+
p = Path(target)
|
|
180
|
+
if not p.exists():
|
|
181
|
+
return {"stored": False, "reason": "file no longer exists"}
|
|
182
|
+
|
|
183
|
+
from .crypto import CryptoError
|
|
184
|
+
from .vault import Vault
|
|
185
|
+
vp = vault_path or env("VAULT", str(home() / "memory.vault"))
|
|
186
|
+
if not os.path.exists(vp):
|
|
187
|
+
return {"stored": False, "reason": "no vault"}
|
|
188
|
+
try:
|
|
189
|
+
pw, key = Vault.resolve_credential(vp)
|
|
190
|
+
v = Vault.unlock(vp, passphrase=pw, raw_key=key)
|
|
191
|
+
except CryptoError:
|
|
192
|
+
# A locked vault is the user's choice, not an error worth shouting
|
|
193
|
+
# about mid-edit; the next import-claude sweeps up whatever was missed.
|
|
194
|
+
return {"stored": False, "reason": "vault locked"}
|
|
195
|
+
try:
|
|
196
|
+
rec = claude_memory.parse(p)
|
|
197
|
+
out = v.store(rec["text"], caller="claude-code-hook",
|
|
198
|
+
tags=rec["tags"], importance=rec["importance"])
|
|
199
|
+
v.save()
|
|
200
|
+
except Exception as exc: # noqa: BLE001
|
|
201
|
+
return {"stored": False, "reason": f"store failed: {exc}"}
|
|
202
|
+
return {"stored": not out.get("duplicate"), "id": out.get("id"),
|
|
203
|
+
"duplicate": bool(out.get("duplicate")), "name": rec["name"]}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["install", "uninstall", "is_installed", "is_ours", "capture", "hook_command",
|
|
207
|
+
"is_memory_file", "SETTINGS", "MARKER", "EVENT", "MATCHER"]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Import Claude Code's file-based memories into the vault.
|
|
2
|
+
|
|
3
|
+
Claude Code keeps a per-project memory directory of Markdown files, each
|
|
4
|
+
one a single fact with YAML-ish frontmatter:
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
name: some-slug
|
|
8
|
+
description: one-line summary
|
|
9
|
+
metadata:
|
|
10
|
+
type: user | feedback | project | reference
|
|
11
|
+
---
|
|
12
|
+
the fact itself
|
|
13
|
+
|
|
14
|
+
That store is local to one project, unencrypted, and invisible to every
|
|
15
|
+
other agent and host. This module moves those facts into the vault, where
|
|
16
|
+
they are encrypted at rest and shared across every agent that speaks to
|
|
17
|
+
compartment. Nothing is deleted: the source files are read, never modified, so
|
|
18
|
+
an import is always safe to re-run.
|
|
19
|
+
|
|
20
|
+
Re-running is a no-op by design - Vault.store's near-duplicate guard
|
|
21
|
+
recognises text it has already embedded and returns the existing record
|
|
22
|
+
instead of a second copy.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# Claude Code's per-project memory lives under <root>/<project-slug>/memory.
|
|
31
|
+
DEFAULT_ROOT = Path.home() / ".claude" / "projects"
|
|
32
|
+
INDEX_NAME = "MEMORY.md" # a table of contents, not a fact
|
|
33
|
+
|
|
34
|
+
# Frontmatter `type` -> Compartment importance. Mirrors the vault's own tiers:
|
|
35
|
+
# who the user is and what they have decided outranks reference material.
|
|
36
|
+
IMPORTANCE = {
|
|
37
|
+
"user": 0.85,
|
|
38
|
+
"feedback": 0.85,
|
|
39
|
+
"project": 0.75,
|
|
40
|
+
"reference": 0.60,
|
|
41
|
+
}
|
|
42
|
+
DEFAULT_IMPORTANCE = 0.70
|
|
43
|
+
|
|
44
|
+
_FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover(root: Path | str | None = None) -> list[Path]:
|
|
48
|
+
"""Every Claude Code memory file, sorted, index files excluded."""
|
|
49
|
+
root = Path(root) if root else DEFAULT_ROOT
|
|
50
|
+
if not root.exists():
|
|
51
|
+
return []
|
|
52
|
+
if root.name == "memory" and root.is_dir(): # a memory dir directly
|
|
53
|
+
dirs = [root]
|
|
54
|
+
else:
|
|
55
|
+
dirs = sorted(p for p in root.glob("*/memory") if p.is_dir())
|
|
56
|
+
if not dirs and (root / "memory").is_dir():
|
|
57
|
+
dirs = [root / "memory"]
|
|
58
|
+
out: list[Path] = []
|
|
59
|
+
for d in dirs:
|
|
60
|
+
out.extend(sorted(p for p in d.glob("*.md")
|
|
61
|
+
if p.name != INDEX_NAME and p.is_file()))
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_frontmatter(raw: str) -> tuple[dict, str]:
|
|
66
|
+
"""Return (fields, body). Hand-rolled: the frontmatter Compartment cares
|
|
67
|
+
about is flat scalars plus a one-level `metadata:` block, so pulling in
|
|
68
|
+
a YAML dependency (and its parser CVEs) would be a poor trade."""
|
|
69
|
+
m = _FRONTMATTER.match(raw)
|
|
70
|
+
if not m:
|
|
71
|
+
return {}, raw.strip()
|
|
72
|
+
fields: dict[str, str] = {}
|
|
73
|
+
section = None
|
|
74
|
+
for line in m.group(1).splitlines():
|
|
75
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
76
|
+
continue
|
|
77
|
+
indented = line[:1].isspace()
|
|
78
|
+
key, sep, val = line.strip().partition(":")
|
|
79
|
+
if not sep:
|
|
80
|
+
continue
|
|
81
|
+
key, val = key.strip(), val.strip().strip("'\"")
|
|
82
|
+
if not val: # a nested block opens, e.g. metadata:
|
|
83
|
+
section = key
|
|
84
|
+
continue
|
|
85
|
+
fields[f"{section}.{key}" if indented and section else key] = val
|
|
86
|
+
return fields, raw[m.end():].strip()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse(path: Path) -> dict:
|
|
90
|
+
"""One memory file -> the record Compartment should store."""
|
|
91
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
92
|
+
fields, body = _parse_frontmatter(raw)
|
|
93
|
+
name = fields.get("name") or path.stem
|
|
94
|
+
description = fields.get("description", "")
|
|
95
|
+
kind = (fields.get("metadata.type") or fields.get("type") or "").lower()
|
|
96
|
+
|
|
97
|
+
# The description is a standalone summary and the body is the fact; a
|
|
98
|
+
# record carrying both retrieves well on either phrasing. Skip the
|
|
99
|
+
# description when the body already opens with it.
|
|
100
|
+
text = body
|
|
101
|
+
if description and not body.lower().startswith(description.lower()[:40]):
|
|
102
|
+
text = f"{description}\n\n{body}".strip() if body else description
|
|
103
|
+
if not text.strip():
|
|
104
|
+
text = description or name
|
|
105
|
+
|
|
106
|
+
tags = ["claude-memory"]
|
|
107
|
+
if kind:
|
|
108
|
+
tags.append(kind)
|
|
109
|
+
if name and name not in tags:
|
|
110
|
+
tags.append(name)
|
|
111
|
+
return {
|
|
112
|
+
"text": text,
|
|
113
|
+
"tags": tags,
|
|
114
|
+
"importance": IMPORTANCE.get(kind, DEFAULT_IMPORTANCE),
|
|
115
|
+
"name": name,
|
|
116
|
+
"source": str(path),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def import_files(vault, files: list[Path], caller: str = "import-claude",
|
|
121
|
+
namespace: str | None = None, dry_run: bool = False) -> dict:
|
|
122
|
+
"""Store each parsed memory. Returns counts plus per-file outcomes.
|
|
123
|
+
|
|
124
|
+
Duplicates are not an error: re-importing an unchanged directory stores
|
|
125
|
+
nothing new, so this is safe to run on every `compartment integrate claude`.
|
|
126
|
+
"""
|
|
127
|
+
imported = duplicates = failed = 0
|
|
128
|
+
items, errors = [], []
|
|
129
|
+
for f in files:
|
|
130
|
+
try:
|
|
131
|
+
rec = parse(f)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
failed += 1
|
|
134
|
+
errors.append(f"{f.name}: {exc}")
|
|
135
|
+
continue
|
|
136
|
+
if dry_run:
|
|
137
|
+
imported += 1
|
|
138
|
+
items.append({"name": rec["name"], "importance": rec["importance"],
|
|
139
|
+
"tags": rec["tags"], "chars": len(rec["text"])})
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
out = vault.store(rec["text"], caller=caller, namespace=namespace,
|
|
143
|
+
tags=rec["tags"], importance=rec["importance"])
|
|
144
|
+
except Exception as exc: # noqa: BLE001
|
|
145
|
+
failed += 1
|
|
146
|
+
errors.append(f"{f.name}: {exc}")
|
|
147
|
+
continue
|
|
148
|
+
if out.get("duplicate"):
|
|
149
|
+
duplicates += 1
|
|
150
|
+
else:
|
|
151
|
+
imported += 1
|
|
152
|
+
items.append({"name": rec["name"], "id": out.get("id"),
|
|
153
|
+
"duplicate": bool(out.get("duplicate"))})
|
|
154
|
+
return {"scanned": len(files), "imported": imported,
|
|
155
|
+
"duplicates": duplicates, "failed": failed,
|
|
156
|
+
"dry_run": dry_run, "items": items, "errors": errors}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def pending(vault, root: Path | str | None = None) -> int:
|
|
160
|
+
"""How many Claude Code memory files are not in the vault yet.
|
|
161
|
+
|
|
162
|
+
Cheap enough for `compartment status` to call: it compares file count against
|
|
163
|
+
records already tagged `claude-memory`, no embedding involved.
|
|
164
|
+
"""
|
|
165
|
+
files = discover(root)
|
|
166
|
+
if not files:
|
|
167
|
+
return 0
|
|
168
|
+
try:
|
|
169
|
+
rows = vault.db.conn.execute(
|
|
170
|
+
"SELECT COUNT(*) AS n FROM records WHERE tags LIKE '%claude-memory%'"
|
|
171
|
+
).fetchone()
|
|
172
|
+
have = int(rows["n"] if hasattr(rows, "keys") else rows[0])
|
|
173
|
+
except Exception: # noqa: BLE001
|
|
174
|
+
return len(files)
|
|
175
|
+
return max(0, len(files) - have)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def default_root_exists() -> bool:
|
|
179
|
+
return DEFAULT_ROOT.exists() and bool(discover())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["DEFAULT_ROOT", "discover", "parse", "import_files", "pending",
|
|
183
|
+
"default_root_exists", "IMPORTANCE"]
|