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

146 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-28 13:07 -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 

15from collections import defaultdict 

16from pathlib import Path 

17from typing import Any 

18 

19from little_loops.cli.output import ( 

20 configure_output, 

21 format_relative_time, 

22 terminal_width, 

23 use_color_enabled, 

24) 

25from little_loops.logger import Logger 

26 

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

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

29 

30 

31def _build_parser() -> argparse.ArgumentParser: 

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

33 parser = argparse.ArgumentParser( 

34 prog="ll-ctx-stats", 

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

36 formatter_class=argparse.RawDescriptionHelpFormatter, 

37 epilog=""" 

38Examples: 

39 %(prog)s # Print savings summary 

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

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

42 

43Exit codes: 

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

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

46""", 

47 ) 

48 parser.add_argument( 

49 "--db", 

50 type=Path, 

51 default=None, 

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

53 ) 

54 parser.add_argument( 

55 "-j", 

56 "--json", 

57 dest="json_mode", 

58 action="store_true", 

59 help="Output as JSON", 

60 ) 

61 return parser 

62 

63 

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

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

66 return _build_parser().parse_args(argv) 

67 

68 

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

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

71 if value < 1024: 

72 return f"{value} B" 

73 if value < 1024 * 1024: 

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

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

76 

77 

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

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

80 

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

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

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

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

85 """ 

86 label = format_relative_time(seconds) 

87 if label.endswith(" ago"): 

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

89 return f"+{label}" 

90 

91 

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

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

94 if width < 3: 

95 width = 3 

96 inner = width - 2 

97 if ceiling <= 0: 

98 filled = 0 

99 else: 

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

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

102 

103 

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

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

106 

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

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

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

110 

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

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

113 """ 

114 if not db_path.exists(): 

115 return None 

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

117 try: 

118 conn.row_factory = sqlite3.Row 

119 try: 

120 rows = conn.execute( 

121 "SELECT tool_name, bytes_in, bytes_out, cache_hit " 

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

123 ).fetchall() 

124 except sqlite3.OperationalError: 

125 return None 

126 finally: 

127 conn.close() 

128 

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

130 total_in = 0 

131 total_out = 0 

132 cache_hits = 0 

133 cache_bytes = 0 

134 for row in rows: 

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

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

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

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

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

140 total_in += bin_ 

141 total_out += bout 

142 if row["cache_hit"]: 

143 cache_hits += 1 

144 cache_bytes += bout 

145 

146 return { 

147 "total_in": total_in, 

148 "total_out": total_out, 

149 "cache_hits": cache_hits, 

150 "cache_bytes": cache_bytes, 

151 "per_tool": dict(per_tool), 

152 } 

153 

154 

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

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

157 if not path.exists(): 

158 return None 

159 try: 

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

161 except (OSError, json.JSONDecodeError): 

162 return None 

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

164 

165 

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

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

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

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

170 saved = max(0, total_processed - in_context) 

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

172 

173 width = terminal_width() 

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

175 print( 

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

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

178 ) 

179 print( 

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

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

182 ) 

183 print() 

184 print( 

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

186 f"({reduction}% reduction)" 

187 ) 

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

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

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

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

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

193 print() 

194 

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

196 if per_tool: 

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

198 for tool, stats in ranked: 

199 print( 

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

201 ) 

202 print() 

203 

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

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

206 if cache_hits: 

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

208 else: 

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

210 

211 

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

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

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

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

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

217 

218 logger.info( 

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

220 "(set analytics.enabled: true in .ll/ll-config.json to collect per-tool byte metrics)." 

221 ) 

222 print() 

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

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

225 if isinstance(breakdown, dict) and breakdown: 

226 print() 

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

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

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

230 

231 

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

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

234 if summary is not None: 

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

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

237 saved = max(0, total_processed - in_context) 

238 payload: dict[str, Any] = { 

239 "source": "sqlite", 

240 "bytes_processed": total_processed, 

241 "bytes_in_context": in_context, 

242 "bytes_saved": saved, 

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

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

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

246 "per_tool": summary["per_tool"], 

247 } 

248 elif state is not None: 

249 payload = { 

250 "source": "fallback", 

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

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

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

254 } 

255 else: 

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

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

258 

259 

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

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

262 

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

264 a context-window savings summary. Falls back to 

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

266 """ 

267 args = _parse_args(argv) 

268 configure_output() 

269 logger = Logger(use_color=use_color_enabled()) 

270 

271 cwd = Path.cwd() 

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

273 state_path = cwd / DEFAULT_STATE_RELPATH 

274 

275 summary = _aggregate_tool_events(db_path) 

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

277 

278 if args.json_mode: 

279 _print_json(summary, fallback) 

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

281 

282 if summary is not None: 

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

284 if total_rows == 0: 

285 logger.warning( 

286 "No analytic rows in .ll/history.db — set analytics.enabled: true " 

287 "in .ll/ll-config.json and run a few tool calls to populate the store." 

288 ) 

289 if fallback is None: 

290 fallback = _load_fallback_state(state_path) 

291 else: 

292 _render(summary, logger) 

293 return 0 

294 

295 if fallback is not None: 

296 _render_fallback(fallback, logger) 

297 return 0 

298 

299 logger.error( 

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

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

302 ) 

303 return 1