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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
services/vector_store.py
ADDED
|
@@ -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)
|