Coverage for little_loops / cli / ctx_stats.py: 16%

149 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -0500

1"""ll-ctx-stats: Context-window analytics for the current project (FEAT-1624). 

2 

3Reads per-tool byte metrics that the ``post_tool_use`` hook persists into 

4``.ll/history.db`` (FEAT-1623) and renders a compact summary of how much 

5data was processed by tools vs. how much actually entered the conversation 

6context. Falls back to ``.ll/ll-context-state.json`` (token estimates) when 

7the SQLite store is absent so first-time users still get useful output. 

8""" 

9 

10from __future__ import annotations 

11 

12import argparse 

13import json 

14import sqlite3 

15import sys 

16from collections import defaultdict 

17from pathlib import Path 

18from typing import Any 

19 

20from little_loops.cli.output import ( 

21 configure_output, 

22 format_relative_time, 

23 terminal_width, 

24 use_color_enabled, 

25) 

26from little_loops.logger import Logger 

27from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context 

28 

29DEFAULT_DB_RELPATH = Path(".ll") / "history.db" 

30DEFAULT_STATE_RELPATH = Path(".ll") / "ll-context-state.json" 

31 

32 

33def _build_parser() -> argparse.ArgumentParser: 

34 """Build the ll-ctx-stats argument parser (exposed for testing).""" 

35 parser = argparse.ArgumentParser( 

36 prog="ll-ctx-stats", 

37 description="Show context-window savings metrics for the current project", 

38 formatter_class=argparse.RawDescriptionHelpFormatter, 

39 epilog=""" 

40Examples: 

41 %(prog)s # Print savings summary 

42 %(prog)s --db PATH # Use a non-default session database 

43 %(prog)s --json # Output as JSON 

44 

45Exit codes: 

46 0 - Report rendered (data present or fallback used) 

47 1 - No data found in either the SQLite store or the fallback file 

48""", 

49 ) 

50 parser.add_argument( 

51 "--db", 

52 type=Path, 

53 default=None, 

54 help="Path to the session database (default: .ll/history.db)", 

55 ) 

56 parser.add_argument( 

57 "-j", 

58 "--json", 

59 dest="json_mode", 

60 action="store_true", 

61 help="Output as JSON", 

62 ) 

63 return parser 

64 

65 

66def _parse_args(argv: list[str] | None) -> argparse.Namespace: 

67 """Parse argv into a Namespace (exposed for testing).""" 

68 return _build_parser().parse_args(argv) 

69 

70 

71def _format_bytes(value: int) -> str: 

72 """Render *value* bytes as a short ``KB``/``MB`` string.""" 

73 if value < 1024: 

74 return f"{value} B" 

75 if value < 1024 * 1024: 

76 return f"{value / 1024:.1f} KB" 

77 return f"{value / (1024 * 1024):.1f} MB" 

78 

79 

80def _time_gained(seconds: float) -> str: 

81 """Render *seconds* as a positive-tense ``+Xm`` string. 

82 

83 ``format_relative_time`` appends ``" ago"`` (it is designed for past-tense 

84 durations); strip that suffix here so the line reads as savings rather 

85 than elapsed time. The shared helper is intentionally left unchanged 

86 (see Implementation Constraints #4 on FEAT-1624). 

87 """ 

88 label = format_relative_time(seconds) 

89 if label.endswith(" ago"): 

90 label = label[: -len(" ago")] 

91 return f"+{label}" 

92 

93 

94def _progress_bar(value: int, ceiling: int, width: int) -> str: 

95 """Return a ``|#### |`` bar of ``width`` columns scaled to ``value/ceiling``.""" 

96 if width < 3: 

97 width = 3 

98 inner = width - 2 

99 if ceiling <= 0: 

100 filled = 0 

101 else: 

102 filled = max(0, min(inner, round(inner * value / ceiling))) 

103 return "|" + "#" * filled + " " * (inner - filled) + "|" 

104 

105 

106def _aggregate_tool_events(db_path: Path) -> dict[str, Any] | None: 

107 """Sum per-tool byte metrics from ``tool_events``. 

108 

109 Backfilled rows have ``NULL`` byte columns (see Implementation Constraints 

110 #1 on FEAT-1624). Per-tool aggregation filters those rows out so historic 

111 JSONL noise does not skew the summary; cache totals likewise. 

112 

113 Returns ``None`` when the database file is missing. Returns an empty 

114 summary (all zeros) when the database exists but has no analytic rows. 

115 """ 

