siftcode 0.3.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.
siftcode/core/cache.py ADDED
@@ -0,0 +1,300 @@
1
+ """
2
+ Hash-based cache for Siftcode analysis results.
3
+
4
+ Caches LLM responses keyed by file content hash and model name.
5
+ This means:
6
+ - Unchanged files are never re-analyzed
7
+ - Switching models invalidates the cache (different model = different result)
8
+ - Cache entries never expire on their own — run `siftcode cache gc` to prune
9
+
10
+ No GTK dependencies.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ import time
19
+ from dataclasses import asdict, dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from platformdirs import user_cache_dir
24
+
25
+ from siftcode.core.config import APP_AUTHOR, APP_NAME
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Default location
33
+ # ---------------------------------------------------------------------------
34
+
35
+ CACHE_DIR = Path(user_cache_dir(APP_NAME, APP_AUTHOR))
36
+ FILES_CACHE_DIR = CACHE_DIR / "files"
37
+ DIRS_CACHE_DIR = CACHE_DIR / "dirs"
38
+ ROOT_CACHE_DIR = CACHE_DIR / "roots"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Hash helpers
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def file_hash(path: Path) -> str:
46
+ """
47
+ Compute a stable SHA-256 hash of a file's content.
48
+
49
+ Reads in 8 KB chunks so large files don't blow up memory.
50
+ Returns the first 16 hex characters — collision-free in practice.
51
+ """
52
+ h = hashlib.sha256()
53
+ with open(path, "rb") as f:
54
+ for chunk in iter(lambda: f.read(8192), b""):
55
+ h.update(chunk)
56
+ return h.hexdigest()[:16]
57
+
58
+
59
+ def string_hash(text: str) -> str:
60
+ """Hash a string (used for directory and root keys)."""
61
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Cache entry
66
+ # ---------------------------------------------------------------------------
67
+
68
+ @dataclass
69
+ class CacheEntry:
70
+ """A single cached analysis result."""
71
+
72
+ key: str # hash of the input (file content, dir listing, etc.)
73
+ path: str # project-relative path or label
74
+ model: str # model name — cache is model-specific
75
+ summary: str # the LLM's response
76
+ analyzed_at: str # ISO timestamp
77
+ last_used: str # ISO timestamp — updated on every hit
78
+
79
+ @property
80
+ def age_days(self) -> float:
81
+ """Days since this entry was last used."""
82
+ last = time.mktime(time.strptime(self.last_used[:19], "%Y-%m-%dT%H:%M:%S"))
83
+ return (time.time() - last) / 86400.0
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Cache
88
+ # ---------------------------------------------------------------------------
89
+
90
+ class AnalysisCache:
91
+ """
92
+ File-backed cache for LLM analysis results.
93
+
94
+ Layout:
95
+ ~/.cache/siftcode/files/<hash>.json
96
+ ~/.cache/siftcode/dirs/<hash>.json
97
+ ~/.cache/siftcode/roots/<hash>.json
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ base_dir: Path | None = None,
103
+ enabled: bool = True,
104
+ ) -> None:
105
+ self.base_dir = base_dir or CACHE_DIR
106
+ self.enabled = enabled
107
+
108
+ self.files_dir = self.base_dir / "files"
109
+ self.dirs_dir = self.base_dir / "dirs"
110
+ self.roots_dir = self.base_dir / "roots"
111
+
112
+ for d in (self.files_dir, self.dirs_dir, self.roots_dir):
113
+ d.mkdir(parents=True, exist_ok=True)
114
+
115
+ # ------------------------------------------------------------------
116
+ # Public API — file-level
117
+ # ------------------------------------------------------------------
118
+
119
+ def get_file(self, key: str, model: str) -> str | None:
120
+ """Return cached file summary, or None on miss."""
121
+ return self._get(self.files_dir, key, model)
122
+
123
+ def put_file(self, key: str, path: str, model: str, summary: str) -> None:
124
+ """Store a file summary in the cache."""
125
+ self._put(self.files_dir, key, path, model, summary)
126
+
127
+ # ------------------------------------------------------------------
128
+ # Public API — directory-level
129
+ # ------------------------------------------------------------------
130
+
131
+ def get_directory(self, key: str, model: str) -> str | None:
132
+ return self._get(self.dirs_dir, key, model)
133
+
134
+ def put_directory(self, key: str, path: str, model: str, summary: str) -> None:
135
+ self._put(self.dirs_dir, key, path, model, summary)
136
+
137
+ # ------------------------------------------------------------------
138
+ # Public API — root-level
139
+ # ------------------------------------------------------------------
140
+
141
+ def get_root(self, key: str, model: str) -> str | None:
142
+ return self._get(self.roots_dir, key, model)
143
+
144
+ def put_root(self, key: str, path: str, model: str, summary: str) -> None:
145
+ self._put(self.roots_dir, key, path, model, summary)
146
+
147
+ # ------------------------------------------------------------------
148
+ # Public API — stats / maintenance
149
+ # ------------------------------------------------------------------
150
+
151
+ def stats(self) -> dict[str, Any]:
152
+ """Return counts and total size per cache category."""
153
+ result: dict[str, Any] = {}
154
+ total_entries = 0
155
+ total_bytes = 0
156
+
157
+ for name, d in (
158
+ ("files", self.files_dir),
159
+ ("directories", self.dirs_dir),
160
+ ("roots", self.roots_dir),
161
+ ):
162
+ count = 0
163
+ size = 0
164
+ for f in d.glob("*.json"):
165
+ count += 1
166
+ size += f.stat().st_size
167
+ result[name] = {"entries": count, "bytes": size}
168
+ total_entries += count
169
+ total_bytes += size
170
+
171
+ result["total"] = {
172
+ "entries": total_entries,
173
+ "bytes": total_bytes,
174
+ "human_size": _human_size(total_bytes),
175
+ }
176
+ result["base_dir"] = str(self.base_dir)
177
+ return result
178
+
179
+ def clear(self, model: str | None = None) -> int:
180
+ """
181
+ Remove cache entries.
182
+
183
+ If model is None, clears everything. Otherwise only entries for
184
+ that model. Returns the number of entries removed.
185
+ """
186
+ removed = 0
187
+ for d in (self.files_dir, self.dirs_dir, self.roots_dir):
188
+ for f in list(d.glob("*.json")):
189
+ if model is None:
190
+ f.unlink()
191
+ removed += 1
192
+ else:
193
+ try:
194
+ data = json.loads(f.read_text())
195
+ if data.get("model") == model:
196
+ f.unlink()
197
+ removed += 1
198
+ except (OSError, json.JSONDecodeError):
199
+ # Corrupt file — remove it anyway
200
+ f.unlink()
201
+ removed += 1
202
+ return removed
203
+
204
+ def gc(self, max_age_days: float = 30.0) -> int:
205
+ """
206
+ Delete cache entries not used within max_age_days.
207
+
208
+ Returns the number of entries removed.
209
+ """
210
+ removed = 0
211
+ for d in (self.files_dir, self.dirs_dir, self.roots_dir):
212
+ for f in d.glob("*.json"):
213
+ try:
214
+ data = json.loads(f.read_text())
215
+ entry = CacheEntry(**data)
216
+ if entry.age_days > max_age_days:
217
+ f.unlink()
218
+ removed += 1
219
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
220
+ # Corrupt or missing fields — treat as garbage
221
+ f.unlink()
222
+ removed += 1
223
+ return removed
224
+
225
+ # ------------------------------------------------------------------
226
+ # Internals
227
+ # ------------------------------------------------------------------
228
+
229
+ def _get(self, directory: Path, key: str, model: str) -> str | None:
230
+ if not self.enabled:
231
+ return None
232
+
233
+ path = directory / f"{key}.json"
234
+ if not path.exists():
235
+ return None
236
+
237
+ try:
238
+ data = json.loads(path.read_text())
239
+ except (OSError, json.JSONDecodeError) as e:
240
+ logger.warning(f"Corrupt cache entry {path}: {e}")
241
+ return None
242
+
243
+ # Model must match
244
+ if data.get("model") != model:
245
+ return None
246
+
247
+ # Update last_used timestamp (fire-and-forget)
248
+ try:
249
+ data["last_used"] = _now_iso()
250
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
251
+ except OSError:
252
+ pass
253
+
254
+ return data.get("summary")
255
+
256
+ def _put(
257
+ self,
258
+ directory: Path,
259
+ key: str,
260
+ path_str: str,
261
+ model: str,
262
+ summary: str,
263
+ ) -> None:
264
+ if not self.enabled:
265
+ return
266
+
267
+ entry = CacheEntry(
268
+ key=key,
269
+ path=path_str,
270
+ model=model,
271
+ summary=summary,
272
+ analyzed_at=_now_iso(),
273
+ last_used=_now_iso(),
274
+ )
275
+
276
+ target = directory / f"{key}.json"
277
+ try:
278
+ target.write_text(
279
+ json.dumps(asdict(entry), indent=2, ensure_ascii=False),
280
+ encoding="utf-8",
281
+ )
282
+ except OSError as e:
283
+ logger.warning(f"Cannot write cache entry {target}: {e}")
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Helpers
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def _now_iso() -> str:
291
+ from datetime import datetime
292
+ return datetime.now().isoformat(timespec="seconds")
293
+
294
+
295
+ def _human_size(num_bytes: int) -> str:
296
+ for unit in ("B", "KB", "MB", "GB"):
297
+ if num_bytes < 1024:
298
+ return f"{num_bytes:.1f} {unit}"
299
+ num_bytes /= 1024
300
+ return f"{num_bytes:.1f} TB"
@@ -0,0 +1,258 @@
1
+ """
2
+ Configuration management for Siftcode.
3
+
4
+ Handles loading, saving and validating project configuration files.
5
+ Uses XDG base directory specification via platformdirs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import tomllib
12
+ from dataclasses import dataclass, field, asdict
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import tomli_w
17
+ from platformdirs import user_config_dir, user_cache_dir
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # XDG-compliant directories
22
+ # ---------------------------------------------------------------------------
23
+
24
+ APP_NAME = "siftcode"
25
+ APP_AUTHOR = "siftcode"
26
+
27
+ CONFIG_DIR = Path(user_config_dir(APP_NAME, APP_AUTHOR))
28
+ PROJECTS_DIR = CONFIG_DIR / "projects"
29
+ CACHE_DIR = Path(user_cache_dir(APP_NAME, APP_AUTHOR))
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Default exclusion list — used if user hasn't customized it
34
+ # ---------------------------------------------------------------------------
35
+
36
+ DEFAULT_EXCLUSIONS = [
37
+ # Version control / environment
38
+ ".git", ".venv", "venv", "env", "node_modules", "__pycache__",
39
+ # Version control metadata (not source code)
40
+ ".gitignore", ".gitattributes", ".gitmodules", ".gitkeep",
41
+ # Build artifacts
42
+ "build", "dist", ".eggs", ".tox", ".pytest_cache", ".mypy_cache",
43
+ ".ruff_cache", ".coverage", ".htmlcov", "htmlcov",
44
+ # Documentation
45
+ "docs", "doc", "documentation", "README.md~", "docs/specs/", "doc/specs/",
46
+ # Editor backup files
47
+ "*~", "*.bak", "*.swp", "*.swo",
48
+ # Binary / media files
49
+ ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp",
50
+ ".pdf", ".zip", ".tar", ".gz", ".whl", ".so", ".dll", ".exe",
51
+ ".ttf", ".otf", ".woff", ".woff2",
52
+ ".pyc", ".pyo",
53
+ # Lock files
54
+ ".lock",
55
+ # Coverage data
56
+ ".coverage", ".coverage.*",
57
+ # Editor / IDE configs (low architectural value)
58
+ ".editorconfig", ".pre-commit-config.yaml",
59
+ ".env", ".env.example", ".envrc",
60
+ # OS noise
61
+ ".DS_Store", "Thumbs.db",
62
+ # Common metadata files that are just lists
63
+ "MANIFEST.in", ".dockerignore", ".npmignore",
64
+ ]
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Dataclasses
69
+ # ---------------------------------------------------------------------------
70
+
71
+ @dataclass
72
+ class ModelConfig:
73
+ """LLM settings for a single analysis run."""
74
+
75
+ name: str = "qwen2.5-coder:14b"
76
+ ollama_url: str = "http://localhost:11434"
77
+ num_ctx: int = 32768 # ← 8192 → 16384 → 32768
78
+ temperature: float = 0.0
79
+ timeout: int = 1200
80
+ max_tokens_file: int = 1000
81
+ max_tokens_guide: int = 8000
82
+
83
+ def validate(self) -> None:
84
+ """Raise ValueError if any field is out of acceptable range."""
85
+ if self.num_ctx < 512 or self.num_ctx > 131072:
86
+ raise ValueError(
87
+ f"num_ctx must be between 512 and 131072, got {self.num_ctx}"
88
+ )
89
+ if not 0.0 <= self.temperature <= 2.0:
90
+ raise ValueError(
91
+ f"temperature must be between 0.0 and 2.0, got {self.temperature}"
92
+ )
93
+ if self.timeout < 1:
94
+ raise ValueError(f"timeout must be positive, got {self.timeout}")
95
+ if not self.ollama_url.startswith(("http://", "https://")):
96
+ raise ValueError(
97
+ f"ollama_url must start with http:// or https://, "
98
+ f"got {self.ollama_url}"
99
+ )
100
+
101
+
102
+ @dataclass
103
+ class AnalysisConfig:
104
+ """Settings controlling what gets analyzed and how."""
105
+
106
+ exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUSIONS))
107
+ priority_files: list[str] = field(default_factory=list)
108
+ use_cache: bool = True
109
+ max_file_chars: int = 30000
110
+
111
+ def validate(self) -> None:
112
+ if self.max_file_chars < 1000:
113
+ raise ValueError(
114
+ f"max_file_chars must be at least 1000, "
115
+ f"got {self.max_file_chars}"
116
+ )
117
+
118
+
119
+ @dataclass
120
+ class ProjectConfig:
121
+ """Complete configuration for a single project."""
122
+
123
+ name: str
124
+ path: Path
125
+ model: ModelConfig = field(default_factory=ModelConfig)
126
+ analysis: AnalysisConfig = field(default_factory=AnalysisConfig)
127
+ created: str = ""
128
+ last_run: str = ""
129
+ notes: str = ""
130
+
131
+ def validate(self) -> None:
132
+ """Validate project config and all nested configs."""
133
+ if not self.name:
134
+ raise ValueError("Project name cannot be empty")
135
+ if not self.path:
136
+ raise ValueError("Project path cannot be empty")
137
+ self.model.validate()
138
+ self.analysis.validate()
139
+
140
+ def to_dict(self) -> dict[str, Any]:
141
+ """Convert to a dictionary suitable for TOML serialization."""
142
+ data = asdict(self)
143
+ # Path → str for TOML
144
+ data["path"] = str(self.path)
145
+ return data
146
+
147
+ @classmethod
148
+ def from_dict(cls, data: dict[str, Any]) -> ProjectConfig:
149
+ """Build a ProjectConfig from a dictionary (loaded from TOML)."""
150
+ data = dict(data) # copy — do not mutate caller's data
151
+
152
+ model_data = data.pop("model", {})
153
+ analysis_data = data.pop("analysis", {})
154
+
155
+ return cls(
156
+ name=data["name"],
157
+ path=Path(data["path"]),
158
+ model=ModelConfig(**model_data),
159
+ analysis=AnalysisConfig(**analysis_data),
160
+ created=data.get("created", ""),
161
+ last_run=data.get("last_run", ""),
162
+ notes=data.get("notes", ""),
163
+ )
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Slug helper
168
+ # ---------------------------------------------------------------------------
169
+
170
+ def project_slug(name: str) -> str:
171
+ """
172
+ Convert a project name to a safe filename slug.
173
+
174
+ >>> project_slug("My Project")
175
+ 'my-project'
176
+ >>> project_slug("SHL Policy Editor v2")
177
+ 'shl-policy-editor-v2'
178
+ """
179
+ slug = name.lower().strip()
180
+ slug = re.sub(r"[^\w\s-]", "", slug)
181
+ slug = re.sub(r"[-\s]+", "-", slug)
182
+ return slug.strip("-") or "unnamed"
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Load / Save
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def project_path(name: str, projects_dir: Path | None = None) -> Path:
190
+ """Return the TOML file path for a given project name."""
191
+ base = projects_dir if projects_dir is not None else PROJECTS_DIR
192
+ return base / f"{project_slug(name)}.toml"
193
+
194
+
195
+ def save_project(
196
+ config: ProjectConfig,
197
+ projects_dir: Path | None = None,
198
+ ) -> Path:
199
+ """
200
+ Save a project configuration to disk.
201
+
202
+ Returns the path where it was written.
203
+ """
204
+ config.validate()
205
+ path = project_path(config.name, projects_dir)
206
+ path.parent.mkdir(parents=True, exist_ok=True)
207
+
208
+ with open(path, "wb") as f:
209
+ tomli_w.dump(config.to_dict(), f)
210
+
211
+ return path
212
+
213
+
214
+ def load_project(
215
+ name: str,
216
+ projects_dir: Path | None = None,
217
+ ) -> ProjectConfig:
218
+ """Load a project configuration by name."""
219
+ path = project_path(name, projects_dir)
220
+ if not path.exists():
221
+ raise FileNotFoundError(f"Project '{name}' not found at {path}")
222
+
223
+ with open(path, "rb") as f:
224
+ data = tomllib.load(f)
225
+
226
+ return ProjectConfig.from_dict(data)
227
+
228
+
229
+ def list_projects(projects_dir: Path | None = None) -> list[ProjectConfig]:
230
+ """List all saved projects, sorted by last run (newest first)."""
231
+ base = projects_dir if projects_dir is not None else PROJECTS_DIR
232
+ if not base.exists():
233
+ return []
234
+
235
+ projects: list[ProjectConfig] = []
236
+ for toml_file in sorted(base.glob("*.toml")):
237
+ try:
238
+ with open(toml_file, "rb") as f:
239
+ data = tomllib.load(f)
240
+ projects.append(ProjectConfig.from_dict(data))
241
+ except (OSError, tomllib.TOMLDecodeError, KeyError, ValueError):
242
+ # Skip malformed files silently — they don't break the listing
243
+ continue
244
+
245
+ projects.sort(key=lambda p: p.last_run or p.created or "", reverse=True)
246
+ return projects
247
+
248
+
249
+ def delete_project(
250
+ name: str,
251
+ projects_dir: Path | None = None,
252
+ ) -> bool:
253
+ """Delete a project configuration. Returns True if deleted."""
254
+ path = project_path(name, projects_dir)
255
+ if path.exists():
256
+ path.unlink()
257
+ return True
258
+ return False