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
cli/tools/shell.py ADDED
@@ -0,0 +1,193 @@
1
+ """c3_shell — structured shell execution with filter/log/timeout.
2
+
3
+ Wraps subprocess.Popen with the Windows-safe pattern from
4
+ services/edit_ledger.py::_git_combined (Popen + taskkill /F /T + stdin=DEVNULL).
5
+ Auto-filters long stdout via handle_filter, auto-logs git mutations
6
+ to the edit ledger, and accounts stdout tokens against session budget.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from cli.tools.filter import handle_filter
19
+ from core import count_tokens
20
+
21
+ # Commands that mutate repo state — trigger edit-ledger refresh after success.
22
+ _GIT_MUTATING = re.compile(
23
+ r"^\s*git\s+(commit|add|mv|rm|merge|rebase|cherry-pick|revert|reset|restore|checkout)\b"
24
+ )
25
+ # Hard deny — fork bombs, rm -rf on root/home. Escape hatch: native Bash.
26
+ _BLOCKED = re.compile(
27
+ r"(\brm\s+-rf\s+(/|~|\$HOME)(\s|$)|:\(\)\s*\{\s*:\s*\|\s*:)"
28
+ )
29
+ # Soft warn — run but prepend a caveat to the response.
30
+ # `(?<!\w)` / `(?!\w)` anchor against word chars, so `--force` (which starts
31
+ # with a non-word `-`) still matches at word/space boundaries.
32
+ _SOFT_WARN = re.compile(
33
+ r"(?<!\w)(rm\s+-rf|--force|--no-verify|reset\s+--hard|DROP\s+(TABLE|DATABASE)|TRUNCATE)(?!\w)",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+ _DEFAULT_TIMEOUT = 60
38
+ _MAX_TIMEOUT = 600
39
+ _FILTER_THRESHOLD_LINES = 30
40
+
41
+
42
+ def _popen_kwargs() -> dict:
43
+ kw: dict = {"stdin": subprocess.DEVNULL}
44
+ if sys.platform == "win32":
45
+ kw["creationflags"] = subprocess.CREATE_NO_WINDOW
46
+ return kw
47
+
48
+
49
+ def _kill_tree(proc: subprocess.Popen) -> None:
50
+ if sys.platform == "win32":
51
+ subprocess.run(
52
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
53
+ capture_output=True,
54
+ creationflags=subprocess.CREATE_NO_WINDOW,
55
+ )
56
+ else:
57
+ try:
58
+ os.killpg(os.getpgid(proc.pid), 9)
59
+ except (ProcessLookupError, PermissionError, OSError):
60
+ proc.kill()
61
+
62
+
63
+ def _run_sync(cmd: str, cwd: str, timeout: int) -> dict:
64
+ """Blocking subprocess run with hard kill on timeout. Returns structured dict."""
65
+ start = time.time()
66
+ proc = subprocess.Popen(
67
+ cmd, shell=True, cwd=cwd,
68
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
69
+ text=True, errors="replace",
70
+ **_popen_kwargs(),
71
+ )
72
+ timed_out = False
73
+ try:
74
+ stdout, stderr = proc.communicate(timeout=timeout)
75
+ except subprocess.TimeoutExpired:
76
+ _kill_tree(proc)
77
+ try:
78
+ stdout, stderr = proc.communicate(timeout=2)
79
+ except Exception:
80
+ stdout, stderr = "", ""
81
+ timed_out = True
82
+
83
+ return {
84
+ "exit_code": -1 if timed_out else (proc.returncode or 0),
85
+ "stdout": stdout or "",
86
+ "stderr": stderr or "",
87
+ "duration_ms": round((time.time() - start) * 1000),
88
+ "timed_out": timed_out,
89
+ }
90
+
91
+
92
+ def _maybe_refresh_ledger(cmd: str, result: dict, svc) -> list[str]:
93
+ """If git mutated state, capture affected files for the edit ledger."""
94
+ if result["exit_code"] != 0 or not _GIT_MUTATING.match(cmd):
95
+ return []
96
+ if not getattr(svc, "edit_ledger", None):
97
+ return []
98
+ try:
99
+ probe = _run_sync("git diff --name-only HEAD~1..HEAD", svc.project_path, timeout=5)
100
+ files = [f.strip() for f in probe["stdout"].splitlines() if f.strip()]
101
+ for f in files[:20]:
102
+ try:
103
+ svc.edit_ledger.log_edit(
104
+ file=f,
105
+ change_type="shell_git",
106
+ summary=f"via c3_shell: {cmd[:80]}",
107
+ include_git=True,
108
+ )
109
+ except Exception:
110
+ pass
111
+ return files
112
+ except Exception:
113
+ return []
114
+
115
+
116
+ async def handle_shell(cmd: str, cwd: str, timeout: int, filter_output: bool,
117
+ log: bool, svc, finalize) -> str:
118
+ if not cmd or not cmd.strip():
119
+ return "[c3_shell:error] empty command"
120
+ if _BLOCKED.search(cmd):
121
+ return (
122
+ "[c3_shell:error] blocked pattern — use native Bash with explicit "
123
+ "approval if this is truly intended"
124
+ )
125
+
126
+ timeout = max(1, min(int(timeout or _DEFAULT_TIMEOUT), _MAX_TIMEOUT))
127
+ work_cwd = cwd or svc.project_path
128
+ work_cwd = str(Path(work_cwd).resolve())
129
+
130
+ result = await asyncio.to_thread(_run_sync, cmd, work_cwd, timeout)
131
+
132
+ raw_stdout = result["stdout"]
133
+ filtered_note = ""
134
+ if filter_output and raw_stdout.count("\n") > _FILTER_THRESHOLD_LINES:
135
+ try:
136
+ filtered = await asyncio.to_thread(
137
+ handle_filter,
138
+ "", raw_stdout, "", 50, "smart", True,
139
+ svc, lambda *a, **kw: a[2],
140
+ )
141
+ result["stdout_raw_bytes"] = len(raw_stdout)
142
+ result["stdout"] = filtered
143
+ filtered_note = " [stdout filtered]"
144
+ except Exception:
145
+ pass
146
+
147
+ touched_files: list[str] = []
148
+ if log:
149
+ touched_files = _maybe_refresh_ledger(cmd, result, svc)
150
+ if getattr(svc, "activity_log", None):
151
+ try:
152
+ svc.activity_log.log("shell_exec", {
153
+ "cmd": cmd[:200],
154
+ "cwd": work_cwd,
155
+ "exit_code": result["exit_code"],
156
+ "duration_ms": result["duration_ms"],
157
+ "timed_out": result["timed_out"],
158
+ "touched_files": touched_files,
159
+ })
160
+ except Exception:
161
+ pass
162
+
163
+ warn = ""
164
+ if _SOFT_WARN.search(cmd):
165
+ warn = "[c3_shell:warn] destructive pattern detected — verify before re-running\n"
166
+
167
+ if result["timed_out"]:
168
+ status = "TIMEOUT"
169
+ elif result["exit_code"] == 0:
170
+ status = "OK"
171
+ else:
172
+ status = f"FAIL({result['exit_code']})"
173
+
174
+ body = (
175
+ f"{warn}"
176
+ f"[c3_shell:{status}] {result['duration_ms']}ms{filtered_note}\n"
177
+ f"$ {cmd}\n"
178
+ f"--- stdout ---\n{result['stdout'].rstrip()}\n"
179
+ )
180
+ if result["stderr"].strip():
181
+ body += f"--- stderr ---\n{result['stderr'].rstrip()}\n"
182
+ if touched_files:
183
+ body += f"--- ledger ---\nlogged {len(touched_files)} file(s)\n"
184
+
185
+ summary = f"shell {status} in {result['duration_ms']}ms"
186
+ resp_tokens = count_tokens(body) if body else 0
187
+ return finalize(
188
+ "c3_shell",
189
+ {"cmd": cmd[:120], "cwd": work_cwd},
190
+ body,
191
+ summary,
192
+ response_tokens=resp_tokens,
193
+ )
cli/tools/status.py ADDED
@@ -0,0 +1,306 @@
1
+ """c3_status — Budget, health, notifications, and ghost-file sweep (4+ views).
2
+
3
+ Removed views (available via REST API/CLI):
4
+ 'why', 'raw', 'optimize' — use `c3 status <view>` CLI command instead.
5
+ 'tokens', 'memory' — merged into 'budget' (detailed=True) and 'health' respectively.
6
+ """
7
+
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from cli.hook_ghost_files import cleanup_ghost_files, scan_ghost_files
13
+ from core import format_token_count
14
+
15
+
16
+ def handle_status(view: str, detailed: bool, svc, finalize) -> str:
17
+ if view == "budget":
18
+ return _budget_view(svc, detailed, finalize)
19
+
20
+ if view == "health":
21
+ return _health_view(svc, finalize)
22
+
23
+ if view == "notifications":
24
+ return _notifications_view(svc, finalize)
25
+
26
+ if view == "sessions":
27
+ return _sessions_view(svc, finalize)
28
+
29
+ if view == "ghost_files":
30
+ return _ghost_files_view(svc, finalize)
31
+
32
+ # Graceful migration for removed views
33
+ removed = {
34
+ "tokens": "Merged into 'budget'. Use c3_status(view='budget', detailed=True).",
35
+ "memory": "Merged into 'health'. Use c3_status(view='health').",
36
+ "why": "Available via CLI: `c3 status why`",
37
+ "raw": "Available via CLI: `c3 status raw`",
38
+ "optimize": "Available via CLI: `c3 status optimize`",
39
+ }
40
+ if view in removed:
41
+ return finalize("c3_status", {"view": view},
42
+ f"[status:moved] '{view}' view removed from MCP. {removed[view]}", "moved")
43
+
44
+ return f"[status:error] Unknown view: {view}. Available: budget, health, notifications, sessions, ghost_files"
45
+
46
+
47
+ def _budget_view(svc, detailed, finalize):
48
+ snap = svc.session_mgr.get_budget_snapshot()
49
+ if "error" in snap:
50
+ return f"[ctx_status] {snap['error']}"
51
+
52
+ tokens = snap["response_tokens"]
53
+ threshold = snap["threshold"]
54
+ pct = round(tokens / threshold * 100) if threshold > 0 else 0
55
+
56
+ # Session age
57
+ age_str = ""
58
+ sess = svc.session_mgr.current_session
59
+ if sess:
60
+ started = sess.get("started", "")
61
+ try:
62
+ start_ts = time.mktime(time.strptime(started, "%Y-%m-%dT%H:%M:%S"))
63
+ age_str = f" age:{round((time.time() - start_ts) / 60)}min"
64
+ except Exception:
65
+ pass
66
+
67
+ content = snap.get("content_tokens", tokens)
68
+ infra = snap.get("infra_tokens", 0)
69
+ infra_note = f" (content:{content} infra:{infra})" if infra > 0 else ""
70
+ lines = [
71
+ f"[ctx_status] {tokens}tok/{snap['call_count']}calls "
72
+ f"avg:{snap['avg_tokens_per_call']} ({pct}% of {threshold}tok threshold){age_str}{infra_note}"
73
+ ]
74
+
75
+ # File memory coverage
76
+ try:
77
+ tracked = svc.file_memory.list_tracked()
78
+ idx_stats = svc.indexer.get_stats()
79
+ total_files = idx_stats.get("files_indexed", 0)
80
+ lines.append(f"[file_memory] {len(tracked)}/{total_files} files indexed")
81
+ except Exception as e:
82
+ lines.append(f"[file_memory] error: {e}")
83
+
84
+ # C3 adoption ratio
85
+ c3_calls = snap.get("c3_calls", 0)
86
+ native_calls = snap.get("native_calls", 0)
87
+ adoption = snap.get("c3_adoption_pct", 100)
88
+ if c3_calls + native_calls > 0:
89
+ lines.append(f"[c3_adoption] {adoption}% ({c3_calls}c3/{native_calls}native)")
90
+
91
+ # Per-tool token breakdown
92
+ by_tool = snap.get("by_tool", {})
93
+ if by_tool:
94
+ sorted_tools = sorted(by_tool.items(), key=lambda x: -x[1])
95
+ shown = sorted_tools[:6]
96
+ breakdown = " | ".join(f"{n}:{t}tok" for n, t in shown)
97
+ if len(sorted_tools) > 6:
98
+ breakdown += f" (+{len(sorted_tools) - 6} more)"
99
+ lines.append(f"[breakdown] {breakdown}")
100
+
101
+ if detailed:
102
+ stats = svc.indexer.get_stats()
103
+ lines.append(f"[index] files:{stats['files_indexed']} "
104
+ f"tok:{format_token_count(stats['total_tokens_in_codebase'])}")
105
+
106
+ # Single warning when over threshold
107
+ if pct >= 100:
108
+ lines.append(f"[warn] Budget exceeded ({pct}%). Consider compact + /clear.")
109
+
110
+ return finalize("c3_status", {"view": "budget"}, "\n".join(lines), f"{pct}%")
111
+
112
+
113
+ def _health_view(svc, finalize):
114
+ parts = []
115
+ ollama_ok = svc.ollama_client and svc.ollama_client.is_available()
116
+ models = svc.ollama_client.list_models() if ollama_ok else []
117
+ parts.append(f"[ollama] {'up (' + str(len(models)) + ' models)' if ollama_ok else 'unavailable'}")
118
+ stats = svc.indexer.get_stats()
119
+ parts.append(f"[index] {stats.get('files_indexed', 0)} files indexed")
120
+ sess = svc.session_mgr.current_session
121
+ if sess:
122
+ started = sess.get("started", "")
123
+ try:
124
+ start_ts = time.mktime(time.strptime(started, "%Y-%m-%dT%H:%M:%S"))
125
+ age_min = round((time.time() - start_ts) / 60)
126
+ parts.append(f"[session] {sess.get('id', '?')[:12]} age:{age_min}min "
127
+ f"calls:{len(sess.get('tool_calls', []))}")
128
+ except Exception:
129
+ parts.append(f"[session] {sess.get('id', '?')[:12]}")
130
+ else:
131
+ parts.append("[session] none active")
132
+ pending = svc.notifications.get_unacknowledged(limit=5) # actionable only
133
+ info_n = svc.notifications.get_suppressed_info_count()
134
+ info_tail = f" (+{info_n} info)" if info_n else ""
135
+ parts.append(f"[notifications] {len(pending)} actionable{info_tail}")
136
+ if svc.vector_store:
137
+ try:
138
+ vs = svc.vector_store.get_stats()
139
+ parts.append(f"[sltm] {vs.get('total_records', 0)} records "
140
+ f"ollama={vs.get('ollama_available', False)}")
141
+ except Exception as e:
142
+ parts.append(f"[sltm] error: {e}")
143
+ else:
144
+ parts.append("[sltm] disabled")
145
+ all_facts = getattr(svc.memory, 'facts', []) or []
146
+ active_facts = [f for f in all_facts if f.get("lifecycle") != "archived"]
147
+ parts.append(f"[memory] {len(active_facts)} active / {len(all_facts)} total")
148
+ # Doc index (Local RAG Pipeline)
149
+ if hasattr(svc, "doc_index") and svc.doc_index:
150
+ di_stats = svc.doc_index.get_stats()
151
+ parts.append(f"[doc_index] {di_stats['total_chunks']} chunks "
152
+ f"({di_stats['files_tracked']} files)")
153
+ else:
154
+ parts.append("[doc_index] disabled")
155
+ # Validation cache stats
156
+ vcache = getattr(svc, "validation_cache", None)
157
+ if vcache:
158
+ vs = vcache.summary()
159
+ err_note = f" ({vs['errors']} errors)" if vs["errors"] else ""
160
+ parts.append(f"[validation] {vs['cached_files']} cached, {vs['clean']} clean{err_note}")
161
+ else:
162
+ parts.append("[validation] disabled")
163
+ # Agent workflows
164
+ wf_cfg = (svc.hybrid_config or {}).get("agent_workflows", {})
165
+ wf_enabled = wf_cfg.get("enabled", True)
166
+ if wf_enabled:
167
+ parts.append(f"[workflows] enabled prefetch_max={wf_cfg.get('prefetch_max_files', 3)} "
168
+ f"batch_max={wf_cfg.get('batch_validate_max_files', 10)} "
169
+ f"compound_max={wf_cfg.get('compound_max_compress', 5)} "
170
+ f"delegate={'on' if wf_cfg.get('delegate_in_workflows', True) else 'off'}")
171
+ else:
172
+ parts.append("[workflows] disabled")
173
+ # Background agents
174
+ agents = getattr(svc, "agents", None) or []
175
+ if agents:
176
+ enabled = sum(1 for a in agents if getattr(a, "enabled", False))
177
+ running = sum(
178
+ 1 for a in agents
179
+ if getattr(a, "_thread", None) and a._thread.is_alive()
180
+ )
181
+ backed_off = [
182
+ a.name for a in agents
183
+ if getattr(a, "_idle_multiplier", 1) > 1
184
+ ]
185
+ line = f"[agents] {len(agents)} total, {enabled} enabled, {running} running"
186
+ if backed_off:
187
+ line += f" (idle-backoff: {', '.join(backed_off)})"
188
+ parts.append(line)
189
+ else:
190
+ parts.append("[agents] none")
191
+
192
+ # .c3/ directory disk usage
193
+ try:
194
+ c3_dir = Path(svc.project_path) / ".c3"
195
+ if c3_dir.exists():
196
+ total_bytes = sum(f.stat().st_size for f in c3_dir.rglob("*") if f.is_file())
197
+ if total_bytes < 1024 * 1024:
198
+ size_str = f"{total_bytes / 1024:.0f}KB"
199
+ else:
200
+ size_str = f"{total_bytes / (1024 * 1024):.1f}MB"
201
+ parts.append(f"[storage] .c3/ {size_str}")
202
+ except Exception as e:
203
+ parts.append(f"[storage] error: {e}")
204
+ # Ghost-file scan
205
+ try:
206
+ ghosts = scan_ghost_files(Path(svc.project_path))
207
+ if ghosts:
208
+ names = ", ".join(g["name"] for g in ghosts)
209
+ parts.append(f"[ghost_files] {len(ghosts)} found: {names} "
210
+ f"(run c3_status(view='ghost_files') to clean)")
211
+ else:
212
+ parts.append("[ghost_files] clean")
213
+ except Exception:
214
+ pass
215
+ return finalize("c3_status", {"view": "health"}, "\n".join(parts), "ok")
216
+
217
+
218
+ def _sessions_view(svc, finalize):
219
+ """Show recent session token/cost stats from .c3/session_stats.jsonl."""
220
+ stats_path = Path(svc.project_path) / ".c3" / "session_stats.jsonl"
221
+ if not stats_path.exists():
222
+ return finalize(
223
+ "c3_status", {"view": "sessions"},
224
+ "[sessions] No data yet. Run c3 install-mcp to enable the Stop hook that captures stats.",
225
+ "empty",
226
+ )
227
+
228
+ entries = []
229
+ try:
230
+ with open(stats_path, encoding="utf-8") as f:
231
+ for line in f:
232
+ line = line.strip()
233
+ if line:
234
+ entries.append(json.loads(line))
235
+ except Exception as exc:
236
+ return f"[sessions:error] Could not read {stats_path}: {exc}"
237
+
238
+ if not entries:
239
+ return finalize("c3_status", {"view": "sessions"}, "[sessions] No sessions recorded yet.", "empty")
240
+
241
+ recent = entries[-20:] # last 20 sessions
242
+ total_cost = sum(e.get("cost_usd") or 0 for e in recent)
243
+ total_in = sum(e.get("input_tokens") or 0 for e in recent)
244
+ total_out = sum(e.get("output_tokens") or 0 for e in recent)
245
+ total_cache_read = sum(e.get("cache_read_tokens") or 0 for e in recent)
246
+
247
+ lines = [f"# Session Stats (last {len(recent)} of {len(entries)} sessions)"]
248
+ lines.append(f"Total cost: ${total_cost:.4f} | In: {total_in:,} Out: {total_out:,} Cache-read: {total_cache_read:,}")
249
+ lines.append("")
250
+ lines.append(f"{'Date':<22} {'Cost':>8} {'In':>8} {'Out':>6} {'Cache-rd':>9} Stop")
251
+ lines.append("-" * 64)
252
+ for e in reversed(recent):
253
+ ts = (e.get("ts") or "")[:19].replace("T", " ")
254
+ cost = f"${e.get('cost_usd') or 0:.4f}"
255
+ inp = f"{e.get('input_tokens') or 0:,}"
256
+ out = f"{e.get('output_tokens') or 0:,}"
257
+ cr = f"{e.get('cache_read_tokens') or 0:,}"
258
+ reason = e.get("stop_reason") or ""
259
+ lines.append(f"{ts:<22} {cost:>8} {inp:>8} {out:>6} {cr:>9} {reason}")
260
+
261
+ resp = "\n".join(lines)
262
+ return finalize("c3_status", {"view": "sessions"}, resp, f"{len(recent)}sess")
263
+
264
+
265
+ def _notifications_view(svc, finalize):
266
+ """Actionable warnings + critical only. Info events are archived,
267
+ not surfaced here — 'File maps updated' is not news worth paging on.
268
+ Use the web UI activity log or the REST API with severities='info'
269
+ to inspect them.
270
+ """
271
+ pending = svc.notifications.get_unacknowledged(limit=20) # actionable by default
272
+ info_count = svc.notifications.get_suppressed_info_count()
273
+ if not pending:
274
+ tail = f" ({info_count} info events archived)" if info_count else ""
275
+ return f"No actionable notifications.{tail}"
276
+ lines = [f"# Actionable ({len(pending)})"]
277
+ for n in pending:
278
+ lines.append(f"[{n['severity']}] {n['agent']}: {n['title']}")
279
+ if info_count:
280
+ lines.append(f"\n(+{info_count} info events archived — not shown)")
281
+ return finalize("c3_status", {"view": "notifications"},
282
+ "\n".join(lines), f"{len(pending)}p")
283
+
284
+
285
+ def _ghost_files_view(svc, finalize):
286
+ """Scan project root for ghost files, report details, and auto-clean them."""
287
+ project_root = Path(svc.project_path)
288
+ ghosts = scan_ghost_files(project_root)
289
+
290
+ if not ghosts:
291
+ return finalize("c3_status", {"view": "ghost_files"},
292
+ "[ghost_files] Project root is clean — no ghost files detected.", "clean")
293
+
294
+ lines = [f"# Ghost Files ({len(ghosts)} found)"]
295
+ for g in ghosts:
296
+ lines.append(f" {g['name']} ({g['size']}B) — {g['reason']}")
297
+
298
+ # Auto-clean
299
+ deleted = cleanup_ghost_files(ghosts)
300
+ if deleted:
301
+ lines.append(f"\nDeleted {len(deleted)}: {', '.join(deleted)}")
302
+ else:
303
+ lines.append("\nCould not delete any ghost files (permission error?).")
304
+
305
+ resp = "\n".join(lines)
306
+ return finalize("c3_status", {"view": "ghost_files"}, resp, f"{len(deleted)} cleaned")