116 if not db_path.exists(): 

117 return None 

118 conn = sqlite3.connect(str(db_path)) 

119 try: 

120 conn.row_factory = sqlite3.Row 

121 try: 

122 rows = conn.execute( 

123 "SELECT tool_name, bytes_in, bytes_out, cache_hit " 

124 "FROM tool_events WHERE bytes_in IS NOT NULL OR bytes_out IS NOT NULL" 

125 ).fetchall() 

126 except sqlite3.OperationalError: 

127 return None 

128 finally: 

129 conn.close() 

130 

131 per_tool: dict[str, dict[str, int]] = defaultdict(lambda: {"calls": 0, "bytes": 0}) 

132 total_in = 0 

133 total_out = 0 

134 cache_hits = 0 

135 cache_bytes = 0 

136 for row in rows: 

137 tool = (row["tool_name"] or "unknown").lower() 

138 bin_ = int(row["bytes_in"] or 0) 

139 bout = int(row["bytes_out"] or 0) 

140 per_tool[tool]["calls"] += 1 

141 per_tool[tool]["bytes"] += bout 

142 total_in += bin_ 

143 total_out += bout 

144 if row["cache_hit"]: 

145 cache_hits += 1 

146 cache_bytes += bout 

147 

148 return { 

149 "total_in": total_in, 

150 "total_out": total_out, 

151 "cache_hits": cache_hits, 

152 "cache_bytes": cache_bytes, 

153 "per_tool": dict(per_tool), 

154 } 

155 

156 

157def _load_fallback_state(path: Path) -> dict[str, Any] | None: 

158 """Return ``.ll/ll-context-state.json`` parsed, or ``None`` if absent/invalid.""" 

159 if not path.exists(): 

160 return None 

161 try: 

162 data = json.loads(path.read_text(encoding="utf-8")) 

163 except (OSError, json.JSONDecodeError): 

164 return None 

165 return data if isinstance(data, dict) else None 

166 

167 

168def _render(summary: dict[str, Any], logger: Logger) -> None: 

169 """Print the savings report for an aggregated SQLite ``summary`` dict.""" 

170 total_processed = int(summary["total_in"]) + int(summary["total_out"]) 

171 in_context = max(0, int(summary["total_out"]) - int(summary["cache_bytes"])) 

172 saved = max(0, total_processed - in_context) 

173 reduction = round(100 * saved / total_processed) if total_processed > 0 else 0 

174 

175 width = terminal_width() 

176 bar_width = max(20, min(50, width - 30)) 

177 print( 

178 f"Without savings: {_progress_bar(total_processed, total_processed, bar_width)} " 

179 f"{_format_bytes(total_processed)} in conversation" 

180 ) 

181 print( 

182 f"With savings: {_progress_bar(in_context, total_processed, bar_width)} " 

183 f"{_format_bytes(in_context)} in conversation" 

184 ) 

185 print() 

186 print( 

187 f"{_format_bytes(saved)} processed by tools, never entered conversation. " 

188 f"({reduction}% reduction)" 

189 ) 

190 # Heuristic: ~100 bytes/sec of saved context ≈ time the user would have spent 

191 # waiting for compaction or re-reading. The estimate is rough by design; 

192 # FEAT-1625 may revisit once real telemetry is collected. 

193 time_seconds = saved / 100.0 if saved > 0 else 0.0 

194 print(f"{_time_gained(time_seconds)} session time gained.") 

195 print() 

196 

197 per_tool: dict[str, dict[str, int]] = summary["per_tool"] 

198 if per_tool: 

199 ranked = sorted(per_tool.items(), key=lambda kv: kv[1]["bytes"], reverse=True) 

200 for tool, stats in ranked: 

201 print( 

202 f" {tool:<13} {stats['calls']:>3} calls {_format_bytes(stats['bytes']):>10} used" 

203 ) 

204 print() 

205 

206 cache_hits = int(summary["cache_hits"]) 

207 cache_bytes = int(summary["cache_bytes"]) 

208 if cache_hits: 

209 print(f"Cache: {cache_hits} hits | {_format_bytes(cache_bytes)} saved") 

210 else: 

