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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
@@ -0,0 +1,246 @@
1
+ """Memory Scoring Engine — composite salience scores for facts.
2
+
3
+ Replaces raw relevance_count with a multi-signal score that captures
4
+ recency, frequency, cross-session spread, co-activation strength,
5
+ source authority, confirmation history, and contradiction penalties.
6
+
7
+ Each signal is normalized to [0, 1] and combined via weighted sum.
8
+ The final salience score determines recall ranking, consolidation
9
+ priority, and pruning eligibility.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from datetime import datetime, timezone
16
+ from typing import Any
17
+
18
+ # ── Signal weights (sum to 1.0) ─────────────────────────────────────
19
+ DEFAULT_WEIGHTS = {
20
+ "recency": 0.20,
21
+ "frequency": 0.15,
22
+ "cross_session": 0.20,
23
+ "co_activation": 0.10,
24
+ "source_auth": 0.10,
25
+ "confirmation": 0.15,
26
+ "contradiction": 0.10,
27
+ }
28
+
29
+ # ── Tier thresholds ─────────────────────────────────────────────────
30
+ TIER_CORE = 0.70 # immune to pruning
31
+ TIER_ACTIVE = 0.40 # normal retention
32
+ TIER_DORMANT = 0.20 # candidate for archival
33
+ # below DORMANT = ephemeral, auto-pruned
34
+
35
+
36
+ class MemoryScorer:
37
+ """Computes and caches composite salience scores for memory facts."""
38
+
39
+ _CACHE_MAX = 512
40
+
41
+ def __init__(self, weights: dict[str, float] | None = None):
42
+ w = {**DEFAULT_WEIGHTS, **(weights or {})}
43
+ total = sum(w.values())
44
+ # Normalize so weights always sum to 1.0
45
+ self.weights = {k: v / total for k, v in w.items()}
46
+ # Bounded score cache keyed on fact state. Natural invalidation via
47
+ # relevance-bucket and signal-count keys; no explicit busting needed
48
+ # except on graph-wide mutations (see invalidate_all).
49
+ self._score_cache: dict[tuple, dict] = {}
50
+
51
+ # ── Public API ──────────────────────────────────────────────────
52
+
53
+ def _cache_key(self, fact: dict) -> tuple:
54
+ """Cache key tolerates log-scale rc drift (every 5 recalls → new entry)."""
55
+ rc = int(fact.get("relevance_count", 0))
56
+ return (
57
+ fact.get("id", ""),
58
+ rc // 5,
59
+ len(fact.get("recall_sessions") or []),
60
+ int(fact.get("confirmation_count", 0)),
61
+ int(fact.get("contradiction_count", 0)),
62
+ fact.get("source_quality", ""),
63
+ )
64
+
65
+ def invalidate(self, fact_id: str) -> None:
66
+ """Drop cached scores for a single fact (called on update/delete)."""
67
+ for key in [k for k in self._score_cache if k and k[0] == fact_id]:
68
+ self._score_cache.pop(key, None)
69
+
70
+ def invalidate_all(self) -> None:
71
+ """Drop the entire cache (called on graph-wide mutations)."""
72
+ self._score_cache.clear()
73
+
74
+ def score(self, fact: dict, graph: Any = None) -> dict:
75
+ """Compute all signals and return a scoring breakdown.
76
+
77
+ Args:
78
+ fact: A fact dict from MemoryStore.
79
+ graph: Optional MemoryGraph for co-activation lookups.
80
+
81
+ Returns:
82
+ dict with per-signal scores, weighted total, and tier.
83
+ """
84
+ key = self._cache_key(fact)
85
+ cached = self._score_cache.get(key)
86
+ if cached is not None:
87
+ return cached
88
+
89
+ signals = {
90
+ "recency": self._recency(fact),
91
+ "frequency": self._frequency(fact),
92
+ "cross_session": self._cross_session(fact),
93
+ "co_activation": self._co_activation(fact, graph),
94
+ "source_auth": self._source_authority(fact),
95
+ "confirmation": self._confirmation(fact),
96
+ "contradiction": self._contradiction(fact),
97
+ }
98
+
99
+ salience = sum(
100
+ self.weights[k] * v for k, v in signals.items()
101
+ )
102
+ salience = round(max(0.0, min(1.0, salience)), 4)
103
+
104
+ tier = self._tier(salience)
105
+
106
+ result = {
107
+ "salience": salience,
108
+ "tier": tier,
109
+ "signals": {k: round(v, 4) for k, v in signals.items()},
110
+ }
111
+
112
+ if len(self._score_cache) >= self._CACHE_MAX:
113
+ # Drop oldest ~20% (dict is insertion-ordered)
114
+ victims = list(self._score_cache.keys())[: self._CACHE_MAX // 5]
115
+ for v in victims:
116
+ self._score_cache.pop(v, None)
117
+ self._score_cache[key] = result
118
+ return result
119
+
120
+ def score_batch(self, facts: list[dict], graph: Any = None) -> list[dict]:
121
+ """Score a list of facts. Returns list of (fact_id, score_dict) pairs."""
122
+ results = []
123
+ for f in facts:
124
+ s = self.score(f, graph)
125
+ results.append({"id": f.get("id", ""), **s})
126
+ results.sort(key=lambda x: x["salience"], reverse=True)
127
+ return results
128
+
129
+ def tier_partition(self, facts: list[dict], graph: Any = None) -> dict[str, list[dict]]:
130
+ """Partition facts into tier buckets."""
131
+ buckets: dict[str, list[dict]] = {
132
+ "core": [], "active": [], "dormant": [], "ephemeral": [],
133
+ }
134
+ for f in facts:
135
+ s = self.score(f, graph)
136
+ f_with_score = {**f, "_score": s}
137
+ buckets[s["tier"]].append(f_with_score)
138
+ return buckets
139
+
140
+ # ── Signal functions (each returns 0.0 - 1.0) ──────────────────
141
+
142
+ def _recency(self, fact: dict) -> float:
143
+ """Exponential decay based on last access or creation time."""
144
+ ref = fact.get("last_accessed_at") or fact.get("timestamp")
145
+ if not ref:
146
+ return 0.0
147
+ try:
148
+ dt = datetime.fromisoformat(ref)
149
+ if dt.tzinfo is None:
150
+ dt = dt.replace(tzinfo=timezone.utc)
151
+ age_days = (datetime.now(timezone.utc) - dt).total_seconds() / 86400
152
+ except (ValueError, TypeError):
153
+ return 0.0
154
+ # Half-life of 14 days: score halves every 14 days of inactivity
155
+ half_life = 14.0
156
+ return math.exp(-0.693 * age_days / half_life)
157
+
158
+ def _frequency(self, fact: dict) -> float:
159
+ """Log-scaled recall count with diminishing returns."""
160
+ count = int(fact.get("relevance_count", 0))
161
+ if count <= 0:
162
+ return 0.0
163
+ # log2(count+1) / log2(max_expected+1), capped at 1.0
164
+ # 32 recalls = score 1.0
165
+ return min(1.0, math.log2(count + 1) / 5.0)
166
+
167
+ def _cross_session(self, fact: dict) -> float:
168
+ """How many distinct sessions have recalled this fact."""
169
+ sessions = fact.get("recall_sessions", [])
170
+ if isinstance(sessions, list):
171
+ n = len(set(sessions))
172
+ else:
173
+ n = 0
174
+ # Also count source session
175
+ if fact.get("source_session"):
176
+ n = max(n, 1)
177
+ # 5+ sessions = max score
178
+ return min(1.0, n / 5.0)
179
+
180
+ def _co_activation(self, fact: dict, graph: Any = None) -> float:
181
+ """Strength of connections to other facts in the graph."""
182
+ if graph is None:
183
+ return 0.5 # neutral when no graph available
184
+ fact_id = fact.get("id", "")
185
+ if not fact_id:
186
+ return 0.0
187
+ edges = graph.get_edges(fact_id)
188
+ if not edges:
189
+ return 0.0
190
+ # Sum of edge weights, capped
191
+ total_weight = sum(e.get("weight", 1) for e in edges)
192
+ # 10+ total weight = max score
193
+ return min(1.0, total_weight / 10.0)
194
+
195
+ def _source_authority(self, fact: dict) -> float:
196
+ """User-provided facts score higher than auto-extracted."""
197
+ quality = fact.get("source_quality", "user")
198
+ authority_map = {
199
+ "user": 1.0,
200
+ "validated": 0.9,
201
+ "inferred": 0.6,
202
+ "auto": 0.4,
203
+ "auto:search": 0.3,
204
+ "auto:session": 0.2,
205
+ }
206
+ # Also check category prefix for auto-categorized facts
207
+ cat = fact.get("category", "")
208
+ if quality in authority_map:
209
+ base = authority_map[quality]
210
+ elif cat.startswith("auto:"):
211
+ base = authority_map.get(cat, 0.4)
212
+ else:
213
+ base = 0.5
214
+ return base
215
+
216
+ def _confirmation(self, fact: dict) -> float:
217
+ """How many times this fact has been confirmed/validated."""
218
+ count = int(fact.get("confirmation_count", 0))
219
+ if count <= 0:
220
+ return 0.0
221
+ # 3 confirmations = max score
222
+ return min(1.0, count / 3.0)
223
+
224
+ def _contradiction(self, fact: dict) -> float:
225
+ """Inverse penalty — contradictions reduce this score.
226
+
227
+ Returns 1.0 for no contradictions, 0.0 for heavily contradicted.
228
+ This is inverted so that the weighted sum penalizes contradictions.
229
+ """
230
+ count = int(fact.get("contradiction_count", 0))
231
+ if count <= 0:
232
+ return 1.0 # no contradictions = full score
233
+ # Each contradiction halves the score
234
+ return max(0.0, 1.0 / (1.0 + count))
235
+
236
+ # ── Tier classification ─────────────────────────────────────────
237
+
238
+ @staticmethod
239
+ def _tier(salience: float) -> str:
240
+ if salience >= TIER_CORE:
241
+ return "core"
242
+ if salience >= TIER_ACTIVE:
243
+ return "active"
244
+ if salience >= TIER_DORMANT:
245
+ return "dormant"
246
+ return "ephemeral"
services/metrics.py ADDED
@@ -0,0 +1,86 @@
1
+ """MetricsCollector — Aggregates metrics from all hybrid tier services.
2
+
3
+ Provides a single unified view of:
4
+ - Tier 1 (Output Filter): calls, raw/filtered tokens, savings
5
+ - Tier 2 (Router): routing decisions per class, latency, failures
6
+ - Tier 3 (SLTM): collection sizes, backend status
7
+ """
8
+
9
+
10
+ class MetricsCollector:
11
+ """Aggregates metrics from output_filter, router, vector_store, and activity_log."""
12
+
13
+ def __init__(self, output_filter=None, router=None, vector_store=None, activity_log=None):
14
+ self.output_filter = output_filter
15
+ self.router = router
16
+ self.vector_store = vector_store
17
+ self.activity_log = activity_log
18
+
19
+ def collect(self) -> dict:
20
+ """Gather metrics from all tier services."""
21
+ result = {
22
+ "tier1_filter": None,
23
+ "tier2_router": None,
24
+ "tier3_sltm": None,
25
+ "precision": None,
26
+ }
27
+
28
+ if self.output_filter:
29
+ try:
30
+ result["tier1_filter"] = self.output_filter.get_metrics()
31
+ except Exception:
32
+ result["tier1_filter"] = {"error": "failed to collect"}
33
+
34
+ if self.router:
35
+ try:
36
+ result["tier2_router"] = self.router.get_metrics()
37
+ except Exception:
38
+ result["tier2_router"] = {"error": "failed to collect"}
39
+
40
+ if self.vector_store:
41
+ try:
42
+ result["tier3_sltm"] = self.vector_store.get_stats()
43
+ except Exception:
44
+ result["tier3_sltm"] = {"error": "failed to collect"}
45
+
46
+ if self.activity_log:
47
+ try:
48
+ stats = self.activity_log.get_stats()
49
+ # Determine precision: % of tool calls that are c3_*
50
+ # We need to look at actual tool names if possible, but for now
51
+ # we use the 'by_type' counts if they distinguish.
52
+ # (Refinement: ActivityLog should ideally track tool_name in tool_call events)
53
+ # For now, we'll provide a placeholder or return the raw counts.
54
+ result["precision"] = stats.get("by_type", {})
55
+ except Exception:
56
+ pass
57
+
58
+ return result
59
+
60
+ def summary(self) -> str:
61
+ """One-line summary of all tiers."""
62
+ metrics = self.collect()
63
+ parts = []
64
+
65
+ t1 = metrics.get("tier1_filter")
66
+ if t1 and "calls" in t1:
67
+ parts.append(f"filter:{t1['calls']}calls,{t1.get('total_savings_pct', 0)}%saved")
68
+
69
+ t2 = metrics.get("tier2_router")
70
+ if t2 and "total_routes" in t2:
71
+ parts.append(f"router:{t2['total_routes']}routes,{t2.get('avg_latency_ms', 0)}ms")
72
+
73
+ t3 = metrics.get("tier3_sltm")
74
+ if t3 and "total_records" in t3:
75
+ vec = "on" if t3.get("vector_enabled") else "off"
76
+ parts.append(f"sltm:{t3['total_records']}rec,vec={vec}")
77
+
78
+ # Precision metric: ratio of precision tools
79
+ p = metrics.get("precision", {})
80
+ total_calls = p.get("tool_call", 0)
81
+ if total_calls > 0:
82
+ # This is just a count of tool_call events, not tool names yet.
83
+ # To be truly useful, we'd need to parse the log for 'c3_' prefixes.
84
+ pass
85
+
86
+ return " | ".join(parts) if parts else "no hybrid services active"
@@ -0,0 +1,209 @@
1
+ """NotificationStore — Thread-safe notification queue for background agents.
2
+
3
+ Persists to .c3/notifications.jsonl. Supports dedup, severity filtering,
4
+ and auto-acknowledgement when surfaced to Claude via tool responses.
5
+ """
6
+ import hashlib
7
+ import json
8
+ import threading
9
+ import uuid
10
+ from datetime import datetime, timedelta, timezone
11
+ from pathlib import Path
12
+
13
+ # How long to suppress a repeated agent+title after it has been acknowledged.
14
+ _COOLDOWN_MINUTES = {"critical": 5, "warning": 30, "info": 60}
15
+
16
+
17
+ class NotificationStore:
18
+ """Thread-safe JSONL notification store for background agents."""
19
+
20
+ def __init__(self, project_path: str):
21
+ self._file = Path(project_path) / ".c3" / "notifications.jsonl"
22
+ self._file.parent.mkdir(parents=True, exist_ok=True)
23
+ self._lock = threading.Lock()
24
+
25
+ def add(self, agent: str, severity: str, title: str, message: str,
26
+ ai_enhanced: bool = False, replace_if_unacked: bool = False) -> dict | None:
27
+ """Append a notification. Dedup: skip if same agent+title+message already unacknowledged.
28
+
29
+ severity: 'info', 'warning', 'critical'
30
+ replace_if_unacked: if True and an unacked notification with the same agent+title
31
+ already exists, update its message in-place instead of appending a new entry.
32
+ Use for high-frequency agents (budget, index) to prevent pile-up.
33
+ Returns the entry if written/updated, None if deduped.
34
+ """
35
+ with self._lock:
36
+ message_hash = hashlib.md5((message or "").encode("utf-8")).hexdigest()[:12]
37
+ cooldown = timedelta(minutes=_COOLDOWN_MINUTES.get(severity, 30))
38
+ now = datetime.now(timezone.utc)
39
+ entries = self._read_all()
40
+ for existing in entries:
41
+ if existing.get("agent") != agent or existing.get("title") != title:
42
+ continue
43
+ if not existing.get("acknowledged"):
44
+ if replace_if_unacked:
45
+ # Update in-place — prevents repeated pile-up for chatty agents
46
+ existing["message"] = message
47
+ existing["message_hash"] = message_hash
48
+ existing["timestamp"] = now.isoformat()
49
+ existing["ai_enhanced"] = ai_enhanced
50
+ self._write_all(entries)
51
+ return existing
52
+ # Same notification still pending — dedup if message matches
53
+ if existing.get("message_hash") == message_hash:
54
+ return None
55
+ else:
56
+ # Already acknowledged — suppress if within cooldown window
57
+ try:
58
+ acked_at = datetime.fromisoformat(existing["timestamp"])
59
+ if acked_at.tzinfo is None:
60
+ acked_at = acked_at.replace(tzinfo=timezone.utc)
61
+ if now - acked_at < cooldown:
62
+ return None
63
+ except (KeyError, ValueError):
64
+ pass
65
+
66
+ entry = {
67
+ "id": uuid.uuid4().hex[:12],
68
+ "agent": agent,
69
+ "severity": severity,
70
+ "title": title,
71
+ "message": message,
72
+ "message_hash": message_hash,
73
+ "timestamp": now.isoformat(),
74
+ "acknowledged": False,
75
+ "ai_enhanced": ai_enhanced,
76
+ }
77
+ with open(self._file, "a", encoding="utf-8") as f:
78
+ f.write(json.dumps(entry) + "\n")
79
+ return entry
80
+
81
+ def get_pending_count(self) -> int:
82
+ """Return count of unacknowledged warning/critical notifications without consuming them."""
83
+ with self._lock:
84
+ return sum(
85
+ 1 for e in self._read_all()
86
+ if not e.get("acknowledged")
87
+ and e.get("severity") in ("warning", "critical")
88
+ )
89
+
90
+ # Default filter: only notifications a human should act on.
91
+ # Info-level entries are archival; they show up in get_history(), not here.
92
+ _ACTIONABLE = ("warning", "critical")
93
+
94
+ def get_unacknowledged(self, limit: int = 5, severities=None) -> list:
95
+ """Return unacknowledged notifications, newest first.
96
+
97
+ severities: tuple of severities to include. Defaults to actionable
98
+ ('warning', 'critical') so auto-chatter 'info' events don't
99
+ drown real signals. Pass severities=() or ('info','warning','critical')
100
+ to include info events (e.g. for an activity log view).
101
+ """
102
+ if severities is None:
103
+ severities = self._ACTIONABLE
104
+ with self._lock:
105
+ entries = [
106
+ e for e in self._read_all()
107
+ if not e.get("acknowledged")
108
+ and (not severities or e.get("severity") in severities)
109
+ ]
110
+ entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
111
+ return entries[:limit]
112
+
113
+ def get_suppressed_info_count(self) -> int:
114
+ """Count of unacknowledged 'info' notifications not shown in actionable view."""
115
+ with self._lock:
116
+ return sum(
117
+ 1 for e in self._read_all()
118
+ if not e.get("acknowledged") and e.get("severity") == "info"
119
+ )
120
+
121
+ def get_history(self, limit: int = 50) -> list:
122
+ """Return all notifications (including acknowledged) for the activity console, newest first."""
123
+ with self._lock:
124
+ entries = self._read_all()
125
+ entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
126
+ return entries[:limit]
127
+
128
+ def acknowledge(self, notification_id: str) -> bool:
129
+ """Mark a single notification as acknowledged."""
130
+ with self._lock:
131
+ return self._set_ack(lambda e: e.get("id") == notification_id)
132
+
133
+ def acknowledge_all(self) -> int:
134
+ """Mark all unacknowledged notifications as acknowledged. Returns count."""
135
+ with self._lock:
136
+ entries = self._read_all()
137
+ count = 0
138
+ for e in entries:
139
+ if not e.get("acknowledged"):
140
+ e["acknowledged"] = True
141
+ count += 1
142
+ if count:
143
+ self._write_all(entries)
144
+ return count
145
+
146
+ def get_pending_summary(self) -> str:
147
+ """Format up to 3 unacked warning/critical notifications for prepending.
148
+
149
+ Auto-acknowledges those included. Returns empty string if none.
150
+ """
151
+ with self._lock:
152
+ entries = self._read_all()
153
+ pending = [
154
+ e for e in entries
155
+ if not e.get("acknowledged")
156
+ and e.get("severity") in ("warning", "critical")
157
+ ]
158
+ # Newest first
159
+ pending.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
160
+ pending = pending[:3]
161
+
162
+ if not pending:
163
+ return ""
164
+
165
+ # Auto-acknowledge
166
+ pending_ids = {e["id"] for e in pending}
167
+ for e in entries:
168
+ if e.get("id") in pending_ids:
169
+ e["acknowledged"] = True
170
+ self._write_all(entries)
171
+
172
+ # Format
173
+ lines = ["[c3:agents]"]
174
+ for e in pending:
175
+ prefix = "!!" if e["severity"] == "critical" else "!"
176
+ lines.append(f"{prefix} {e['agent']}: {e['title']} — {e['message']}")
177
+ return "\n".join(lines)
178
+
179
+ def _read_all(self) -> list:
180
+ """Read all entries from JSONL file. Caller must hold _lock."""
181
+ if not self._file.exists():
182
+ return []
183
+ entries = []
184
+ for line in self._file.read_text(encoding="utf-8").strip().splitlines():
185
+ if not line.strip():
186
+ continue
187
+ try:
188
+ entries.append(json.loads(line))
189
+ except json.JSONDecodeError:
190
+ continue
191
+ return entries
192
+
193
+ def _write_all(self, entries: list):
194
+ """Rewrite entire file. Caller must hold _lock."""
195
+ with open(self._file, "w", encoding="utf-8") as f:
196
+ for e in entries:
197
+ f.write(json.dumps(e) + "\n")
198
+
199
+ def _set_ack(self, predicate) -> bool:
200
+ """Acknowledge entries matching predicate. Caller must hold _lock."""
201
+ entries = self._read_all()
202
+ found = False
203
+ for e in entries:
204
+ if not e.get("acknowledged") and predicate(e):
205
+ e["acknowledged"] = True
206
+ found = True
207
+ if found:
208
+ self._write_all(entries)
209
+ return found