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/agents.py
ADDED
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
"""Background Agents — Concrete agent implementations + factory.
|
|
2
|
+
|
|
3
|
+
Base class lives in services/agent_base.py.
|
|
4
|
+
"""
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from services.agent_base import BackgroundAgent # noqa: F401 — re-exported for consumers
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IndexStalenessAgent(BackgroundAgent):
|
|
17
|
+
"""Monitors file changes and triggers index rebuild when threshold is reached."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, watcher, indexer, notifications, enabled=True, interval=60,
|
|
20
|
+
warn_threshold=5, rebuild_threshold=15, **kwargs):
|
|
21
|
+
super().__init__("IndexStaleness", interval, notifications, enabled, **kwargs)
|
|
22
|
+
self.watcher = watcher
|
|
23
|
+
self.indexer = indexer
|
|
24
|
+
self.warn_threshold = warn_threshold
|
|
25
|
+
self.rebuild_threshold = rebuild_threshold
|
|
26
|
+
self._last_warned_count = 0
|
|
27
|
+
|
|
28
|
+
def check(self):
|
|
29
|
+
count = self.watcher._handler.change_count
|
|
30
|
+
if count >= self.rebuild_threshold:
|
|
31
|
+
# Capture changed file paths before rebuild resets the list
|
|
32
|
+
changed_files = []
|
|
33
|
+
try:
|
|
34
|
+
changes = self.watcher._handler._changes
|
|
35
|
+
changed_files = [c.get("path", "") for c in changes if isinstance(c, dict)]
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
self.watcher.rebuild_if_needed(self.indexer, threshold=self.rebuild_threshold)
|
|
40
|
+
msg = f"Rebuilt after {count} file changes"
|
|
41
|
+
used_ai = False
|
|
42
|
+
|
|
43
|
+
# AI: summarize what areas changed
|
|
44
|
+
if self.ai_available and changed_files:
|
|
45
|
+
# Group by directory for better summaries
|
|
46
|
+
dirs = Counter(str(Path(f).parent) for f in changed_files if f)
|
|
47
|
+
top_dirs = ", ".join(f"{d} ({c})" for d, c in dirs.most_common(5))
|
|
48
|
+
summary = self._ai_generate(
|
|
49
|
+
f"These files changed in a codebase:\n{chr(10).join(changed_files[:20])}\n"
|
|
50
|
+
f"Top directories: {top_dirs}\n\n"
|
|
51
|
+
"Summarize in ONE sentence what areas/components were affected.",
|
|
52
|
+
system="You are a concise code analyst. Reply in one sentence only.",
|
|
53
|
+
max_tokens=80,
|
|
54
|
+
)
|
|
55
|
+
if summary:
|
|
56
|
+
msg += f" — {summary.strip()}"
|
|
57
|
+
used_ai = True
|
|
58
|
+
|
|
59
|
+
self.notify("info", "Index auto-rebuilt", msg, ai_enhanced=used_ai, replace_if_unacked=True)
|
|
60
|
+
self._last_warned_count = 0
|
|
61
|
+
elif count >= self.warn_threshold and count != self._last_warned_count:
|
|
62
|
+
self._last_warned_count = count
|
|
63
|
+
self.notify("warning", "Index is stale", f"{count} file changes since last rebuild",
|
|
64
|
+
replace_if_unacked=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MemoryPrunerAgent(BackgroundAgent):
|
|
68
|
+
"""Finds duplicate and unused facts in the memory store."""
|
|
69
|
+
|
|
70
|
+
_CONSOLIDATE_EVERY_N = 10 # every ~50 min at default 300s interval
|
|
71
|
+
|
|
72
|
+
def __init__(self, memory, notifications, enabled=True, interval=300,
|
|
73
|
+
similarity_threshold=0.8, embed_model="nomic-embed-text", **kwargs):
|
|
74
|
+
super().__init__("MemoryPruner", interval, notifications, enabled, **kwargs)
|
|
75
|
+
self.memory = memory
|
|
76
|
+
self.similarity_threshold = similarity_threshold
|
|
77
|
+
self.embed_model = embed_model
|
|
78
|
+
self._embedding_cache = {} # fact_id -> vector
|
|
79
|
+
self._last_fact_count = 0
|
|
80
|
+
self._cycle_count = 0
|
|
81
|
+
|
|
82
|
+
def check(self):
|
|
83
|
+
self._cycle_count += 1
|
|
84
|
+
if self._cycle_count % self._CONSOLIDATE_EVERY_N == 0:
|
|
85
|
+
self._auto_consolidate()
|
|
86
|
+
|
|
87
|
+
facts = self.memory.facts
|
|
88
|
+
# Skip duplicate/unused checks if fact count hasn't changed
|
|
89
|
+
if len(facts) == self._last_fact_count:
|
|
90
|
+
return
|
|
91
|
+
self._last_fact_count = len(facts)
|
|
92
|
+
|
|
93
|
+
if len(facts) < 2:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
# Try AI-powered duplicate detection, fall back to Jaccard
|
|
97
|
+
if self.ai_available:
|
|
98
|
+
duplicates = self._find_duplicates_embedding(facts)
|
|
99
|
+
else:
|
|
100
|
+
duplicates = self._find_duplicates_jaccard(facts)
|
|
101
|
+
|
|
102
|
+
# Find unused facts (relevance_count == 0, only if enough facts exist)
|
|
103
|
+
unused = []
|
|
104
|
+
if len(facts) > 10:
|
|
105
|
+
unused = [f for f in facts if f.get("relevance_count", 0) == 0]
|
|
106
|
+
|
|
107
|
+
if duplicates:
|
|
108
|
+
# Auto-delete near-identical duplicates (sim >= 0.95) — keep higher relevance_count
|
|
109
|
+
auto_deleted = 0
|
|
110
|
+
remaining_duplicates = []
|
|
111
|
+
for a_id, b_id, sim in duplicates:
|
|
112
|
+
if sim >= 0.95:
|
|
113
|
+
a_fact = next((f for f in facts if f["id"] == a_id), None)
|
|
114
|
+
b_fact = next((f for f in facts if f["id"] == b_id), None)
|
|
115
|
+
if a_fact and b_fact:
|
|
116
|
+
victim = b_fact if a_fact.get("relevance_count", 0) >= b_fact.get("relevance_count", 0) else a_fact
|
|
117
|
+
try:
|
|
118
|
+
self.memory.delete_fact(victim["id"])
|
|
119
|
+
auto_deleted += 1
|
|
120
|
+
except Exception:
|
|
121
|
+
remaining_duplicates.append((a_id, b_id, sim))
|
|
122
|
+
else:
|
|
123
|
+
remaining_duplicates.append((a_id, b_id, sim))
|
|
124
|
+
|
|
125
|
+
if auto_deleted:
|
|
126
|
+
self.notify("info", "Auto-removed near-identical facts",
|
|
127
|
+
f"Deleted {auto_deleted} fact(s) with similarity ≥ 0.95")
|
|
128
|
+
|
|
129
|
+
if remaining_duplicates:
|
|
130
|
+
pairs_str = "; ".join(f"{a}≈{b} ({sim})" for a, b, sim in remaining_duplicates[:3])
|
|
131
|
+
used_ai = False
|
|
132
|
+
|
|
133
|
+
# AI: propose merged text for top duplicate pair
|
|
134
|
+
if self.ai_available and remaining_duplicates:
|
|
135
|
+
a_id, b_id, _ = remaining_duplicates[0]
|
|
136
|
+
a_text = next((f["fact"] for f in facts if f["id"] == a_id), "")
|
|
137
|
+
b_text = next((f["fact"] for f in facts if f["id"] == b_id), "")
|
|
138
|
+
merged = self._ai_generate(
|
|
139
|
+
f"These two facts are duplicates. Merge them into one concise fact:\n"
|
|
140
|
+
f"1: {a_text}\n2: {b_text}\n\nMerged fact:",
|
|
141
|
+
system="You merge duplicate knowledge base entries. Output only the merged text.",
|
|
142
|
+
max_tokens=120,
|
|
143
|
+
)
|
|
144
|
+
if merged:
|
|
145
|
+
pairs_str += f"\n\nSuggested merge: {merged.strip()}"
|
|
146
|
+
used_ai = True
|
|
147
|
+
|
|
148
|
+
self.notify("info", "Duplicate facts found",
|
|
149
|
+
f"{len(remaining_duplicates)} similar pair(s): {pairs_str}", ai_enhanced=used_ai)
|
|
150
|
+
|
|
151
|
+
if unused:
|
|
152
|
+
unused_preview = "; ".join(f["fact"][:40] for f in unused[:3])
|
|
153
|
+
self.notify("info", "Unused facts detected",
|
|
154
|
+
f"{len(unused)} facts with 0 relevance — e.g. {unused_preview}")
|
|
155
|
+
|
|
156
|
+
def _auto_consolidate(self):
|
|
157
|
+
"""Run lightweight cleanup: session rolling window + verbose orphan archive."""
|
|
158
|
+
from datetime import datetime, timezone
|
|
159
|
+
_MAX_SESSION_FACTS = 5
|
|
160
|
+
_VERBOSE_CHARS = 600
|
|
161
|
+
_VERBOSE_AGE_DAYS = 14
|
|
162
|
+
_SESSION_AGE_DAYS = 7
|
|
163
|
+
|
|
164
|
+
facts = self.memory.facts
|
|
165
|
+
active = [f for f in facts if f.get("lifecycle", "active") == "active"]
|
|
166
|
+
to_delete = set()
|
|
167
|
+
now = datetime.now(timezone.utc)
|
|
168
|
+
|
|
169
|
+
# Rolling window: keep only last N auto:session entries
|
|
170
|
+
session_facts = sorted(
|
|
171
|
+
[f for f in active if f.get("category") == "auto:session"],
|
|
172
|
+
key=lambda f: f.get("timestamp", ""),
|
|
173
|
+
reverse=True,
|
|
174
|
+
)
|
|
175
|
+
for f in session_facts[_MAX_SESSION_FACTS:]:
|
|
176
|
+
to_delete.add(f["id"])
|
|
177
|
+
|
|
178
|
+
# Stale auto:session with 0 recall after N days (catch any not pruned by rolling window)
|
|
179
|
+
for f in active:
|
|
180
|
+
if f["id"] in to_delete or f.get("category") != "auto:session":
|
|
181
|
+
continue
|
|
182
|
+
if f.get("relevance_count", 0) > 0:
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
age = (now - datetime.fromisoformat(f.get("timestamp", ""))).days
|
|
186
|
+
except (ValueError, TypeError):
|
|
187
|
+
age = 0
|
|
188
|
+
if age >= _SESSION_AGE_DAYS:
|
|
189
|
+
to_delete.add(f["id"])
|
|
190
|
+
|
|
191
|
+
# Verbose orphans: >600 chars, 0 recall, 14+ days
|
|
192
|
+
for f in active:
|
|
193
|
+
if f["id"] in to_delete:
|
|
194
|
+
continue
|
|
195
|
+
if len(f.get("fact", "")) <= _VERBOSE_CHARS:
|
|
196
|
+
continue
|
|
197
|
+
if f.get("relevance_count", 0) > 0:
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
age = (now - datetime.fromisoformat(f.get("timestamp", ""))).days
|
|
201
|
+
except (ValueError, TypeError):
|
|
202
|
+
age = 0
|
|
203
|
+
if age >= _VERBOSE_AGE_DAYS:
|
|
204
|
+
to_delete.add(f["id"])
|
|
205
|
+
|
|
206
|
+
removed = 0
|
|
207
|
+
for fid in to_delete:
|
|
208
|
+
try:
|
|
209
|
+
self.memory.delete_fact(fid)
|
|
210
|
+
removed += 1
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
if removed:
|
|
215
|
+
self.notify("info", "Memory auto-cleanup",
|
|
216
|
+
f"Archived {removed} stale/verbose fact(s) (session window + orphan sweep)")
|
|
217
|
+
|
|
218
|
+
def _find_duplicates_jaccard(self, facts):
|
|
219
|
+
"""Original Jaccard similarity duplicate detection."""
|
|
220
|
+
duplicates = []
|
|
221
|
+
token_sets = {}
|
|
222
|
+
for f in facts:
|
|
223
|
+
token_sets[f["id"]] = set(self.memory._tokenize(f["fact"]))
|
|
224
|
+
|
|
225
|
+
seen = set()
|
|
226
|
+
for i, f1 in enumerate(facts):
|
|
227
|
+
for f2 in facts[i + 1:]:
|
|
228
|
+
pair_key = (f1["id"], f2["id"])
|
|
229
|
+
if pair_key in seen:
|
|
230
|
+
continue
|
|
231
|
+
seen.add(pair_key)
|
|
232
|
+
s1, s2 = token_sets[f1["id"]], token_sets[f2["id"]]
|
|
233
|
+
if not s1 or not s2:
|
|
234
|
+
continue
|
|
235
|
+
jaccard = len(s1 & s2) / len(s1 | s2)
|
|
236
|
+
if jaccard >= self.similarity_threshold:
|
|
237
|
+
duplicates.append((f1["id"], f2["id"], round(jaccard, 2)))
|
|
238
|
+
return duplicates
|
|
239
|
+
|
|
240
|
+
def _find_duplicates_embedding(self, facts):
|
|
241
|
+
"""Embedding-based cosine similarity duplicate detection."""
|
|
242
|
+
# Identify facts needing new embeddings
|
|
243
|
+
new_facts = [f for f in facts if f["id"] not in self._embedding_cache]
|
|
244
|
+
if new_facts:
|
|
245
|
+
texts = [f["fact"] for f in new_facts]
|
|
246
|
+
embeddings = self.ollama.embed_batch(texts, model=self.embed_model)
|
|
247
|
+
if embeddings is None:
|
|
248
|
+
# Ollama failed — fall back to Jaccard for this cycle
|
|
249
|
+
return self._find_duplicates_jaccard(facts)
|
|
250
|
+
for f, emb in zip(new_facts, embeddings):
|
|
251
|
+
self._embedding_cache[f["id"]] = emb
|
|
252
|
+
|
|
253
|
+
# Prune cache for deleted facts
|
|
254
|
+
live_ids = {f["id"] for f in facts}
|
|
255
|
+
for fid in list(self._embedding_cache.keys()):
|
|
256
|
+
if fid not in live_ids:
|
|
257
|
+
del self._embedding_cache[fid]
|
|
258
|
+
|
|
259
|
+
# Cosine similarity comparison
|
|
260
|
+
duplicates = []
|
|
261
|
+
fact_ids = [f["id"] for f in facts]
|
|
262
|
+
for i in range(len(fact_ids)):
|
|
263
|
+
for j in range(i + 1, len(fact_ids)):
|
|
264
|
+
a, b = fact_ids[i], fact_ids[j]
|
|
265
|
+
va, vb = self._embedding_cache.get(a), self._embedding_cache.get(b)
|
|
266
|
+
if va is None or vb is None:
|
|
267
|
+
continue
|
|
268
|
+
sim = self._cosine_similarity(va, vb)
|
|
269
|
+
if sim >= self.similarity_threshold:
|
|
270
|
+
duplicates.append((a, b, round(sim, 2)))
|
|
271
|
+
return duplicates
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _cosine_similarity(a, b):
|
|
275
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
276
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
277
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
278
|
+
if norm_a == 0 or norm_b == 0:
|
|
279
|
+
return 0.0
|
|
280
|
+
return dot / (norm_a * norm_b)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class ClaudeMdDriftAgent(BackgroundAgent):
|
|
284
|
+
"""Checks CLAUDE.md for staleness when files have changed."""
|
|
285
|
+
|
|
286
|
+
def __init__(self, watcher, claude_md, notifications, enabled=True, interval=120, **kwargs):
|
|
287
|
+
super().__init__("ClaudeMdDrift", interval, notifications, enabled, **kwargs)
|
|
288
|
+
self.watcher = watcher
|
|
289
|
+
self.claude_md = claude_md
|
|
290
|
+
self._last_issues_hash = None
|
|
291
|
+
|
|
292
|
+
def check(self):
|
|
293
|
+
# Short-circuit if no file changes and we already checked
|
|
294
|
+
if self.watcher._handler.change_count == 0 and self._last_issues_hash is not None:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
result = self.claude_md.check_staleness()
|
|
299
|
+
except Exception:
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if result.get("status") != "stale":
|
|
303
|
+
self._last_issues_hash = ""
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
# Hash issues list to avoid re-notifying for same state
|
|
307
|
+
issues = result.get("issues", [])
|
|
308
|
+
issues_str = json.dumps(issues, sort_keys=True)
|
|
309
|
+
issues_hash = hashlib.md5(issues_str.encode()).hexdigest()
|
|
310
|
+
|
|
311
|
+
if issues_hash == self._last_issues_hash:
|
|
312
|
+
return
|
|
313
|
+
self._last_issues_hash = issues_hash
|
|
314
|
+
|
|
315
|
+
# AI: produce actionable summary instead of raw issue list
|
|
316
|
+
if self.ai_available and issues:
|
|
317
|
+
raw_issues = "; ".join(i.get("message", "")[:80] for i in issues)
|
|
318
|
+
summary = self._ai_generate(
|
|
319
|
+
f"CLAUDE.md has these staleness issues:\n{raw_issues}\n\n"
|
|
320
|
+
"Write 1-2 actionable sentences about what to update.",
|
|
321
|
+
system="You are a concise project documentation advisor. Be specific and actionable.",
|
|
322
|
+
max_tokens=100,
|
|
323
|
+
)
|
|
324
|
+
if summary:
|
|
325
|
+
self.notify("warning", "CLAUDE.md is stale", summary.strip(),
|
|
326
|
+
ai_enhanced=True, replace_if_unacked=True)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
# Fallback: raw issue concatenation
|
|
330
|
+
summary = "; ".join(i.get("message", "")[:60] for i in issues[:3])
|
|
331
|
+
self.notify("warning", "CLAUDE.md is stale", summary, replace_if_unacked=True)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class SessionInsightAgent(BackgroundAgent):
|
|
335
|
+
"""Periodic analysis of session activity to surface coaching tips."""
|
|
336
|
+
|
|
337
|
+
def __init__(self, session_mgr, memory, notifications, enabled=True, interval=600,
|
|
338
|
+
min_tool_calls=10, **kwargs):
|
|
339
|
+
super().__init__("SessionInsight", interval, notifications, enabled, **kwargs)
|
|
340
|
+
self.session_mgr = session_mgr
|
|
341
|
+
self.memory = memory
|
|
342
|
+
self.min_tool_calls = min_tool_calls
|
|
343
|
+
self._last_insight_hash = None
|
|
344
|
+
self._last_tool_count = 0
|
|
345
|
+
self._last_signal_hash = None
|
|
346
|
+
|
|
347
|
+
def check(self):
|
|
348
|
+
session = self.session_mgr.current_session
|
|
349
|
+
if not session:
|
|
350
|
+
return
|
|
351
|
+
tool_calls = session.get("tool_calls", [])
|
|
352
|
+
if len(tool_calls) < self.min_tool_calls:
|
|
353
|
+
return
|
|
354
|
+
# Only re-analyze when tool call count has grown meaningfully
|
|
355
|
+
if len(tool_calls) - self._last_tool_count < 5:
|
|
356
|
+
return
|
|
357
|
+
self._last_tool_count = len(tool_calls)
|
|
358
|
+
signal_summary = self._build_signal_summary(session, tool_calls)
|
|
359
|
+
signal_hash = hashlib.md5(signal_summary.encode("utf-8")).hexdigest()
|
|
360
|
+
if signal_hash == self._last_signal_hash:
|
|
361
|
+
return
|
|
362
|
+
self._last_signal_hash = signal_hash
|
|
363
|
+
|
|
364
|
+
# Try AI insight, fall back to heuristic
|
|
365
|
+
if self.ai_available:
|
|
366
|
+
insight = self._ai_insight(signal_summary)
|
|
367
|
+
if insight:
|
|
368
|
+
self._emit_insight(insight, ai_enhanced=True)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# Heuristic mode — emit all applicable insights (not just first)
|
|
372
|
+
insights = self._heuristic_insights(session, tool_calls)
|
|
373
|
+
if insights:
|
|
374
|
+
self._emit_insight("; ".join(insights))
|
|
375
|
+
|
|
376
|
+
def _heuristic_insights(self, session, tool_calls):
|
|
377
|
+
"""Rule-based coaching tips."""
|
|
378
|
+
insights = []
|
|
379
|
+
tool_names = [tc.get("tool", "") for tc in tool_calls]
|
|
380
|
+
tool_counts = Counter(tool_names)
|
|
381
|
+
budget = session.get("context_budget", {})
|
|
382
|
+
top_consumers = budget.get("top_consumers", [])
|
|
383
|
+
top_tool = top_consumers[0]["tool"] if top_consumers else ""
|
|
384
|
+
top_tokens = top_consumers[0]["tokens"] if top_consumers else 0
|
|
385
|
+
|
|
386
|
+
# Detect repeated search queries (>=3 same query)
|
|
387
|
+
search_queries = [tc.get("args", {}).get("query", "") for tc in tool_calls
|
|
388
|
+
if tc.get("tool") in ("c3_search", "c3_recall")]
|
|
389
|
+
query_counts = Counter(q for q in search_queries if q)
|
|
390
|
+
repeated = [q for q, c in query_counts.items() if c >= 3]
|
|
391
|
+
if repeated:
|
|
392
|
+
insights.append(f"Query '{repeated[0]}' used {query_counts[repeated[0]]}x — consider adding to CLAUDE.md")
|
|
393
|
+
|
|
394
|
+
# Many tool calls with 0 c3_remember calls
|
|
395
|
+
if len(tool_calls) > 20 and tool_counts.get("c3_remember", 0) == 0:
|
|
396
|
+
insights.append("Many tool calls but no facts saved — use c3_remember to preserve key discoveries")
|
|
397
|
+
|
|
398
|
+
# No decisions logged
|
|
399
|
+
decisions = session.get("decisions", [])
|
|
400
|
+
if len(tool_calls) > 15 and len(decisions) == 0:
|
|
401
|
+
insights.append("No decisions logged this session — use c3_session_log to preserve reasoning")
|
|
402
|
+
|
|
403
|
+
# Real token hotspot from session budget should override raw call-count intuition.
|
|
404
|
+
if top_tool in ("Read", "read", "view_file") and top_tokens >= 800:
|
|
405
|
+
insights.append(
|
|
406
|
+
f"File reads are the top token consumer ({top_tokens} tok) — switch to c3_compress(mode='map') before more broad reads"
|
|
407
|
+
)
|
|
408
|
+
elif top_tool == "c3_search" and top_tokens >= 800:
|
|
409
|
+
insights.append(
|
|
410
|
+
f"c3_search is the top token consumer ({top_tokens} tok) — tighten top_k/max_tokens or stabilize findings with c3_remember/c3_session_log"
|
|
411
|
+
)
|
|
412
|
+
elif top_tool in ("Bash", "run_command") and top_tokens >= 600:
|
|
413
|
+
insights.append(
|
|
414
|
+
f"Terminal output is the top token consumer ({top_tokens} tok) — route noisy output through c3_filter before analysis"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Detect c3_read thrashing on same file without prior c3_compress
|
|
418
|
+
c3_read_files = [tc.get("args", {}).get("file_path", "").split(",")[0]
|
|
419
|
+
for tc in tool_calls if tc.get("tool") == "c3_read"]
|
|
420
|
+
c3_read_file_counts = Counter(f for f in c3_read_files if f)
|
|
421
|
+
compress_files = {tc.get("args", {}).get("file_path", "")
|
|
422
|
+
for tc in tool_calls if tc.get("tool") == "c3_compress"}
|
|
423
|
+
for file_path, count in c3_read_file_counts.items():
|
|
424
|
+
if count >= 3 and file_path not in compress_files:
|
|
425
|
+
fname = Path(file_path).name if file_path else "unknown"
|
|
426
|
+
insights.append(
|
|
427
|
+
f"{count}x c3_read on '{fname}' without c3_compress — "
|
|
428
|
+
"use c3_compress(mode='map') first to see all symbols and target sections"
|
|
429
|
+
)
|
|
430
|
+
break # one tip is enough
|
|
431
|
+
|
|
432
|
+
# Many reads with 0 compressions
|
|
433
|
+
reads = tool_counts.get("Read", 0) + tool_counts.get("read", 0)
|
|
434
|
+
compressions = tool_counts.get("c3_compress", 0)
|
|
435
|
+
if reads > 5 and compressions == 0:
|
|
436
|
+
insights.append(f"{reads} file reads but no compressions — use c3_compress to save tokens")
|
|
437
|
+
|
|
438
|
+
# Heavy c3_search usage without c3_filter
|
|
439
|
+
searches = tool_counts.get("c3_search", 0)
|
|
440
|
+
filters = tool_counts.get("c3_filter", 0) + tool_counts.get("c3_extract", 0)
|
|
441
|
+
if searches > 8 and filters == 0:
|
|
442
|
+
insights.append(f"{searches} searches but no filtering — c3_filter is better for large files")
|
|
443
|
+
|
|
444
|
+
# Many compress/review operations but no delegation
|
|
445
|
+
compressions = tool_counts.get("c3_compress", 0)
|
|
446
|
+
delegate_calls = tool_counts.get("c3_delegate", 0)
|
|
447
|
+
heavy_ops = compressions + tool_counts.get("c3_summarize", 0)
|
|
448
|
+
if heavy_ops >= 5 and delegate_calls == 0:
|
|
449
|
+
insights.append(
|
|
450
|
+
f"{heavy_ops} compress/summarize calls but no c3_delegate — "
|
|
451
|
+
"use c3_delegate(task_type='summarize'/'review'/'test') to save Claude tokens"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Many file reads with zero delegation — stronger file-read hint
|
|
455
|
+
total_reads = tool_counts.get("Read", 0) + tool_counts.get("read", 0)
|
|
456
|
+
if total_reads > 8 and delegate_calls == 0 and len(tool_calls) > 15:
|
|
457
|
+
insights.append(
|
|
458
|
+
f"{total_reads} file reads and 0 c3_delegate calls — "
|
|
459
|
+
"for large files you only need to understand (not edit), use "
|
|
460
|
+
"c3_delegate(task_type='explain', file_path='...') to offload to local LLM"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Bash/run_command calls suggest possible errors worth delegating
|
|
464
|
+
bash_calls = tool_counts.get("Bash", 0) + tool_counts.get("run_command", 0)
|
|
465
|
+
if bash_calls > 3 and delegate_calls == 0 and len(tool_calls) > 10:
|
|
466
|
+
insights.append(
|
|
467
|
+
f"{bash_calls} terminal commands with no c3_delegate — "
|
|
468
|
+
"if any produced errors, use c3_delegate(task_type='diagnose', task='<error>') "
|
|
469
|
+
"to root-cause locally and save Claude tokens"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# --- Stuck detection → Codex escalation ---
|
|
473
|
+
# Detect repeated edit/validate/bash cycles on the same file (fix attempts)
|
|
474
|
+
recent_n = tool_calls[-20:] if len(tool_calls) >= 20 else tool_calls
|
|
475
|
+
edit_targets = []
|
|
476
|
+
error_count = 0
|
|
477
|
+
for tc in recent_n:
|
|
478
|
+
t = tc.get("tool", "")
|
|
479
|
+
if t in ("c3_edit", "Edit", "edit_file"):
|
|
480
|
+
fp = tc.get("args", {}).get("file_path", "")
|
|
481
|
+
if fp:
|
|
482
|
+
edit_targets.append(fp)
|
|
483
|
+
if t in ("Bash", "run_command", "c3_validate"):
|
|
484
|
+
summary = tc.get("summary", "").lower()
|
|
485
|
+
if any(w in summary for w in ("error", "fail", "exception", "traceback")):
|
|
486
|
+
error_count += 1
|
|
487
|
+
|
|
488
|
+
edit_file_counts = Counter(edit_targets)
|
|
489
|
+
repeated_edits = {f: c for f, c in edit_file_counts.items() if c >= 3}
|
|
490
|
+
if repeated_edits and error_count >= 2:
|
|
491
|
+
stuck_file = max(repeated_edits, key=repeated_edits.get)
|
|
492
|
+
fname = Path(stuck_file).name
|
|
493
|
+
insights.append(
|
|
494
|
+
f"Possible stuck loop: {repeated_edits[stuck_file]}x edits on '{fname}' "
|
|
495
|
+
f"with {error_count} errors in last 20 calls — "
|
|
496
|
+
"try c3_delegate(task_type='diagnose', backend='codex', "
|
|
497
|
+
f"task='debug repeated failures in {fname}') for a fresh perspective"
|
|
498
|
+
)
|
|
499
|
+
elif error_count >= 3 and not repeated_edits:
|
|
500
|
+
insights.append(
|
|
501
|
+
f"{error_count} errors in recent calls — "
|
|
502
|
+
"consider c3_delegate(task_type='diagnose', backend='codex') "
|
|
503
|
+
"to escalate investigation to Codex for a second opinion"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return insights
|
|
507
|
+
|
|
508
|
+
def _build_signal_summary(self, session, tool_calls) -> str:
|
|
509
|
+
tool_names = [tc.get("tool", "") for tc in tool_calls[-20:]]
|
|
510
|
+
tool_counts = Counter(tool_names)
|
|
511
|
+
decisions = session.get("decisions", [])
|
|
512
|
+
fact_count = len(self.memory.facts)
|
|
513
|
+
budget = session.get("context_budget", {})
|
|
514
|
+
top_consumers = budget.get("top_consumers", [])
|
|
515
|
+
consumers = ", ".join(f"{c['tool']}:{c['tokens']}" for c in top_consumers[:3]) if top_consumers else "none"
|
|
516
|
+
summary = (
|
|
517
|
+
f"Recent tool calls ({len(tool_calls[-20:])} sampled of {len(tool_calls)} total): "
|
|
518
|
+
+ ", ".join(f"{t}:{c}" for t, c in tool_counts.most_common(8))
|
|
519
|
+
+ f"\nCompression level: {budget.get('compression_level', 0)}"
|
|
520
|
+
+ f"\nResponse tokens: {budget.get('response_tokens', 0)}"
|
|
521
|
+
+ f"\nTop consumers: {consumers}"
|
|
522
|
+
+ f"\nDecisions logged: {len(decisions)}"
|
|
523
|
+
+ f"\nFacts in memory: {fact_count}"
|
|
524
|
+
)
|
|
525
|
+
if decisions:
|
|
526
|
+
summary += "\nRecent decisions: " + "; ".join(d.get("data", "")[:50] for d in decisions[-3:])
|
|
527
|
+
return summary
|
|
528
|
+
|
|
529
|
+
def _ai_insight(self, signal_summary: str):
|
|
530
|
+
"""AI-powered session coaching."""
|
|
531
|
+
tip = self._ai_generate(
|
|
532
|
+
f"Analyze this Claude Code session and give ONE specific coaching tip:\n\n{signal_summary}",
|
|
533
|
+
system="You are a productivity coach for AI coding assistants. "
|
|
534
|
+
"Give one actionable tip to improve workflow efficiency. Be specific.",
|
|
535
|
+
max_tokens=90,
|
|
536
|
+
)
|
|
537
|
+
return tip.strip() if tip else None
|
|
538
|
+
|
|
539
|
+
def _emit_insight(self, insight, ai_enhanced=False):
|
|
540
|
+
"""Emit insight notification, deduplicating via hash."""
|
|
541
|
+
h = hashlib.md5(insight.encode()).hexdigest()[:8]
|
|
542
|
+
if h == self._last_insight_hash:
|
|
543
|
+
return
|
|
544
|
+
self._last_insight_hash = h
|
|
545
|
+
self.notify("info", "Session insight", insight, ai_enhanced=ai_enhanced)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class ClaudeMdUpdaterAgent(BackgroundAgent):
|
|
549
|
+
"""Automatically maintains CLAUDE.md using local AI, memory, and session data.
|
|
550
|
+
|
|
551
|
+
Periodically checks for staleness, gathers promotion candidates from memory,
|
|
552
|
+
analyzes recent sessions for recurring patterns, and drafts targeted updates.
|
|
553
|
+
When AI is available, produces refined section patches; otherwise applies
|
|
554
|
+
safe heuristic updates (session refresh, fact promotion, compaction).
|
|
555
|
+
|
|
556
|
+
Updates are written to disk and surfaced via notifications. The agent never
|
|
557
|
+
deletes user-written content — it only appends, refreshes auto-generated
|
|
558
|
+
sections, and compacts when the file exceeds the truncation limit.
|
|
559
|
+
"""
|
|
560
|
+
|
|
561
|
+
def __init__(self, claude_md, memory, session_mgr, watcher, notifications,
|
|
562
|
+
enabled=True, interval=900, auto_apply=True, min_facts_for_promote=2,
|
|
563
|
+
**kwargs):
|
|
564
|
+
super().__init__("ClaudeMdUpdater", interval, notifications, enabled, **kwargs)
|
|
565
|
+
self.claude_md = claude_md
|
|
566
|
+
self.memory = memory
|
|
567
|
+
self.session_mgr = session_mgr
|
|
568
|
+
self.watcher = watcher
|
|
569
|
+
self.auto_apply = auto_apply
|
|
570
|
+
self.min_facts_for_promote = min_facts_for_promote
|
|
571
|
+
self._last_content_hash = ""
|
|
572
|
+
self._last_update_time = 0.0
|
|
573
|
+
self._updates_applied = 0
|
|
574
|
+
self._last_action_hash = ""
|
|
575
|
+
|
|
576
|
+
@property
|
|
577
|
+
def truncation_limit(self) -> int:
|
|
578
|
+
"""Read line limit from the ClaudeMdManager (IDE-aware)."""
|
|
579
|
+
return getattr(self.claude_md, 'line_limit', 200) or 200
|
|
580
|
+
|
|
581
|
+
def check(self):
|
|
582
|
+
# Gather signals
|
|
583
|
+
staleness = self._check_staleness()
|
|
584
|
+
promotions = self._check_promotions()
|
|
585
|
+
needs_compact = self._check_line_count()
|
|
586
|
+
|
|
587
|
+
# Nothing to do
|
|
588
|
+
if not staleness and not promotions and not needs_compact:
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
# Build an update plan
|
|
592
|
+
actions = []
|
|
593
|
+
if staleness:
|
|
594
|
+
actions.append(("staleness", staleness))
|
|
595
|
+
if promotions:
|
|
596
|
+
actions.append(("promote", promotions))
|
|
597
|
+
if needs_compact:
|
|
598
|
+
actions.append(("compact", needs_compact))
|
|
599
|
+
|
|
600
|
+
action_hash = self._action_hash(actions)
|
|
601
|
+
if action_hash == self._last_action_hash:
|
|
602
|
+
return
|
|
603
|
+
self._last_action_hash = action_hash
|
|
604
|
+
|
|
605
|
+
if self.ai_available:
|
|
606
|
+
self._ai_update(actions)
|
|
607
|
+
else:
|
|
608
|
+
self._heuristic_update(actions)
|
|
609
|
+
|
|
610
|
+
def _check_staleness(self) -> dict | None:
|
|
611
|
+
"""Return staleness result if CLAUDE.md is stale, else None."""
|
|
612
|
+
# Only check if files have changed or we haven't checked before
|
|
613
|
+
if self.watcher._handler.change_count == 0 and self._last_content_hash:
|
|
614
|
+
return None
|
|
615
|
+
try:
|
|
616
|
+
result = self.claude_md.check_staleness()
|
|
617
|
+
if result.get("status") == "stale":
|
|
618
|
+
return result
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
def _check_promotions(self) -> dict | None:
|
|
624
|
+
"""Return promotion candidates if any qualify."""
|
|
625
|
+
try:
|
|
626
|
+
result = self.claude_md.get_promotion_candidates(
|
|
627
|
+
min_relevance=self.min_facts_for_promote
|
|
628
|
+
)
|
|
629
|
+
total = result.get("total_candidates", 0)
|
|
630
|
+
if total > 0:
|
|
631
|
+
return result
|
|
632
|
+
except Exception:
|
|
633
|
+
pass
|
|
634
|
+
return None
|
|
635
|
+
|
|
636
|
+
def _check_line_count(self) -> dict | None:
|
|
637
|
+
"""Return compact info if CLAUDE.md exceeds truncation limit."""
|
|
638
|
+
try:
|
|
639
|
+
current = self.claude_md._read_current()
|
|
640
|
+
if current and len(current.split("\n")) > self.truncation_limit:
|
|
641
|
+
return {"lines": len(current.split("\n")), "limit": self.truncation_limit}
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
def _heuristic_update(self, actions: list):
|
|
647
|
+
"""Apply safe heuristic updates without AI."""
|
|
648
|
+
applied = []
|
|
649
|
+
|
|
650
|
+
for action_type, data in actions:
|
|
651
|
+
if action_type == "staleness":
|
|
652
|
+
# Regenerate the auto-generated sections
|
|
653
|
+
try:
|
|
654
|
+
result = self.claude_md.generate(include_sessions=True)
|
|
655
|
+
if result.get("content") and self.auto_apply:
|
|
656
|
+
self._write_claude_md(result["content"])
|
|
657
|
+
applied.append("Regenerated CLAUDE.md (stale)")
|
|
658
|
+
elif result.get("content"):
|
|
659
|
+
applied.append(f"CLAUDE.md is stale ({len(data.get('issues', []))} issues) — regeneration available")
|
|
660
|
+
except Exception:
|
|
661
|
+
pass
|
|
662
|
+
|
|
663
|
+
elif action_type == "promote":
|
|
664
|
+
# Append high-relevance facts to CLAUDE.md
|
|
665
|
+
candidates = data.get("candidates", {})
|
|
666
|
+
total = data.get("total_candidates", 0)
|
|
667
|
+
if total > 0 and not self.auto_apply:
|
|
668
|
+
applied.append(f"{total} facts ready to promote into CLAUDE.md")
|
|
669
|
+
elif total > 0 and self.auto_apply:
|
|
670
|
+
promoted = self._apply_promotions(candidates)
|
|
671
|
+
if promoted:
|
|
672
|
+
applied.append(f"Promoted {promoted} facts into CLAUDE.md")
|
|
673
|
+
|
|
674
|
+
elif action_type == "compact":
|
|
675
|
+
lines = data["lines"]
|
|
676
|
+
limit = data["limit"]
|
|
677
|
+
if self.auto_apply:
|
|
678
|
+
try:
|
|
679
|
+
result = self.claude_md.compact(target_lines=limit)
|
|
680
|
+
if result.get("content"):
|
|
681
|
+
self._write_claude_md(result["content"])
|
|
682
|
+
saved = result.get("original_lines", 0) - result.get("compacted_lines", 0)
|
|
683
|
+
applied.append(f"Compacted CLAUDE.md ({saved} lines saved)")
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
else:
|
|
687
|
+
applied.append(f"CLAUDE.md is {lines} lines (limit {limit}) — compaction available")
|
|
688
|
+
|
|
689
|
+
if applied:
|
|
690
|
+
self._updates_applied += len(applied)
|
|
691
|
+
self.notify("info", "CLAUDE.md updated", "; ".join(applied))
|
|
692
|
+
|
|
693
|
+
def _ai_update(self, actions: list):
|
|
694
|
+
"""AI-enhanced update — uses local LLM to produce targeted patches."""
|
|
695
|
+
# Build context for AI
|
|
696
|
+
current_md = ""
|
|
697
|
+
try:
|
|
698
|
+
current_md = self.claude_md._read_current() or ""
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
# Gather signals into a compact summary
|
|
703
|
+
signals = []
|
|
704
|
+
for action_type, data in actions:
|
|
705
|
+
if action_type == "staleness":
|
|
706
|
+
issues = data.get("issues", [])
|
|
707
|
+
signals.append(
|
|
708
|
+
"STALENESS: " + "; ".join(i.get("message", "")[:60] for i in issues[:5])
|
|
709
|
+
)
|
|
710
|
+
elif action_type == "promote":
|
|
711
|
+
candidates = data.get("candidates", {})
|
|
712
|
+
for section, items in candidates.items():
|
|
713
|
+
for item in items[:3]:
|
|
714
|
+
signals.append(f"PROMOTE [{section}]: {item['fact'][:80]}")
|
|
715
|
+
elif action_type == "compact":
|
|
716
|
+
signals.append(f"OVER LIMIT: {data['lines']} lines (limit {data['limit']})")
|
|
717
|
+
|
|
718
|
+
# Add recent session context
|
|
719
|
+
session = self.session_mgr.current_session
|
|
720
|
+
if session:
|
|
721
|
+
decisions = session.get("decisions", [])
|
|
722
|
+
if decisions:
|
|
723
|
+
signals.append(
|
|
724
|
+
"RECENT DECISIONS: " + "; ".join(d.get("data", "")[:50] for d in decisions[-3:])
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# Add high-relevance memory facts
|
|
728
|
+
top_facts = sorted(self.memory.facts, key=lambda f: f.get("relevance_count", 0), reverse=True)[:5]
|
|
729
|
+
if top_facts:
|
|
730
|
+
signals.append(
|
|
731
|
+
"TOP FACTS: " + "; ".join(f["fact"][:60] for f in top_facts)
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
signals_text = "\n".join(signals)
|
|
735
|
+
|
|
736
|
+
# Ask AI for a targeted update plan
|
|
737
|
+
ai_plan = self._ai_generate(
|
|
738
|
+
f"Current CLAUDE.md has {len(current_md.split(chr(10)))} lines.\n\n"
|
|
739
|
+
f"These signals indicate needed updates:\n{signals_text}\n\n"
|
|
740
|
+
"List the specific updates to make as a numbered list. Be concise.\n"
|
|
741
|
+
"Focus on: refreshing stale sections, adding high-value facts, removing duplicates.\n"
|
|
742
|
+
"Do NOT suggest removing user-written content.",
|
|
743
|
+
system="You are a project documentation maintainer. Output a concise numbered update plan.",
|
|
744
|
+
max_tokens=200,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if ai_plan and self.auto_apply:
|
|
748
|
+
# Apply heuristic updates (AI plan guides notification, but actual
|
|
749
|
+
# changes use the safe ClaudeMdManager methods)
|
|
750
|
+
self._heuristic_update(actions)
|
|
751
|
+
# Enhance the notification with the AI plan
|
|
752
|
+
self.notify("info", "CLAUDE.md update plan",
|
|
753
|
+
ai_plan.strip(), ai_enhanced=True)
|
|
754
|
+
elif ai_plan:
|
|
755
|
+
self.notify("info", "CLAUDE.md update plan (dry run)",
|
|
756
|
+
ai_plan.strip(), ai_enhanced=True)
|
|
757
|
+
else:
|
|
758
|
+
# AI failed, fall back
|
|
759
|
+
self._heuristic_update(actions)
|
|
760
|
+
|
|
761
|
+
def _action_hash(self, actions: list) -> str:
|
|
762
|
+
signature = []
|
|
763
|
+
for action_type, data in actions:
|
|
764
|
+
if action_type == "staleness":
|
|
765
|
+
issues = [i.get("message", "") for i in data.get("issues", [])[:5]]
|
|
766
|
+
signature.append((action_type, issues))
|
|
767
|
+
elif action_type == "promote":
|
|
768
|
+
signature.append((action_type, data.get("total_candidates", 0)))
|
|
769
|
+
elif action_type == "compact":
|
|
770
|
+
signature.append((action_type, data.get("lines", 0), data.get("limit", 0)))
|
|
771
|
+
return hashlib.md5(json.dumps(signature, sort_keys=True).encode("utf-8")).hexdigest()
|
|
772
|
+
|
|
773
|
+
def _apply_promotions(self, candidates: dict) -> int:
|
|
774
|
+
"""Append promotion candidates to the appropriate CLAUDE.md sections."""
|
|
775
|
+
try:
|
|
776
|
+
current = self.claude_md._read_current()
|
|
777
|
+
if not current:
|
|
778
|
+
return 0
|
|
779
|
+
except Exception:
|
|
780
|
+
return 0
|
|
781
|
+
|
|
782
|
+
additions = 0
|
|
783
|
+
lines = current.split("\n")
|
|
784
|
+
|
|
785
|
+
for section_name, items in candidates.items():
|
|
786
|
+
if not items:
|
|
787
|
+
continue
|
|
788
|
+
|
|
789
|
+
# Find the section header
|
|
790
|
+
section_idx = None
|
|
791
|
+
for i, line in enumerate(lines):
|
|
792
|
+
if section_name in line and line.strip().startswith("#"):
|
|
793
|
+
section_idx = i
|
|
794
|
+
break
|
|
795
|
+
|
|
796
|
+
if section_idx is not None:
|
|
797
|
+
# Find end of section (next header or EOF)
|
|
798
|
+
insert_idx = section_idx + 1
|
|
799
|
+
while insert_idx < len(lines) and not lines[insert_idx].strip().startswith("#"):
|
|
800
|
+
insert_idx += 1
|
|
801
|
+
|
|
802
|
+
# Insert before next header
|
|
803
|
+
new_lines = [item["snippet"] for item in items[:3]]
|
|
804
|
+
for nl in reversed(new_lines):
|
|
805
|
+
lines.insert(insert_idx, nl)
|
|
806
|
+
additions += len(new_lines)
|
|
807
|
+
|
|
808
|
+
if additions > 0:
|
|
809
|
+
self._write_claude_md("\n".join(lines))
|
|
810
|
+
return additions
|
|
811
|
+
|
|
812
|
+
def _write_claude_md(self, content: str):
|
|
813
|
+
"""Write content to the instructions file and update hash."""
|
|
814
|
+
try:
|
|
815
|
+
md_path = self.claude_md.project_path / self.claude_md.instructions_file
|
|
816
|
+
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
817
|
+
md_path.write_text(content, encoding="utf-8")
|
|
818
|
+
self._last_content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
819
|
+
self._last_update_time = time.time()
|
|
820
|
+
except Exception:
|
|
821
|
+
pass
|
|
822
|
+
|
|
823
|
+
def get_status(self) -> dict:
|
|
824
|
+
"""Extended status including updater-specific metrics."""
|
|
825
|
+
status = super().get_status()
|
|
826
|
+
status.update({
|
|
827
|
+
"auto_apply": self.auto_apply,
|
|
828
|
+
"updates_applied": self._updates_applied,
|
|
829
|
+
"last_update": self._last_update_time,
|
|
830
|
+
})
|
|
831
|
+
return status
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
class FileMemoryAgent(BackgroundAgent):
|
|
835
|
+
"""Maintains persistent structural maps of source files.
|
|
836
|
+
|
|
837
|
+
Watches for file changes, re-extracts section maps (classes, functions, line ranges),
|
|
838
|
+
and optionally generates AI summaries. Processes queued files from the Read hook.
|
|
839
|
+
"""
|
|
840
|
+
|
|
841
|
+
def __init__(self, file_memory, watcher, notifications,
|
|
842
|
+
enabled=True, interval=120, max_files_per_cycle=5, **kwargs):
|
|
843
|
+
super().__init__("FileMemory", interval, notifications, enabled, **kwargs)
|
|
844
|
+
self.file_memory = file_memory
|
|
845
|
+
self.watcher = watcher
|
|
846
|
+
self.max_files_per_cycle = max_files_per_cycle
|
|
847
|
+
self._last_change_count = 0
|
|
848
|
+
|
|
849
|
+
def check(self):
|
|
850
|
+
files_to_process = []
|
|
851
|
+
|
|
852
|
+
# 1. Drain the async queue (from Read hook)
|
|
853
|
+
queued = self.file_memory.drain_queue()
|
|
854
|
+
files_to_process.extend(queued)
|
|
855
|
+
|
|
856
|
+
# 2. Check watcher for changed files
|
|
857
|
+
change_count = self.watcher._handler.change_count
|
|
858
|
+
if change_count != self._last_change_count:
|
|
859
|
+
self._last_change_count = change_count
|
|
860
|
+
# Check tracked files for staleness
|
|
861
|
+
for rel_path in self.file_memory.list_tracked():
|
|
862
|
+
if self.file_memory.needs_update(rel_path):
|
|
863
|
+
files_to_process.append(rel_path)
|
|
864
|
+
|
|
865
|
+
# Deduplicate
|
|
866
|
+
seen = set()
|
|
867
|
+
unique = []
|
|
868
|
+
for p in files_to_process:
|
|
869
|
+
if p not in seen:
|
|
870
|
+
seen.add(p)
|
|
871
|
+
unique.append(p)
|
|
872
|
+
files_to_process = unique[:self.max_files_per_cycle]
|
|
873
|
+
|
|
874
|
+
if not files_to_process:
|
|
875
|
+
return
|
|
876
|
+
|
|
877
|
+
updated_count = 0
|
|
878
|
+
completed = []
|
|
879
|
+
failed = []
|
|
880
|
+
for rel_path in files_to_process:
|
|
881
|
+
ai_summary = None
|
|
882
|
+
|
|
883
|
+
# Generate AI summary if available
|
|
884
|
+
if self.ai_available:
|
|
885
|
+
record = self.file_memory.get(rel_path)
|
|
886
|
+
section_names = ""
|
|
887
|
+
if record:
|
|
888
|
+
names = [s.get("name", "") for s in record.get("sections", [])
|
|
889
|
+
if s.get("type") not in ("import", "decorator")]
|
|
890
|
+
section_names = ", ".join(names[:10])
|
|
891
|
+
|
|
892
|
+
if section_names:
|
|
893
|
+
ai_summary = self._ai_generate(
|
|
894
|
+
f"File: {rel_path}\n"
|
|
895
|
+
f"Symbols: {section_names}\n\n"
|
|
896
|
+
f"Describe the purpose of this file and its main symbols in 1-2 concise sentences. "
|
|
897
|
+
f"Focus on what they do, not how they are implemented.",
|
|
898
|
+
system="You are a senior architect summarizing code for an AI assistant. "
|
|
899
|
+
"Be technical, concise, and highlight the main responsibilities of the symbols.",
|
|
900
|
+
max_tokens=100,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
result = self.file_memory.update(rel_path, ai_summary=ai_summary)
|
|
904
|
+
if result:
|
|
905
|
+
updated_count += 1
|
|
906
|
+
completed.append(rel_path)
|
|
907
|
+
else:
|
|
908
|
+
failed.append(rel_path)
|
|
909
|
+
|
|
910
|
+
if completed:
|
|
911
|
+
self.file_memory.complete_updates(completed)
|
|
912
|
+
if failed:
|
|
913
|
+
self.file_memory.complete_updates(failed, failed=True)
|
|
914
|
+
|
|
915
|
+
if updated_count > 0:
|
|
916
|
+
self.notify("info", "File maps updated",
|
|
917
|
+
f"Updated {updated_count} file map(s): {', '.join(files_to_process[:3])}")
|
|
918
|
+
|
|
919
|
+
def get_status(self) -> dict:
|
|
920
|
+
status = super().get_status()
|
|
921
|
+
status["tracked_files"] = len(self.file_memory.list_tracked())
|
|
922
|
+
return status
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
class AutonomyPlannerAgent(BackgroundAgent):
|
|
926
|
+
"""Builds a prioritized autonomous action plan from recent tool telemetry."""
|
|
927
|
+
|
|
928
|
+
def __init__(self, session_mgr, watcher, notifications, enabled=True, interval=240,
|
|
929
|
+
lookback_tool_calls=30, cooldown_seconds=600, min_signal_score=2, max_actions=3, **kwargs):
|
|
930
|
+
super().__init__("AutonomyPlanner", interval, notifications, enabled, **kwargs)
|
|
931
|
+
self.session_mgr = session_mgr
|
|
932
|
+
self.watcher = watcher
|
|
933
|
+
self.lookback_tool_calls = max(10, int(lookback_tool_calls))
|
|
934
|
+
self.cooldown_seconds = max(60, int(cooldown_seconds))
|
|
935
|
+
self.min_signal_score = max(1, int(min_signal_score))
|
|
936
|
+
self.max_actions = max(1, int(max_actions))
|
|
937
|
+
self._last_tool_count = 0
|
|
938
|
+
self._last_plan_hash = None
|
|
939
|
+
self._last_plan_time = 0.0
|
|
940
|
+
|
|
941
|
+
def check(self):
|
|
942
|
+
session = self.session_mgr.current_session
|
|
943
|
+
if not session:
|
|
944
|
+
return
|
|
945
|
+
tool_calls = session.get("tool_calls", [])
|
|
946
|
+
if len(tool_calls) < 5:
|
|
947
|
+
return
|
|
948
|
+
if len(tool_calls) - self._last_tool_count < 3:
|
|
949
|
+
return
|
|
950
|
+
self._last_tool_count = len(tool_calls)
|
|
951
|
+
|
|
952
|
+
recent = tool_calls[-self.lookback_tool_calls:]
|
|
953
|
+
actions = self._build_actions(session, recent)
|
|
954
|
+
if not actions:
|
|
955
|
+
return
|
|
956
|
+
|
|
957
|
+
actions.sort(key=lambda a: a["score"], reverse=True)
|
|
958
|
+
selected = actions[:self.max_actions]
|
|
959
|
+
if selected[0]["score"] < self.min_signal_score:
|
|
960
|
+
return
|
|
961
|
+
|
|
962
|
+
message = self._format_plan(selected, len(recent))
|
|
963
|
+
now = time.time()
|
|
964
|
+
plan_hash = hashlib.md5(message.encode("utf-8")).hexdigest()[:12]
|
|
965
|
+
if self._last_plan_hash == plan_hash and (now - self._last_plan_time) < self.cooldown_seconds:
|
|
966
|
+
return
|
|
967
|
+
|
|
968
|
+
used_ai = False
|
|
969
|
+
if self.ai_available:
|
|
970
|
+
ai_plan = self._ai_refine_plan(selected, len(recent))
|
|
971
|
+
if ai_plan:
|
|
972
|
+
message = ai_plan
|
|
973
|
+
used_ai = True
|
|
974
|
+
plan_hash = hashlib.md5(message.encode("utf-8")).hexdigest()[:12]
|
|
975
|
+
if self._last_plan_hash == plan_hash and (now - self._last_plan_time) < self.cooldown_seconds:
|
|
976
|
+
return
|
|
977
|
+
|
|
978
|
+
severity = "warning" if selected[0]["score"] >= 4 else "info"
|
|
979
|
+
self.notify(severity, "Autonomy plan", message, ai_enhanced=used_ai)
|
|
980
|
+
self._last_plan_hash = plan_hash
|
|
981
|
+
self._last_plan_time = now
|
|
982
|
+
|
|
983
|
+
def _build_actions(self, session, tool_calls: list[dict]) -> list[dict]:
|
|
984
|
+
actions = {}
|
|
985
|
+
|
|
986
|
+
def add_action(key: str, score: int, text: str):
|
|
987
|
+
existing = actions.get(key)
|
|
988
|
+
if existing and existing["score"] >= score:
|
|
989
|
+
return
|
|
990
|
+
actions[key] = {"score": score, "text": text}
|
|
991
|
+
|
|
992
|
+
names = [tc.get("tool", "") for tc in tool_calls]
|
|
993
|
+
counts = Counter(names)
|
|
994
|
+
delegate_calls = counts.get("c3_delegate", 0)
|
|
995
|
+
budget = session.get("context_budget", {})
|
|
996
|
+
top_consumers = budget.get("top_consumers", [])
|
|
997
|
+
top_tool = top_consumers[0]["tool"] if top_consumers else ""
|
|
998
|
+
top_tokens = top_consumers[0]["tokens"] if top_consumers else 0
|
|
999
|
+
|
|
1000
|
+
# Context pressure should surface first.
|
|
1001
|
+
level = self.session_mgr.get_compression_level() if hasattr(self.session_mgr, "get_compression_level") else 0
|
|
1002
|
+
if level >= 2:
|
|
1003
|
+
add_action(
|
|
1004
|
+
"context_critical",
|
|
1005
|
+
5,
|
|
1006
|
+
"Context is at compression level 2. Run `c3_session(action='snapshot', data='checkpoint')`, then start a fresh session.",
|
|
1007
|
+
)
|
|
1008
|
+
elif level == 1:
|
|
1009
|
+
add_action(
|
|
1010
|
+
"context_tight",
|
|
1011
|
+
3,
|
|
1012
|
+
"Context is elevated (level 1). Prefer `c3_compress`/`c3_search` and keep responses concise to avoid escalation.",
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
# Real token hotspots should produce more precise next steps.
|
|
1016
|
+
if top_tool in ("Read", "read", "view_file") and top_tokens >= 800:
|
|
1017
|
+
add_action(
|
|
1018
|
+
"read_hotspot",
|
|
1019
|
+
4,
|
|
1020
|
+
f"File reads are currently the top token consumer ({top_tokens} tok). Use `c3_compress(mode='map')` or `c3_compress(mode='smart')` before more broad reads.",
|
|
1021
|
+
)
|
|
1022
|
+
elif top_tool == "c3_search" and top_tokens >= 800:
|
|
1023
|
+
add_action(
|
|
1024
|
+
"search_hotspot",
|
|
1025
|
+
3,
|
|
1026
|
+
f"`c3_search` is the top token consumer ({top_tokens} tok). Reduce `top_k`/`max_tokens` and persist stable findings with `c3_remember(...)`.",
|
|
1027
|
+
)
|
|
1028
|
+
elif top_tool in ("Bash", "run_command") and top_tokens >= 600:
|
|
1029
|
+
add_action(
|
|
1030
|
+
"terminal_hotspot",
|
|
1031
|
+
3,
|
|
1032
|
+
f"Terminal output is the top token consumer ({top_tokens} tok). Run noisy output through `c3_filter(text=...)` before more analysis.",
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
# Detect large file reads without file maps.
|
|
1036
|
+
read_tools = {"Read", "read", "view_file"}
|
|
1037
|
+
read_calls = [tc for tc in tool_calls if tc.get("tool", "") in read_tools]
|
|
1038
|
+
large_reads = [tc for tc in read_calls if self._extract_read_lines(tc.get("result_summary", "")) >= 200]
|
|
1039
|
+
file_map_calls = counts.get("c3_file_map", 0) + counts.get("c3_compress", 0)
|
|
1040
|
+
if large_reads and file_map_calls == 0:
|
|
1041
|
+
path_hint = self._extract_path_hint(large_reads[-1])
|
|
1042
|
+
target = f" for `{path_hint}`" if path_hint else ""
|
|
1043
|
+
add_action(
|
|
1044
|
+
"file_map",
|
|
1045
|
+
4 if len(large_reads) >= 2 else 3,
|
|
1046
|
+
f"Large reads detected{target}. Use `c3_compress(file_path='...', mode='map')` before additional reads to target sections.",
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
# Detect terminal failures that should be delegated to local diagnosis.
|
|
1050
|
+
failed_commands = 0
|
|
1051
|
+
for tc in tool_calls:
|
|
1052
|
+
if tc.get("tool", "") not in ("Bash", "run_command"):
|
|
1053
|
+
continue
|
|
1054
|
+
summary = (tc.get("result_summary", "") or "").lower()
|
|
1055
|
+
if any(tok in summary for tok in ("error", "err", "fail", "exception", "traceback", "exit code")):
|
|
1056
|
+
failed_commands += 1
|
|
1057
|
+
if failed_commands > 0 and delegate_calls == 0:
|
|
1058
|
+
add_action(
|
|
1059
|
+
"diagnose",
|
|
1060
|
+
4,
|
|
1061
|
+
"Terminal failures detected. Use `c3_delegate(task_type='diagnose', task='<error output>')` for local root-cause analysis.",
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
# Detect heavy analysis done in Claude without local delegation.
|
|
1065
|
+
heavy_ops = counts.get("c3_compress", 0) + counts.get("c3_summarize", 0)
|
|
1066
|
+
if heavy_ops >= 4 and delegate_calls == 0:
|
|
1067
|
+
add_action(
|
|
1068
|
+
"delegate_heavy",
|
|
1069
|
+
3,
|
|
1070
|
+
"High summarize/compress volume. Offload with `c3_delegate(task_type='summarize'|'review'|'test')` where possible.",
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# Detect repeated search loops that should be stabilized into memory/decisions.
|
|
1074
|
+
queries = [tc.get("args", {}).get("query", "").strip() for tc in tool_calls if tc.get("tool") == "c3_search"]
|
|
1075
|
+
query_counts = Counter(q for q in queries if q)
|
|
1076
|
+
repeated = [q for q, c in query_counts.items() if c >= 3]
|
|
1077
|
+
if repeated:
|
|
1078
|
+
top_query = repeated[0]
|
|
1079
|
+
if len(top_query) > 48:
|
|
1080
|
+
top_query = top_query[:45] + "..."
|
|
1081
|
+
add_action(
|
|
1082
|
+
"stabilize_loop",
|
|
1083
|
+
2,
|
|
1084
|
+
f"Repeated search loop on '{top_query}'. Record the result with `c3_session_log(...)` and persist reusable facts with `c3_remember(...)`.",
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
# Detect c3_read thrashing on same file without a structural map
|
|
1088
|
+
c3_read_calls = [tc for tc in tool_calls if tc.get("tool") == "c3_read"]
|
|
1089
|
+
read_file_counts = Counter(
|
|
1090
|
+
tc.get("args", {}).get("file_path", "").split(",")[0]
|
|
1091
|
+
for tc in c3_read_calls
|
|
1092
|
+
if tc.get("args", {}).get("file_path", "")
|
|
1093
|
+
)
|
|
1094
|
+
compress_files = {tc.get("args", {}).get("file_path", "")
|
|
1095
|
+
for tc in tool_calls if tc.get("tool") == "c3_compress"}
|
|
1096
|
+
thrashing = [(f, c) for f, c in read_file_counts.items() if c >= 3 and f not in compress_files]
|
|
1097
|
+
if thrashing:
|
|
1098
|
+
worst = max(thrashing, key=lambda x: x[1])
|
|
1099
|
+
fname = Path(worst[0]).name if worst[0] else "file"
|
|
1100
|
+
add_action(
|
|
1101
|
+
"read_thrash",
|
|
1102
|
+
4,
|
|
1103
|
+
f"`c3_read` called {worst[1]}x on '{fname}' without a structural map. "
|
|
1104
|
+
f"Run `c3_compress(file_path='{worst[0]}', mode='map')` first to locate all symbols, "
|
|
1105
|
+
"then target exact sections — or delegate with `c3_delegate(task_type='investigate')`.",
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
# Detect high tool-call volume with no compress/plan — loop risk
|
|
1109
|
+
if len(tool_calls) > 30 and file_map_calls == 0:
|
|
1110
|
+
add_action(
|
|
1111
|
+
"loop_risk",
|
|
1112
|
+
3,
|
|
1113
|
+
f"High tool call volume ({len(tool_calls)} calls) with no structural maps. "
|
|
1114
|
+
"Stop, run `c3_compress(mode='map')` on key files, then use `c3_session(action='plan')` to reset approach.",
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
# Detect stale index pressure for search-heavy loops.
|
|
1118
|
+
change_count = getattr(getattr(self.watcher, "_handler", None), "change_count", 0)
|
|
1119
|
+
if change_count >= 10:
|
|
1120
|
+
add_action(
|
|
1121
|
+
"index_stale",
|
|
1122
|
+
2,
|
|
1123
|
+
"Index likely stale from file churn. Run `c3_status(view='optimize')` or rebuild index before more broad searches.",
|
|
1124
|
+
)
|
|
1125
|
+
elif change_count >= 5 and counts.get("c3_search", 0) >= 2:
|
|
1126
|
+
add_action(
|
|
1127
|
+
"index_stale",
|
|
1128
|
+
2,
|
|
1129
|
+
"Recent file changes plus active search detected. Consider `c3_status(view='optimize')` to refresh retrieval quality.",
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
return list(actions.values())
|
|
1133
|
+
|
|
1134
|
+
def _extract_read_lines(self, summary: str) -> int:
|
|
1135
|
+
m = re.search(r"(\d+)\s*(?:L|lines?)", summary or "", flags=re.IGNORECASE)
|
|
1136
|
+
if not m:
|
|
1137
|
+
return 0
|
|
1138
|
+
try:
|
|
1139
|
+
return int(m.group(1))
|
|
1140
|
+
except Exception:
|
|
1141
|
+
return 0
|
|
1142
|
+
|
|
1143
|
+
def _extract_path_hint(self, tool_call: dict) -> str:
|
|
1144
|
+
args = tool_call.get("args", {}) or {}
|
|
1145
|
+
raw = args.get("file_path") or args.get("AbsolutePath") or args.get("path") or ""
|
|
1146
|
+
if not raw:
|
|
1147
|
+
return ""
|
|
1148
|
+
return Path(str(raw)).name
|
|
1149
|
+
|
|
1150
|
+
def _format_plan(self, actions: list[dict], sample_size: int) -> str:
|
|
1151
|
+
lines = [f"Autonomy plan ({sample_size} recent calls):"]
|
|
1152
|
+
for i, action in enumerate(actions, start=1):
|
|
1153
|
+
lines.append(f"{i}. {action['text']}")
|
|
1154
|
+
return "\n".join(lines)
|
|
1155
|
+
|
|
1156
|
+
def _ai_refine_plan(self, actions: list[dict], sample_size: int) -> str | None:
|
|
1157
|
+
actions_text = "\n".join(f"- ({a['score']}) {a['text']}" for a in actions)
|
|
1158
|
+
refined = self._ai_generate(
|
|
1159
|
+
f"Rewrite this autonomous next-step plan to be concise and prioritized.\n"
|
|
1160
|
+
f"Keep command snippets exactly as written.\n"
|
|
1161
|
+
f"Use up to {len(actions)} numbered items.\n\n"
|
|
1162
|
+
f"Scope: {sample_size} recent tool calls\n"
|
|
1163
|
+
f"Draft actions:\n{actions_text}",
|
|
1164
|
+
system="You are an operations planner for a local AI coding workflow. Be precise, direct, and compact.",
|
|
1165
|
+
max_tokens=220,
|
|
1166
|
+
)
|
|
1167
|
+
if not refined:
|
|
1168
|
+
return None
|
|
1169
|
+
text = refined.strip()
|
|
1170
|
+
return text if len(text) >= 20 else None
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
class DelegateCoachAgent(BackgroundAgent):
|
|
1174
|
+
"""Watches activity log for missed local AI delegation opportunities and emits actionable coaching."""
|
|
1175
|
+
|
|
1176
|
+
def __init__(self, session_mgr, notifications, enabled=True, interval=180, lookback_lines=200, **kwargs):
|
|
1177
|
+
super().__init__("DelegateCoach", interval, notifications, enabled, **kwargs)
|
|
1178
|
+
self.session_mgr = session_mgr
|
|
1179
|
+
self.lookback_lines = lookback_lines
|
|
1180
|
+
self._last_checked_tool_count = 0
|
|
1181
|
+
|
|
1182
|
+
def check(self):
|
|
1183
|
+
session = self.session_mgr.current_session
|
|
1184
|
+
if not session:
|
|
1185
|
+
return
|
|
1186
|
+
|
|
1187
|
+
tool_calls = session.get("tool_calls", [])
|
|
1188
|
+
if len(tool_calls) <= self._last_checked_tool_count:
|
|
1189
|
+
return
|
|
1190
|
+
|
|
1191
|
+
new_calls = tool_calls[self._last_checked_tool_count:]
|
|
1192
|
+
self._last_checked_tool_count = len(tool_calls)
|
|
1193
|
+
|
|
1194
|
+
# Look for heavy operations that should have been delegated
|
|
1195
|
+
for tc in new_calls:
|
|
1196
|
+
tool = tc.get("tool", "")
|
|
1197
|
+
args = tc.get("args", {})
|
|
1198
|
+
summary = tc.get("result_summary", "")
|
|
1199
|
+
|
|
1200
|
+
# Detected a large file read without delegation
|
|
1201
|
+
if tool in ("Read", "read", "view_file"):
|
|
1202
|
+
try:
|
|
1203
|
+
# Parse lines from summary if possible (e.g. "850L" or "850 lines")
|
|
1204
|
+
lines = 0
|
|
1205
|
+
if "L" in summary:
|
|
1206
|
+
lines = int(summary.split("L")[0].split()[-1])
|
|
1207
|
+
if lines > self.lookback_lines:
|
|
1208
|
+
path_str = args.get("file_path", args.get("AbsolutePath", ""))
|
|
1209
|
+
if path_str:
|
|
1210
|
+
file_name = Path(path_str).name
|
|
1211
|
+
self.notify(
|
|
1212
|
+
"info", "Delegate opportunity",
|
|
1213
|
+
f"You read {lines} lines from {file_name}. Next time, use `c3_delegate(task_type='explain', file_path='...')` to save Claude tokens."
|
|
1214
|
+
)
|
|
1215
|
+
return # one tip per cycle is enough
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|
|
1218
|
+
|
|
1219
|
+
# Detected an error output from Bash/Run Command
|
|
1220
|
+
if tool in ("Bash", "run_command"):
|
|
1221
|
+
# We can't see the full output here, but we can check if it failed
|
|
1222
|
+
if "err" in summary.lower() or "fail" in summary.lower() or "exit code" in summary.lower():
|
|
1223
|
+
self.notify(
|
|
1224
|
+
"info", "Delegate opportunity",
|
|
1225
|
+
"Command failed. Use `c3_delegate(task_type='diagnose', task='<error output>')` to have local AI root-cause the issue."
|
|
1226
|
+
)
|
|
1227
|
+
return
|
|
1228
|
+
|
|
1229
|
+
# Heavy compression usage
|
|
1230
|
+
if tool == "c3_compress" and len(new_calls) > 3:
|
|
1231
|
+
# Count recent compressions
|
|
1232
|
+
recent_comps = sum(1 for c in new_calls if c.get("tool") == "c3_compress")
|
|
1233
|
+
if recent_comps >= 3:
|
|
1234
|
+
self.notify(
|
|
1235
|
+
"info", "Delegate opportunity",
|
|
1236
|
+
"Multiple files compressed. If you need a summary of them, use `c3_delegate(task_type='summarize')` instead of doing it yourself."
|
|
1237
|
+
)
|
|
1238
|
+
return
|
|
1239
|
+
|
|
1240
|
+
# Detect c3_read thrashing — many symbol reads on same file without a structural map
|
|
1241
|
+
c3_reads = [tc for tc in new_calls if tc.get("tool") == "c3_read"]
|
|
1242
|
+
if len(c3_reads) >= 3:
|
|
1243
|
+
read_file_counts = Counter(
|
|
1244
|
+
tc.get("args", {}).get("file_path", "").split(",")[0]
|
|
1245
|
+
for tc in c3_reads
|
|
1246
|
+
if tc.get("args", {}).get("file_path", "")
|
|
1247
|
+
)
|
|
1248
|
+
compress_files = {tc.get("args", {}).get("file_path", "")
|
|
1249
|
+
for tc in new_calls if tc.get("tool") == "c3_compress"}
|
|
1250
|
+
for file_path, count in read_file_counts.items():
|
|
1251
|
+
if count >= 3 and file_path not in compress_files:
|
|
1252
|
+
fname = Path(file_path).name if file_path else "file"
|
|
1253
|
+
self.notify(
|
|
1254
|
+
"warning", "Read loop detected",
|
|
1255
|
+
f"`c3_read` called {count}x on '{fname}' — stop and use "
|
|
1256
|
+
f"`c3_compress(file_path='{file_path}', mode='map')` to see all symbols at once, "
|
|
1257
|
+
"or delegate with `c3_delegate(task_type='investigate')`."
|
|
1258
|
+
)
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
class KeyFileVersionAgent(BackgroundAgent):
|
|
1263
|
+
"""Tracks key file versions and warns when agent-facing files drift."""
|
|
1264
|
+
|
|
1265
|
+
def __init__(self, version_tracker, notifications, ide_name: str = "claude-code",
|
|
1266
|
+
enabled=True, interval=180, max_changes_per_notice: int = 4, agent_target: str = "current", **kwargs):
|
|
1267
|
+
super().__init__("KeyFileVersion", interval, notifications, enabled, **kwargs)
|
|
1268
|
+
self.version_tracker = version_tracker
|
|
1269
|
+
self.ide_name = ide_name
|
|
1270
|
+
self.max_changes_per_notice = max_changes_per_notice
|
|
1271
|
+
self.agent_target = agent_target
|
|
1272
|
+
self._primed = False
|
|
1273
|
+
|
|
1274
|
+
def check(self):
|
|
1275
|
+
if not self.version_tracker:
|
|
1276
|
+
return
|
|
1277
|
+
result = self.version_tracker.scan(agent=self.agent_target)
|
|
1278
|
+
changed = result.get("changed", [])
|
|
1279
|
+
if not self._primed:
|
|
1280
|
+
self._primed = True
|
|
1281
|
+
return
|
|
1282
|
+
if not changed:
|
|
1283
|
+
return
|
|
1284
|
+
|
|
1285
|
+
sample = changed[:self.max_changes_per_notice]
|
|
1286
|
+
files = ", ".join(item["file"] for item in sample)
|
|
1287
|
+
if len(changed) > len(sample):
|
|
1288
|
+
files += f" (+{len(changed) - len(sample)} more)"
|
|
1289
|
+
dirty = sum(1 for item in changed if (item.get("git", {}) or {}).get("dirty"))
|
|
1290
|
+
severity = "warning" if dirty else "info"
|
|
1291
|
+
target = self.ide_name if self.agent_target in ("", "current", None) else self.agent_target
|
|
1292
|
+
self.notify(
|
|
1293
|
+
severity,
|
|
1294
|
+
"Key file versions changed",
|
|
1295
|
+
f"{files}. Tailored target: {target}. Git dirty: {dirty}.",
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
class EditLedgerEnricherAgent(BackgroundAgent):
|
|
1300
|
+
"""Asynchronously enriches edit ledger entries with git info and syntax validation.
|
|
1301
|
+
|
|
1302
|
+
Runs on a short interval after the hook logs entries with git_pending=True.
|
|
1303
|
+
Appends patch entries to the ledger (append-only; readers merge on the fly).
|
|
1304
|
+
Also validates recently edited files and notifies on syntax errors.
|
|
1305
|
+
Optionally runs a Codex verification pass on enriched diffs.
|
|
1306
|
+
"""
|
|
1307
|
+
|
|
1308
|
+
def __init__(self, edit_ledger, validation_cache, notifications,
|
|
1309
|
+
delegate_config=None, project_path=None,
|
|
1310
|
+
enabled=True, interval=30, **kwargs):
|
|
1311
|
+
super().__init__("EditLedgerEnricher", interval, notifications, enabled, **kwargs)
|
|
1312
|
+
self.edit_ledger = edit_ledger
|
|
1313
|
+
self.validation_cache = validation_cache
|
|
1314
|
+
self.delegate_config = delegate_config or {}
|
|
1315
|
+
self.project_path = project_path
|
|
1316
|
+
self._verified_ids: set = set() # track already-verified entries
|
|
1317
|
+
|
|
1318
|
+
def check(self):
|
|
1319
|
+
# Git enrichment — processes entries marked git_pending=True
|
|
1320
|
+
enriched_count = self.edit_ledger.enrich_pending(batch=10)
|
|
1321
|
+
|
|
1322
|
+
# Syntax validation — validates recently edited files
|
|
1323
|
+
validated = self.edit_ledger.validate_pending(
|
|
1324
|
+
batch=5, validation_cache=self.validation_cache
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
failures = [v for v in validated if not v.get("valid", True)]
|
|
1328
|
+
if failures:
|
|
1329
|
+
files = ", ".join(v["file"] for v in failures[:3])
|
|
1330
|
+
extra = f" (+{len(failures) - 3} more)" if len(failures) > 3 else ""
|
|
1331
|
+
self.notify(
|
|
1332
|
+
"warning",
|
|
1333
|
+
"Syntax errors in edited files",
|
|
1334
|
+
f"{len(failures)} file(s) have syntax errors: {files}{extra}",
|
|
1335
|
+
replace_if_unacked=True,
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
# Codex verification — optional, runs on enriched entries with diffs
|
|
1339
|
+
if enriched_count and self.delegate_config.get("codex_enabled") and \
|
|
1340
|
+
self.delegate_config.get("codex_verify_edits", False):
|
|
1341
|
+
self._codex_verify_recent()
|
|
1342
|
+
|
|
1343
|
+
if not enriched_count and not validated:
|
|
1344
|
+
return False
|
|
1345
|
+
|
|
1346
|
+
def _codex_verify_recent(self):
|
|
1347
|
+
"""Run a Codex read-only review on recently enriched diffs."""
|
|
1348
|
+
try:
|
|
1349
|
+
from cli.tools.delegate import _codex_available, _run_codex, check_codex
|
|
1350
|
+
if _codex_available is None:
|
|
1351
|
+
check_codex()
|
|
1352
|
+
from cli.tools.delegate import _codex_available as avail
|
|
1353
|
+
if not avail:
|
|
1354
|
+
return
|
|
1355
|
+
|
|
1356
|
+
# Gather recent enriched entries with diffs
|
|
1357
|
+
recent = self.edit_ledger.get_history(limit=10)
|
|
1358
|
+
diffs = []
|
|
1359
|
+
for entry in recent:
|
|
1360
|
+
eid = entry.get("id", "")
|
|
1361
|
+
if eid in self._verified_ids:
|
|
1362
|
+
continue
|
|
1363
|
+
diff_summary = entry.get("diff_summary", "")
|
|
1364
|
+
if diff_summary and len(diff_summary) > 20:
|
|
1365
|
+
diffs.append((eid, entry.get("file", "unknown"), diff_summary))
|
|
1366
|
+
|
|
1367
|
+
if not diffs:
|
|
1368
|
+
return
|
|
1369
|
+
|
|
1370
|
+
# Batch up to 3 diffs into one Codex call
|
|
1371
|
+
batch = diffs[:3]
|
|
1372
|
+
combined = "\n".join(
|
|
1373
|
+
f"--- {f} ---\n{d}" for _, f, d in batch
|
|
1374
|
+
)
|
|
1375
|
+
task = "Review these recent edits for regressions, bugs, or issues. Be concise — only flag real problems."
|
|
1376
|
+
model = self.delegate_config.get("codex_default_model", "gpt-5.3-codex-spark")
|
|
1377
|
+
timeout = int(self.delegate_config.get("codex_timeout", 120))
|
|
1378
|
+
|
|
1379
|
+
output, ok = _run_codex(
|
|
1380
|
+
task=task, context=combined,
|
|
1381
|
+
model=model, sandbox="read-only",
|
|
1382
|
+
reasoning="medium", timeout=timeout,
|
|
1383
|
+
cwd=str(self.project_path) if self.project_path else None,
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
for eid, _, _ in batch:
|
|
1387
|
+
self._verified_ids.add(eid)
|
|
1388
|
+
# Cap memory
|
|
1389
|
+
if len(self._verified_ids) > 200:
|
|
1390
|
+
self._verified_ids = set(list(self._verified_ids)[-100:])
|
|
1391
|
+
|
|
1392
|
+
if ok and output and not output.startswith("[codex:"):
|
|
1393
|
+
# Only notify if Codex found actual issues (not just "looks good")
|
|
1394
|
+
lower = output.lower()
|
|
1395
|
+
benign = ("no issues", "looks good", "no problems", "no regressions", "lgtm", "all good")
|
|
1396
|
+
if not any(b in lower for b in benign):
|
|
1397
|
+
files = ", ".join(f for _, f, _ in batch)
|
|
1398
|
+
self.notify(
|
|
1399
|
+
"info",
|
|
1400
|
+
"Codex edit verification",
|
|
1401
|
+
f"Codex flagged potential issues in: {files}\n\n{output[:500]}",
|
|
1402
|
+
replace_if_unacked=True,
|
|
1403
|
+
)
|
|
1404
|
+
except Exception:
|
|
1405
|
+
pass # non-critical — never break the enrichment loop
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def create_agents(services, notifications, config=None, ollama=None) -> list:
|
|
1409
|
+
"""Factory to instantiate all background agents with service references.
|
|
1410
|
+
|
|
1411
|
+
config: optional dict from .c3/config.json "agents" key, e.g.:
|
|
1412
|
+
{"IndexStaleness": {"enabled": true, "interval": 90}, "MemoryPruner": {"enabled": false}}
|
|
1413
|
+
ollama: optional OllamaClient instance for AI-enhanced agent behavior.
|
|
1414
|
+
"""
|
|
1415
|
+
config = config or {}
|
|
1416
|
+
|
|
1417
|
+
def _cfg(name, defaults):
|
|
1418
|
+
overrides = config.get(name, {})
|
|
1419
|
+
merged = {**defaults, **overrides}
|
|
1420
|
+
# Inject ollama into all agents
|
|
1421
|
+
merged["ollama"] = ollama
|
|
1422
|
+
return merged
|
|
1423
|
+
|
|
1424
|
+
agents = [
|
|
1425
|
+
IndexStalenessAgent(
|
|
1426
|
+
watcher=services.watcher,
|
|
1427
|
+
indexer=services.indexer,
|
|
1428
|
+
notifications=notifications,
|
|
1429
|
+
**_cfg("IndexStaleness", {
|
|
1430
|
+
"enabled": True, "interval": 60, "use_ai": False,
|
|
1431
|
+
"ai_model": "gemma3n:latest", "warn_threshold": 5, "rebuild_threshold": 15,
|
|
1432
|
+
}),
|
|
1433
|
+
),
|
|
1434
|
+
MemoryPrunerAgent(
|
|
1435
|
+
memory=services.memory,
|
|
1436
|
+
notifications=notifications,
|
|
1437
|
+
**_cfg("MemoryPruner", {
|
|
1438
|
+
"enabled": False, "interval": 300, "use_ai": True,
|
|
1439
|
+
"ai_model": "gemma3n:latest", "embed_model": "nomic-embed-text",
|
|
1440
|
+
"similarity_threshold": 0.8,
|
|
1441
|
+
}),
|
|
1442
|
+
),
|
|
1443
|
+
ClaudeMdDriftAgent(
|
|
1444
|
+
watcher=services.watcher,
|
|
1445
|
+
claude_md=services.claude_md,
|
|
1446
|
+
notifications=notifications,
|
|
1447
|
+
**_cfg("ClaudeMdDrift", {
|
|
1448
|
+
"enabled": False, "interval": 120, "use_ai": False, "ai_model": "gemma3n:latest",
|
|
1449
|
+
}),
|
|
1450
|
+
),
|
|
1451
|
+
SessionInsightAgent(
|
|
1452
|
+
session_mgr=services.session_mgr,
|
|
1453
|
+
memory=services.memory,
|
|
1454
|
+
notifications=notifications,
|
|
1455
|
+
**_cfg("SessionInsight", {
|
|
1456
|
+
"enabled": False, "interval": 600, "use_ai": True,
|
|
1457
|
+
"ai_model": "gemma3n:latest", "min_tool_calls": 10,
|
|
1458
|
+
}),
|
|
1459
|
+
),
|
|
1460
|
+
AutonomyPlannerAgent(
|
|
1461
|
+
session_mgr=services.session_mgr,
|
|
1462
|
+
watcher=services.watcher,
|
|
1463
|
+
notifications=notifications,
|
|
1464
|
+
**_cfg("AutonomyPlanner", {
|
|
1465
|
+
"enabled": False, "interval": 240, "use_ai": True,
|
|
1466
|
+
"ai_model": "gemma3n:latest", "lookback_tool_calls": 30,
|
|
1467
|
+
"cooldown_seconds": 600, "min_signal_score": 2, "max_actions": 3,
|
|
1468
|
+
}),
|
|
1469
|
+
),
|
|
1470
|
+
ClaudeMdUpdaterAgent(
|
|
1471
|
+
claude_md=services.claude_md,
|
|
1472
|
+
memory=services.memory,
|
|
1473
|
+
session_mgr=services.session_mgr,
|
|
1474
|
+
watcher=services.watcher,
|
|
1475
|
+
notifications=notifications,
|
|
1476
|
+
**_cfg("ClaudeMdUpdater", {
|
|
1477
|
+
"enabled": False, "interval": 900, "use_ai": True,
|
|
1478
|
+
"ai_model": "gemma3n:latest", "auto_apply": True,
|
|
1479
|
+
"min_facts_for_promote": 2,
|
|
1480
|
+
}),
|
|
1481
|
+
),
|
|
1482
|
+
DelegateCoachAgent(
|
|
1483
|
+
session_mgr=services.session_mgr,
|
|
1484
|
+
notifications=notifications,
|
|
1485
|
+
**_cfg("DelegateCoach", {
|
|
1486
|
+
"enabled": False, "interval": 180, "use_ai": False,
|
|
1487
|
+
}),
|
|
1488
|
+
),
|
|
1489
|
+
KeyFileVersionAgent(
|
|
1490
|
+
version_tracker=getattr(services, "version_tracker", None),
|
|
1491
|
+
notifications=notifications,
|
|
1492
|
+
ide_name=getattr(services, "ide_name", "claude-code"),
|
|
1493
|
+
**_cfg("KeyFileVersion", {
|
|
1494
|
+
"enabled": False, "interval": 180, "use_ai": False,
|
|
1495
|
+
"agent_target": "current", "max_changes_per_notice": 4,
|
|
1496
|
+
}),
|
|
1497
|
+
),
|
|
1498
|
+
]
|
|
1499
|
+
|
|
1500
|
+
# FileMemoryAgent — only if file_memory is available on services
|
|
1501
|
+
if hasattr(services, 'file_memory') and services.file_memory:
|
|
1502
|
+
agents.append(
|
|
1503
|
+
FileMemoryAgent(
|
|
1504
|
+
file_memory=services.file_memory,
|
|
1505
|
+
watcher=services.watcher,
|
|
1506
|
+
notifications=notifications,
|
|
1507
|
+
**_cfg("FileMemory", {
|
|
1508
|
+
"enabled": True, "interval": 120, "use_ai": False,
|
|
1509
|
+
"ai_model": "gemma3n:latest", "max_files_per_cycle": 5,
|
|
1510
|
+
}),
|
|
1511
|
+
)
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
# EditLedgerEnricherAgent — only if edit_ledger is available
|
|
1515
|
+
if getattr(services, 'edit_ledger', None):
|
|
1516
|
+
agents.append(
|
|
1517
|
+
EditLedgerEnricherAgent(
|
|
1518
|
+
edit_ledger=services.edit_ledger,
|
|
1519
|
+
validation_cache=getattr(services, 'validation_cache', None),
|
|
1520
|
+
delegate_config=getattr(services, 'delegate_config', None),
|
|
1521
|
+
project_path=getattr(services, 'project_path', None),
|
|
1522
|
+
notifications=notifications,
|
|
1523
|
+
**_cfg("EditLedgerEnricher", {
|
|
1524
|
+
"enabled": True, "interval": 10, "use_ai": False,
|
|
1525
|
+
}),
|
|
1526
|
+
)
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
return agents
|