211 logger.info("Cache: no hits recorded in this session") 

212 

213 

214def _render_fallback(state: dict[str, Any], logger: Logger) -> None: 

215 """Render the ``.ll/ll-context-state.json`` fallback (token estimates).""" 

216 estimated = int(state.get("estimated_tokens") or 0) 

217 tool_calls = int(state.get("tool_calls") or 0) 

218 breakdown = state.get("breakdown") or {} 

219 

220 logger.info( 

221 "SQLite session store not found — falling back to .ll/ll-context-state.json " 

222 "(enable analytics (analytics.enabled: true) and ensure analytics.capture.file_events is not disabled)." 

223 ) 

224 print() 

225 print(f"Estimated tokens in context: {estimated:,}") 

226 print(f"Tool calls this session: {tool_calls}") 

227 if isinstance(breakdown, dict) and breakdown: 

228 print() 

229 print("Per-tool token estimates:") 

230 for tool, tokens in sorted(breakdown.items(), key=lambda kv: kv[1], reverse=True): 

231 print(f" {str(tool):<20} {int(tokens):>8} tokens") 

232 

233 

234def _print_json(summary: dict[str, Any] | None, state: dict[str, Any] | None) -> None: 

235 """Emit a JSON document combining SQLite + fallback data.""" 

236 if summary is not None: 

237 total_processed = int(summary["total_in"]) + int(summary["total_out"]) 

238 in_context = max(0, int(summary["total_out"]) - int(summary["cache_bytes"])) 

239 saved = max(0, total_processed - in_context) 

240 payload: dict[str, Any] = { 

241 "source": "sqlite", 

242 "bytes_processed": total_processed, 

243 "bytes_in_context": in_context, 

244 "bytes_saved": saved, 

245 "reduction_pct": round(100 * saved / total_processed) if total_processed else 0, 

246 "cache_hits": int(summary["cache_hits"]), 

247 "cache_bytes_saved": int(summary["cache_bytes"]), 

248 "per_tool": summary["per_tool"], 

249 } 

250 elif state is not None: 

251 payload = { 

252 "source": "fallback", 

253 "estimated_tokens": int(state.get("estimated_tokens") or 0), 

254 "tool_calls": int(state.get("tool_calls") or 0), 

255 "breakdown": state.get("breakdown") or {}, 

256 } 

257 else: 

258 payload = {"source": "none"} 

259 print(json.dumps(payload, indent=2)) 

260 

261 

262def main_ctx_stats(argv: list[str] | None = None) -> int: 

263 """Entry point for ll-ctx-stats command. 

264 

265 Read per-tool byte metrics from ``.ll/history.db`` (FEAT-1623) and print 

266 a context-window savings summary. Falls back to 

267 ``.ll/ll-context-state.json`` when the SQLite store is absent. 

268 """ 

269 with cli_event_context(DEFAULT_DB_PATH, "ll-ctx-stats", sys.argv[1:]): 

270 args = _parse_args(argv) 

271 configure_output() 

272 logger = Logger(use_color=use_color_enabled()) 

273 

274 cwd = Path.cwd() 

275 db_path = args.db if args.db is not None else cwd / DEFAULT_DB_RELPATH 

276 state_path = cwd / DEFAULT_STATE_RELPATH 

277 

278 summary = _aggregate_tool_events(db_path) 

279 fallback = _load_fallback_state(state_path) if summary is None else None 

280 

281 if args.json_mode: 

282 _print_json(summary, fallback) 

283 return 0 if (summary is not None or fallback is not None) else 1 

284 

285 if summary is not None: 

286 total_rows = int(summary["total_in"]) + int(summary["total_out"]) 

287 if total_rows == 0: 

288 logger.warning( 

289 "No analytic rows in .ll/history.db — enable analytics (analytics.enabled: true) " 

290 "and ensure analytics.capture.file_events is not disabled, then run a few tool calls." 

291 ) 

292 if fallback is None: 

293 fallback = _load_fallback_state(state_path) 

294 else: 

295 _render(summary, logger) 

296 return 0 

297 

298 if fallback is not None: 

299 _render_fallback(fallback, logger) 

300 return 0 

301 

302 logger.error( 

303 "No context analytics found: neither .ll/history.db nor " 

304 ".ll/ll-context-state.json contained data for this project." 

305 ) 

306 return 1