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
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")
|