dev-recall 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.
recall/config.py ADDED
@@ -0,0 +1,257 @@
1
+ """Recall configuration — load, save, and default settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from platformdirs import user_config_dir, user_data_dir
11
+
12
+ _APP_NAME = "dev-recall"
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Defaults
16
+ # ---------------------------------------------------------------------------
17
+
18
+ DEFAULT_CONFIG: dict[str, Any] = {
19
+ "data_dir": str(Path(user_data_dir(_APP_NAME))),
20
+ "config_dir": str(Path(user_config_dir(_APP_NAME))),
21
+ "daemon_port": 27182,
22
+ "embedding_model": "all-MiniLM-L6-v2",
23
+ "embedding_dim": 384,
24
+ "llm_model": "anthropic/claude-sonnet-4",
25
+ "retention_days": 90,
26
+ "capture": {
27
+ "terminal": True,
28
+ "git": True,
29
+ "vscode": True,
30
+ "ai_chat": True,
31
+ },
32
+ "privacy": {
33
+ "cmd_ignore_patterns": [
34
+ "*password*",
35
+ "*passwd*",
36
+ "*secret*",
37
+ "*token*",
38
+ "*apikey*",
39
+ "*api_key*",
40
+ "sudo *",
41
+ "*credential*",
42
+ ],
43
+ "file_ignore_patterns": [
44
+ "*.env",
45
+ "*.pem",
46
+ "*.key",
47
+ "*.p12",
48
+ ".env.*",
49
+ ],
50
+ "repo_ignore_patterns": [],
51
+ "ai_chat_max_chars": 200,
52
+ },
53
+ "summary": {
54
+ "auto_generate": True,
55
+ "time": "23:30",
56
+ },
57
+ "search": {
58
+ "default_top_k": 10,
59
+ "rrf_k": 60,
60
+ "session_idle_minutes": 30,
61
+ },
62
+ }
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Config class
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class Config:
71
+ """Loaded and saveable configuration."""
72
+
73
+ def __init__(self, data: dict[str, Any]) -> None:
74
+ self._data: dict[str, Any] = data
75
+
76
+ # ------------------------------------------------------------------
77
+ # Top-level properties
78
+ # ------------------------------------------------------------------
79
+
80
+ @property
81
+ def data_dir(self) -> Path:
82
+ return Path(self._data["data_dir"]).expanduser()
83
+
84
+ @property
85
+ def config_dir(self) -> Path:
86
+ return Path(self._data["config_dir"]).expanduser()
87
+
88
+ @property
89
+ def daemon_port(self) -> int:
90
+ return int(self._data.get("daemon_port", 27182))
91
+
92
+ @property
93
+ def embedding_model(self) -> str:
94
+ return str(self._data.get("embedding_model", "all-MiniLM-L6-v2"))
95
+
96
+ @property
97
+ def embedding_dim(self) -> int:
98
+ return int(self._data.get("embedding_dim", 384))
99
+
100
+ @property
101
+ def llm_model(self) -> str:
102
+ return str(self._data.get("llm_model", "anthropic/claude-sonnet-4"))
103
+
104
+ @property
105
+ def retention_days(self) -> int:
106
+ return int(self._data.get("retention_days", 90))
107
+
108
+ # Nested sections as plain dicts (read-only convenience)
109
+ @property
110
+ def capture(self) -> dict[str, Any]:
111
+ return dict(self._data.get("capture", {}))
112
+
113
+ @property
114
+ def privacy(self) -> dict[str, Any]:
115
+ return dict(self._data.get("privacy", {}))
116
+
117
+ @property
118
+ def summary(self) -> dict[str, Any]:
119
+ return dict(self._data.get("summary", {}))
120
+
121
+ @property
122
+ def search(self) -> dict[str, Any]:
123
+ return dict(self._data.get("search", {}))
124
+
125
+ # ------------------------------------------------------------------
126
+ # Derived paths
127
+ # ------------------------------------------------------------------
128
+
129
+ @property
130
+ def db_path(self) -> Path:
131
+ return self.data_dir / "events.db"
132
+
133
+ @property
134
+ def faiss_path(self) -> Path:
135
+ return self.data_dir / "vectors.faiss"
136
+
137
+ @property
138
+ def shell_tsv_path(self) -> Path:
139
+ return self.data_dir / "shell.tsv"
140
+
141
+ @property
142
+ def git_tsv_path(self) -> Path:
143
+ return self.data_dir / "git.tsv"
144
+
145
+ @property
146
+ def pid_path(self) -> Path:
147
+ return self.data_dir / "daemon.pid"
148
+
149
+ @property
150
+ def log_path(self) -> Path:
151
+ return self.data_dir / "daemon.log"
152
+
153
+ @property
154
+ def hook_zsh_path(self) -> Path:
155
+ return self.config_dir / "hook.zsh"
156
+
157
+ @property
158
+ def hook_bash_path(self) -> Path:
159
+ return self.config_dir / "hook.bash"
160
+
161
+ @property
162
+ def hook_fish_path(self) -> Path:
163
+ return self.config_dir / "hook.fish"
164
+
165
+ @property
166
+ def git_hooks_dir(self) -> Path:
167
+ return self.config_dir / "git-hooks"
168
+
169
+ # ------------------------------------------------------------------
170
+ # Get / set by dot-path key (e.g. "privacy.retention_days")
171
+ # ------------------------------------------------------------------
172
+
173
+ def get(self, key: str) -> Any:
174
+ parts = key.split(".")
175
+ node: Any = self._data
176
+ for part in parts:
177
+ if not isinstance(node, dict) or part not in node:
178
+ return None
179
+ node = node[part]
180
+ return node
181
+
182
+ def set(self, key: str, value: Any) -> None:
183
+ parts = key.split(".")
184
+ node = self._data
185
+ for part in parts[:-1]:
186
+ if part not in node or not isinstance(node[part], dict):
187
+ node[part] = {}
188
+ node = node[part]
189
+ # Attempt to coerce type to match existing value
190
+ leaf = parts[-1]
191
+ if leaf in node and node[leaf] is not None:
192
+ existing = node[leaf]
193
+ try:
194
+ if isinstance(existing, bool):
195
+ value = value.lower() in ("true", "1", "yes") if isinstance(value, str) else bool(value)
196
+ elif isinstance(existing, int):
197
+ value = int(value)
198
+ elif isinstance(existing, float):
199
+ value = float(value)
200
+ except (ValueError, AttributeError):
201
+ pass
202
+ node[leaf] = value
203
+
204
+ def as_dict(self) -> dict[str, Any]:
205
+ return dict(self._data)
206
+
207
+ def __repr__(self) -> str:
208
+ return f"Config(data_dir={self.data_dir}, config_dir={self.config_dir})"
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Load / save helpers
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def _config_file_path() -> Path:
217
+ """Return the path to the config JSON file, respecting DEV_RECALL_CONFIG env override."""
218
+ env_path = os.environ.get("DEV_RECALL_CONFIG")
219
+ if env_path:
220
+ return Path(env_path).expanduser()
221
+ return Path(user_config_dir(_APP_NAME)) / "config.json"
222
+
223
+
224
+ def load_config() -> Config:
225
+ """Load config from disk, merging with defaults for missing keys."""
226
+ path = _config_file_path()
227
+ if not path.exists():
228
+ return Config(_deep_merge({}, DEFAULT_CONFIG))
229
+
230
+ try:
231
+ with path.open("r", encoding="utf-8") as fh:
232
+ user_data = json.load(fh)
233
+ except (json.JSONDecodeError, OSError):
234
+ user_data = {}
235
+
236
+ merged = _deep_merge(user_data, DEFAULT_CONFIG)
237
+ return Config(merged)
238
+
239
+
240
+ def save_config(config: Config) -> None:
241
+ """Persist config to disk."""
242
+ path = _config_file_path()
243
+ path.parent.mkdir(parents=True, exist_ok=True)
244
+ with path.open("w", encoding="utf-8") as fh:
245
+ json.dump(config.as_dict(), fh, indent=2)
246
+ fh.write("\n")
247
+
248
+
249
+ def _deep_merge(user: dict, defaults: dict) -> dict:
250
+ """Return a new dict that is *defaults* overlaid with *user* values (deep)."""
251
+ result = dict(defaults)
252
+ for k, v in user.items():
253
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
254
+ result[k] = _deep_merge(v, result[k])
255
+ else:
256
+ result[k] = v
257
+ return result