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.
@@ -0,0 +1,7 @@
1
+ """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
+
3
+ __version__ = "1.15.0"
4
+
5
+ from . import offline_guard as _og
6
+
7
+ _og.activate_from_env()
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"]