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