code-context-control 2.28.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.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
@@ -0,0 +1,299 @@
1
+ """SLTM Vector Store with local fallback indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from services.ollama_client import OllamaClient
12
+ from services.text_index import TextIndex
13
+
14
+ SLTM_CATEGORIES = [
15
+ "design_docs",
16
+ "api_contracts",
17
+ "bug_history",
18
+ "terminal_summaries",
19
+ "code_notes",
20
+ "general",
21
+ ]
22
+
23
+
24
+ class VectorStore:
25
+ """Hybrid TF-IDF + vector search over categorized memory collections."""
26
+
27
+ def __init__(self, project_path: str, config: dict | None = None):
28
+ self.project_path = Path(project_path)
29
+ self.config = config or {}
30
+ self._lock = threading.Lock()
31
+
32
+ base_url = self.config.get("ollama_base_url", "http://localhost:11434")
33
+ self.ollama = OllamaClient(base_url)
34
+ self.embed_model = self.config.get("embed_model", "nomic-embed-text")
35
+ self.alpha = self.config.get("sltm_alpha", 0.5)
36
+
37
+ self._chroma_client = None
38
+ self._collections: dict[str, object] = {}
39
+ self._chroma_available = False
40
+ self._ollama_available = False
41
+ self._init_backends()
42
+
43
+ self._fallback_dir = self.project_path / ".c3" / "sltm" / "fallback"
44
+ self._fallback_dir.mkdir(parents=True, exist_ok=True)
45
+ self._fallback_data: dict[str, list[dict]] = {}
46
+ self._records_by_id: dict[str, dict] = {}
47
+ self._text_index = TextIndex()
48
+ self._load_fallback()
49
+
50
+ def _init_backends(self):
51
+ if self.config.get("disable_vector_backend"):
52
+ self._chroma_available = False
53
+ self._ollama_available = False
54
+ return
55
+ try:
56
+ import chromadb
57
+ from chromadb.config import Settings
58
+
59
+ persist_dir = str(self.project_path / ".c3" / "sltm" / "chromadb")
60
+ Path(persist_dir).mkdir(parents=True, exist_ok=True)
61
+ self._chroma_client = chromadb.PersistentClient(
62
+ path=persist_dir,
63
+ settings=Settings(anonymized_telemetry=False),
64
+ )
65
+ for cat in SLTM_CATEGORIES:
66
+ self._collections[cat] = self._chroma_client.get_or_create_collection(
67
+ name=cat,
68
+ metadata={"hnsw:space": "cosine"},
69
+ )
70
+ self._chroma_available = True
71
+ except Exception:
72
+ self._chroma_available = False
73
+
74
+ self._ollama_available = self.ollama.is_available(timeout=2) and self.ollama.has_model(self.embed_model)
75
+
76
+ @property
77
+ def vector_enabled(self) -> bool:
78
+ return self._chroma_available and self._ollama_available
79
+
80
+ def add(
81
+ self,
82
+ text: str,
83
+ category: str = "general",
84
+ metadata: dict | None = None,
85
+ record_id: str | None = None,
86
+ ) -> dict:
87
+ """Store a record in the SLTM using a stable record id when provided."""
88
+ if category not in SLTM_CATEGORIES:
89
+ category = "general"
90
+
91
+ record_id = record_id or uuid.uuid4().hex[:12]
92
+ now = datetime.now(timezone.utc).isoformat()
93
+ meta = dict(metadata or {})
94
+ meta.update({"timestamp": now, "category": category})
95
+ record = {
96
+ "id": record_id,
97
+ "text": text,
98
+ "metadata": meta,
99
+ "timestamp": now,
100
+ "category": category,
101
+ }
102
+
103
+ with self._lock:
104
+ self._delete_locked(record_id)
105
+ self._fallback_data.setdefault(category, []).append(record)
106
+ self._save_fallback_category(category)
107
+ self._records_by_id[record_id] = record
108
+ self._text_index.add_or_update(record_id, self._searchable_text(record))
109
+
110
+ if self.vector_enabled:
111
+ try:
112
+ embedding = self.ollama.embed(text, model=self.embed_model)
113
+ if embedding:
114
+ flat_meta = {
115
+ key: value if isinstance(value, (int, float, bool)) else str(value)
116
+ for key, value in meta.items()
117
+ }
118
+ self._collections[category].add(
119
+ ids=[record_id],
120
+ embeddings=[embedding],
121
+ documents=[text],
122
+ metadatas=[flat_meta],
123
+ )
124
+ except Exception:
125
+ pass
126
+
127
+ return {
128
+ "stored": True,
129
+ "id": record_id,
130
+ "category": category,
131
+ "vector_indexed": self.vector_enabled,
132
+ "total_records": len(self._records_by_id),
133
+ }
134
+
135
+ def search(self, query: str, category: str = "", top_k: int = 5) -> list[dict]:
136
+ categories = [category] if category and category in SLTM_CATEGORIES else list(SLTM_CATEGORIES)
137
+ allowed = set(categories)
138
+
139
+ docs = {
140
+ doc_id: record
141
+ for doc_id, record in self._records_by_id.items()
142
+ if record.get("category") in allowed
143
+ }
144
+ if not docs:
145
+ return []
146
+
147
+ tfidf_scores = {
148
+ doc_id: score
149
+ for doc_id, score in self._text_index.search(query, top_k=max(top_k * 5, 20))
150
+ if doc_id in docs
151
+ }
152
+
153
+ vector_scores: dict[str, float] = {}
154
+ if self.vector_enabled:
155
+ try:
156
+ query_embedding = self.ollama.embed(query, model=self.embed_model)
157
+ if query_embedding:
158
+ for cat in categories:
159
+ col = self._collections.get(cat)
160
+ if not col or col.count() == 0:
161
+ continue
162
+ results = col.query(
163
+ query_embeddings=[query_embedding],
164
+ n_results=min(max(top_k * 3, top_k), col.count()),
165
+ )
166
+ ids = (results or {}).get("ids") or []
167
+ distances = (results or {}).get("distances") or []
168
+ for i, rid in enumerate(ids[0] if ids else []):
169
+ dist = distances[0][i] if distances and distances[0] else 0
170
+ vector_scores[rid] = max(vector_scores.get(rid, 0.0), max(0.0, 1.0 - dist))
171
+ except Exception:
172
+ pass
173
+
174
+ candidate_ids = set(tfidf_scores) | set(vector_scores)
175
+ if not candidate_ids:
176
+ return []
177
+
178
+ max_tfidf = max(tfidf_scores.values()) if tfidf_scores else 1.0
179
+ max_tfidf = max(max_tfidf, 0.001)
180
+ min_score = float(self.config.get("sltm_min_score", 0.3))
181
+ combined = []
182
+ for doc_id in candidate_ids:
183
+ if doc_id not in docs:
184
+ continue
185
+ tfidf_score = tfidf_scores.get(doc_id, 0.0) / max_tfidf
186
+ vector_score = vector_scores.get(doc_id, 0.0)
187
+ score = self.alpha * tfidf_score + (1 - self.alpha) * vector_score if vector_scores else tfidf_score
188
+ if score >= min_score:
189
+ combined.append((doc_id, score))
190
+ combined.sort(key=lambda item: item[1], reverse=True)
191
+
192
+ results = []
193
+ for doc_id, score in combined[:top_k]:
194
+ record = docs[doc_id]
195
+ results.append({
196
+ "id": doc_id,
197
+ "text": record.get("text", ""),
198
+ "category": record.get("category", "general"),
199
+ "score": round(score, 4),
200
+ "metadata": record.get("metadata", {}),
201
+ "timestamp": record.get("timestamp", ""),
202
+ "search_method": "hybrid" if vector_scores else "tfidf",
203
+ })
204
+ return results
205
+
206
+ def delete(self, record_id: str) -> dict:
207
+ deleted = False
208
+ with self._lock:
209
+ deleted = self._delete_locked(record_id)
210
+ return {"deleted": deleted, "id": record_id}
211
+
212
+ def get_stats(self) -> dict:
213
+ stats = {
214
+ "vector_enabled": self.vector_enabled,
215
+ "chromadb_available": self._chroma_available,
216
+ "ollama_available": self._ollama_available,
217
+ "embed_model": self.embed_model,
218
+ "alpha": self.alpha,
219
+ "collections": {},
220
+ "total_records": len(self._records_by_id),
221
+ }
222
+ for cat in SLTM_CATEGORIES:
223
+ fallback_count = len(self._fallback_data.get(cat, []))
224
+ chroma_count = 0
225
+ if self._chroma_available and cat in self._collections:
226
+ try:
227
+ chroma_count = self._collections[cat].count()
228
+ except Exception:
229
+ pass
230
+ stats["collections"][cat] = {
231
+ "fallback_count": fallback_count,
232
+ "chroma_count": chroma_count,
233
+ }
234
+ return stats
235
+
236
+ def _delete_locked(self, record_id: str) -> bool:
237
+ deleted = False
238
+ existing = self._records_by_id.pop(record_id, None)
239
+ if existing:
240
+ self._text_index.remove(record_id)
241
+ for cat in SLTM_CATEGORIES:
242
+ records = self._fallback_data.get(cat, [])
243
+ kept = [record for record in records if record.get("id") != record_id]
244
+ if len(kept) != len(records):
245
+ self._fallback_data[cat] = kept
246
+ self._save_fallback_category(cat)
247
+ deleted = True
248
+ if self._chroma_available and cat in self._collections:
249
+ try:
250
+ self._collections[cat].delete(ids=[record_id])
251
+ except Exception:
252
+ pass
253
+ return deleted or existing is not None
254
+
255
+ def _searchable_text(self, record: dict) -> str:
256
+ metadata = record.get("metadata", {})
257
+ fields = [
258
+ record.get("text", ""),
259
+ record.get("category", ""),
260
+ metadata.get("source", ""),
261
+ metadata.get("fact_id", ""),
262
+ metadata.get("source_session", ""),
263
+ ]
264
+ return " ".join(str(field) for field in fields if field)
265
+
266
+ def _load_fallback(self):
267
+ docs = {}
268
+ self._records_by_id = {}
269
+ for cat in SLTM_CATEGORIES:
270
+ path = self._fallback_dir / f"{cat}.json"
271
+ if path.exists():
272
+ try:
273
+ with open(path, encoding="utf-8") as handle:
274
+ records = json.load(handle)
275
+ except Exception:
276
+ records = []
277
+ else:
278
+ records = []
279
+ normalized = []
280
+ for record in records:
281
+ record = {
282
+ "id": record.get("id"),
283
+ "text": record.get("text", ""),
284
+ "metadata": dict(record.get("metadata", {})),
285
+ "timestamp": record.get("timestamp", ""),
286
+ "category": record.get("category", cat),
287
+ }
288
+ if not record["id"]:
289
+ continue
290
+ normalized.append(record)
291
+ self._records_by_id[record["id"]] = record
292
+ docs[record["id"]] = self._searchable_text(record)
293
+ self._fallback_data[cat] = normalized
294
+ self._text_index.rebuild(docs)
295
+
296
+ def _save_fallback_category(self, category: str):
297
+ path = self._fallback_dir / f"{category}.json"
298
+ with open(path, "w", encoding="utf-8") as handle:
299
+ json.dump(self._fallback_data.get(category, []), handle, indent=2)
@@ -0,0 +1,271 @@
1
+ """VersionTracker - Git-aware version tracking for key project files."""
2
+ import hashlib
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from core.ide import get_profile
10
+
11
+
12
+ class VersionTracker:
13
+ """Track key file versions with Git metadata when available."""
14
+
15
+ DEFAULT_MAX_KEY_FILES = 10
16
+ HISTORY_LIMIT = 12
17
+
18
+ def __init__(self, project_path: str, ide_name: str = "claude-code"):
19
+ self.project_path = Path(project_path).resolve()
20
+ self.ide_name = ide_name
21
+ self.store_path = self.project_path / ".c3" / "version_tracker.json"
22
+ self.store_path.parent.mkdir(parents=True, exist_ok=True)
23
+ self._git_root = self._detect_git_root()
24
+ self.state = self._load_state()
25
+
26
+ def scan(self, agent: str = "current", max_files: int | None = None) -> dict:
27
+ target = self.ide_name if agent in ("", "current", None) else str(agent)
28
+ key_files = self.discover_key_files(agent=target, max_files=max_files)
29
+ tracked = {}
30
+ changed = []
31
+ now = datetime.now(timezone.utc).isoformat()
32
+
33
+ for item in key_files:
34
+ rel_path = item["file"]
35
+ record = self._build_record(rel_path, item.get("reason", ""))
36
+ tracked[rel_path] = record
37
+ prev = (self.state.get("files") or {}).get(rel_path)
38
+ if prev and self._record_signature(prev) != self._record_signature(record):
39
+ change = {
40
+ "file": rel_path,
41
+ "reason": record.get("reason", ""),
42
+ "exists": record.get("exists", False),
43
+ "git": record.get("git", {}),
44
+ "previous_hash": prev.get("sha256", ""),
45
+ "current_hash": record.get("sha256", ""),
46
+ }
47
+ record["history"] = ([change] + prev.get("history", []))[:self.HISTORY_LIMIT]
48
+ changed.append(change)
49
+ elif prev:
50
+ record["history"] = prev.get("history", [])[:self.HISTORY_LIMIT]
51
+ else:
52
+ record["history"] = []
53
+
54
+ self.state = {
55
+ "updated_at": now,
56
+ "ide_name": self.ide_name,
57
+ "agent": target,
58
+ "git_root": str(self._git_root) if self._git_root else "",
59
+ "files": tracked,
60
+ }
61
+ self._save_state()
62
+ return {
63
+ "agent": target,
64
+ "updated_at": now,
65
+ "git_available": bool(self._git_root),
66
+ "files": list(tracked.values()),
67
+ "changed": changed,
68
+ }
69
+
70
+ def get_status(self, agent: str = "current", changed_only: bool = False, max_files: int | None = None) -> dict:
71
+ result = self.scan(agent=agent, max_files=max_files)
72
+ if changed_only:
73
+ changed_paths = {item["file"] for item in result["changed"]}
74
+ result["files"] = [item for item in result["files"] if item["file"] in changed_paths]
75
+ return result
76
+
77
+ def discover_key_files(self, agent: str = "current", max_files: int | None = None) -> list[dict]:
78
+ profile = get_profile(agent if agent not in ("", "current", None) else self.ide_name)
79
+ seen = set()
80
+ files: list[dict] = []
81
+
82
+ def add(rel_path: str, reason: str) -> None:
83
+ rel = str(Path(rel_path)).replace("\\", "/")
84
+ if rel.startswith("./"):
85
+ rel = rel[2:]
86
+ if not rel or rel in seen:
87
+ return
88
+ files.append({"file": rel, "reason": reason})
89
+ seen.add(rel)
90
+
91
+ add(".c3/config.json", "C3 project configuration")
92
+ if profile.instructions_file:
93
+ add(profile.instructions_file, f"{profile.display_name} instructions")
94
+ if profile.config_path and not profile.config_path_global:
95
+ add(profile.config_path, f"{profile.display_name} MCP config")
96
+ if profile.settings_path:
97
+ add(profile.settings_path, f"{profile.display_name} settings")
98
+
99
+ for rel_path, reason in self._hot_files():
100
+ add(rel_path, reason)
101
+
102
+ conventional = [
103
+ ("README.md", "Project overview"),
104
+ ("cli/c3.py", "CLI entry point"),
105
+ ("cli/mcp_server.py", "MCP server entry"),
106
+ ("services/agents.py", "Background agent logic"),
107
+ ("core/config.py", "Shared defaults"),
108
+ ]
109
+ for rel_path, reason in conventional:
110
+ if (self.project_path / rel_path).exists():
111
+ add(rel_path, reason)
112
+
113
+ limit = max(1, int(max_files or self.DEFAULT_MAX_KEY_FILES))
114
+ return files[:limit]
115
+
116
+ def _hot_files(self) -> list[tuple[str, str]]:
117
+ session_dir = self.project_path / ".c3" / "sessions"
118
+ if not session_dir.exists():
119
+ return []
120
+ counts: dict[str, int] = {}
121
+ for sf in sorted(session_dir.glob("session_*.json"), reverse=True)[:20]:
122
+ try:
123
+ with open(sf, encoding="utf-8") as f:
124
+ session = json.load(f)
125
+ for ft in session.get("files_touched", []):
126
+ rel_path = str(ft.get("file", "") or "").replace("\\", "/")
127
+ if rel_path:
128
+ counts[rel_path] = counts.get(rel_path, 0) + 1
129
+ except Exception:
130
+ continue
131
+ ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
132
+ return [(path, f"edited in {count} sessions") for path, count in ranked if count >= 2][:5]
133
+
134
+ def _build_record(self, rel_path: str, reason: str) -> dict:
135
+ path = self.project_path / rel_path
136
+ exists = path.exists()
137
+ size = path.stat().st_size if exists else 0
138
+ mtime = path.stat().st_mtime if exists else 0
139
+ return {
140
+ "file": rel_path,
141
+ "reason": reason,
142
+ "exists": exists,
143
+ "size": size,
144
+ "mtime": mtime,
145
+ "sha256": self._sha256(path) if exists and path.is_file() else "",
146
+ "git": self._git_info(rel_path),
147
+ }
148
+
149
+ def _record_signature(self, record: dict) -> str:
150
+ git = record.get("git", {}) or {}
151
+ return "|".join([
152
+ str(int(bool(record.get("exists")))),
153
+ str(record.get("size", 0)),
154
+ str(record.get("mtime", 0)),
155
+ record.get("sha256", ""),
156
+ git.get("commit", ""),
157
+ str(int(bool(git.get("dirty")))),
158
+ ])
159
+
160
+ def _sha256(self, path: Path) -> str:
161
+ try:
162
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
163
+ except Exception:
164
+ return ""
165
+
166
+ def _detect_git_root(self) -> Path | None:
167
+ try:
168
+ result = subprocess.run(
169
+ ["git", "rev-parse", "--show-toplevel"],
170
+ cwd=self.project_path,
171
+ capture_output=True,
172
+ text=True,
173
+ timeout=3,
174
+ check=True,
175
+ )
176
+ root = (result.stdout or "").strip()
177
+ return Path(root).resolve() if root else None
178
+ except Exception:
179
+ return None
180
+
181
+ def _git_info(self, rel_path: str) -> dict:
182
+ info = {
183
+ "available": bool(self._git_root),
184
+ "tracked": False,
185
+ "dirty": False,
186
+ "commit": "",
187
+ "author": "",
188
+ "timestamp": 0,
189
+ "subject": "",
190
+ }
191
+ if not self._git_root:
192
+ return info
193
+ abs_path = (self.project_path / rel_path).resolve()
194
+ try:
195
+ git_rel = abs_path.relative_to(self._git_root)
196
+ except Exception:
197
+ return info
198
+ try:
199
+ kwargs = {}
200
+ if sys.platform == "win32":
201
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
202
+ subprocess.run(
203
+ ["git", "ls-files", "--error-unmatch", str(git_rel)],
204
+ cwd=self._git_root,
205
+ capture_output=True,
206
+ text=True,
207
+ timeout=3,
208
+ check=True,
209
+ **kwargs
210
+ )
211
+ info["tracked"] = True
212
+ except Exception:
213
+ return info
214
+ try:
215
+ kwargs = {}
216
+ if sys.platform == "win32":
217
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
218
+ status = subprocess.run(
219
+ ["git", "status", "--porcelain", "--", str(git_rel)],
220
+ cwd=self._git_root,
221
+ capture_output=True,
222
+ text=True,
223
+ timeout=3,
224
+ check=True,
225
+ **kwargs
226
+ )
227
+ info["dirty"] = bool((status.stdout or "").strip())
228
+ except Exception:
229
+ pass
230
+ try:
231
+ kwargs = {}
232
+ if sys.platform == "win32":
233
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
234
+ log = subprocess.run(
235
+ ["git", "log", "-1", "--format=%H%x1f%ct%x1f%an%x1f%s", "--", str(git_rel)],
236
+ cwd=self._git_root,
237
+ capture_output=True,
238
+ text=True,
239
+ timeout=3,
240
+ check=True,
241
+ **kwargs
242
+ )
243
+ parts = (log.stdout or "").strip().split("\x1f")
244
+ if len(parts) == 4:
245
+ info["commit"] = parts[0]
246
+ try:
247
+ info["timestamp"] = int(parts[1])
248
+ except Exception:
249
+ info["timestamp"] = 0
250
+ info["author"] = parts[2]
251
+ info["subject"] = parts[3]
252
+ except Exception:
253
+ pass
254
+ return info
255
+
256
+ def _load_state(self) -> dict:
257
+ if not self.store_path.exists():
258
+ return {"files": {}}
259
+ try:
260
+ with open(self.store_path, encoding="utf-8") as f:
261
+ data = json.load(f)
262
+ if isinstance(data, dict):
263
+ data.setdefault("files", {})
264
+ return data
265
+ except Exception:
266
+ pass
267
+ return {"files": {}}
268
+
269
+ def _save_state(self) -> None:
270
+ with open(self.store_path, "w", encoding="utf-8") as f:
271
+ json.dump(self.state, f, indent=2)