code-context-control 2.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
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