Coverage for little_loops / cli / session.py: 6%

245 statements  

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

1"""ll-session: query the unified session store (SQLite + FTS5). 

2 

3Wraps :mod:`little_loops.session_store` with a CLI surface so operators can 

4search and inspect the per-project ``.ll/history.db`` without re-parsing the 

5scattered JSON/markdown sources the analyze-* skills read. 

6 

7Subcommands: 

8 search FTS5 full-text query with BM25-ranked results and optional --kind filter 

9 recent most recent rows for an event kind (tool, file, issue, loop, correction, message, skill, cli) 

10 backfill seed the database from existing on-disk sources 

11 related issue events for a given issue ID 

12 path resolve JSONL file path for a session ID 

13 grep regex search over message_events with covering summary node context 

14 expand return message_events covered by a summary node 

15 describe metadata for a summary node 

16 prune delete raw event rows older than configured max-age and VACUUM (ENH-1906) 

17""" 

18 

19from __future__ import annotations 

20 

21import argparse 

22import sys 

23from pathlib import Path 

24from typing import Any 

25 

26from little_loops.cli.output import configure_output, print_json, use_color_enabled 

27from little_loops.cli_args import add_json_arg 

28from little_loops.history_reader import ( 

29 ll_describe, 

30 ll_expand, 

31 ll_grep, 

32 related_issue_events, 

33 sessions_for_issue, 

34) 

35from little_loops.history_reader import search as history_search 

36from little_loops.logger import Logger 

37from little_loops.session_store import ( 

38 DEFAULT_DB_PATH, 

39 backfill, 

40 backfill_incremental, 

41 cli_event_context, 

42 connect, 

43 prune, 

44 recent, 

45 search, 

46) 

47from little_loops.user_messages import get_project_folder 

48 

49 

50def _build_parser() -> argparse.ArgumentParser: 

51 """Build the argument parser for ll-session.""" 

52 parser = argparse.ArgumentParser( 

53 prog="ll-session", 

54 description="Query the unified session store (SQLite + FTS5)", 

55 formatter_class=argparse.RawDescriptionHelpFormatter, 

56 epilog=""" 

57Examples: 

58 %(prog)s search --fts "rate limit" # Full-text search, BM25-ranked 

59 %(prog)s search --fts "error" --kind loop # FTS5 search filtered by kind 

60 %(prog)s recent --kind loop # Recent loop events 

61 %(prog)s related BUG-1759 # Events for a specific issue 

62 %(prog)s backfill # Seed the database from on-disk sources 

63 %(prog)s grep "auth middleware" # Regex search over message_events 

64 %(prog)s expand 42 # Messages covered by summary node 42 

65 %(prog)s describe 42 # Metadata for summary node 42 

66 %(prog)s prune --dry-run # Show what would be pruned 

67 %(prog)s prune # Delete old raw events and VACUUM 

68""", 

69 ) 

70 parser.add_argument( 

71 "--db", 

72 type=Path, 

73 default=DEFAULT_DB_PATH, 

74 metavar="PATH", 

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

76 ) 

77 

78 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

79 

80 path_parser = subparsers.add_parser("path", help="Resolve JSONL file path for a session ID") 

81 path_parser.add_argument("session_id", metavar="SESSION_ID", help="Session ID to look up") 

82 

83 search_parser = subparsers.add_parser("search", help="FTS5 full-text search") 

84 search_parser.add_argument("--fts", required=True, metavar="QUERY", help="FTS5 match query") 

85 search_parser.add_argument( 

86 "--kind", 

87 choices=["tool", "file", "issue", "loop", "correction", "message", "skill", "cli"], 

88 default=None, 

89 help="Filter results by event kind", 

90 ) 

91 search_parser.add_argument( 

92 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)" 

93 ) 

94 add_json_arg(search_parser) 

95 

96 recent_parser = subparsers.add_parser("recent", help="Recent events by kind") 

97 recent_parser.add_argument( 

98 "--kind", 

99 choices=["tool", "file", "issue", "loop", "correction", "message", "skill", "cli"], 

100 default=None, 

101 help="Event kind to list (required unless --issue is given)", 

102 ) 

103 recent_parser.add_argument( 

104 "--issue", 

105 default=None, 

106 metavar="ID", 

107 help="Filter to sessions that touched this issue ID (e.g. ENH-1710)", 

108 ) 

109 recent_parser.add_argument( 

110 "--limit", type=int, default=20, metavar="N", help="Maximum rows (default: 20)" 

111 ) 

112 recent_parser.add_argument( 

113 "--json", action="store_true", dest="json", help="Output as JSON array" 

114 ) 

115 

116 related_parser = subparsers.add_parser("related", help="Issue events for an issue ID") 

117 related_parser.add_argument("issue_id", metavar="ISSUE_ID", help="Issue ID (e.g., BUG-1759)") 

118 related_parser.add_argument( 

119 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)" 

120 ) 

121 add_json_arg(related_parser) 

122 

123 backfill_parser = subparsers.add_parser( 

124 "backfill", help="Seed the database from existing on-disk sources" 

125 ) 

126 backfill_parser.add_argument( 

127 "--since", 

128 metavar="DATE", 

129 default=None, 

130 help="Only process JSONL files modified after DATE (ISO 8601 or YYYY-MM-DD); uses incremental mode", 

131 ) 

132 backfill_parser.add_argument( 

133 "--host", 

134 choices=["claude-code", "codex", "opencode", "pi"], 

135 default=None, 

136 help="Host to discover session logs for (default: auto-detect from LL_HOOK_HOST env)", 

137 ) 

138 

139 grep_parser = subparsers.add_parser( 

140 "grep", help="Regex search over message_events with summary node context" 

141 ) 

142 grep_parser.add_argument("pattern", metavar="PATTERN", help="Regex pattern (case-insensitive)") 

143 grep_parser.add_argument( 

144 "--summary-id", 

145 type=int, 

146 default=None, 

147 metavar="ID", 

148 help="Restrict search to messages covered by this summary node ID", 

149 ) 

150 grep_parser.add_argument( 

151 "--limit", type=int, default=50, metavar="N", help="Maximum results (default: 50)" 

152 ) 

153 add_json_arg(grep_parser) 

154 

155 expand_parser = subparsers.add_parser( 

156 "expand", help="Return message_events covered by a summary node" 

157 ) 

158 expand_parser.add_argument( 

159 "summary_id", type=int, metavar="SUMMARY_ID", help="Summary node ID to expand" 

160 ) 

161 add_json_arg(expand_parser) 

162 

163 describe_parser = subparsers.add_parser("describe", help="Show metadata for a summary node") 

164 describe_parser.add_argument( 

165 "node_id", type=int, metavar="NODE_ID", help="Summary node ID to describe" 

166 ) 

167 add_json_arg(describe_parser) 

168 

169 prune_parser = subparsers.add_parser( 

170 "prune", 

171 help="Prune raw event rows older than configured max-age and VACUUM the database", 

172 ) 

173 prune_parser.add_argument( 

174 "--dry-run", 

175 action="store_true", 

176 default=False, 

177 help="Report which rows would be deleted without actually deleting them", 

178 ) 

179 add_json_arg(prune_parser) 

180 

181 return parser 

182 

183 

184def _parse_args() -> argparse.Namespace: 

185 """Parse command-line arguments. Exposed for testing.""" 

186 return _build_parser().parse_args() 

187 

188 

189def main_session() -> int: 

190 """Entry point for ll-session command. 

191 

192 Returns: 

193 0 on success, 1 when no subcommand is given or on error. 

194 """ 

195 with cli_event_context(DEFAULT_DB_PATH, "ll-session", sys.argv[1:]): 

196 configure_output() 

197 logger = Logger(use_color=use_color_enabled()) 

198 

199 parser = _build_parser() 

200 args = parser.parse_args() 

201 

202 if not args.command: 

203 parser.print_help() 

204 return 1 

205 

206 if args.command == "path": 

207 conn = connect(args.db) 

208 try: 

209 row = conn.execute( 

210 "SELECT jsonl_path FROM sessions WHERE session_id = ?", (args.session_id,) 

211 ).fetchone() 

212 finally: 

213 conn.close() 

214 if row is None: 

215 print(f"Session {args.session_id} not found.") 

216 return 1 

217 print(row["jsonl_path"]) 

218 return 0 

219 

220 if args.command == "search": 

221 results: list[Any] 

222 if args.kind: 

223 results = history_search(args.fts, kind=args.kind, limit=args.limit, db=args.db) 

224 else: 

225 try: 

226 results = search(args.db, query=args.fts, limit=args.limit) 

227 except ValueError as exc: 

228 logger.error(str(exc)) 

229 return 1 

230 if args.json: 

231 if ( 

232 isinstance(results, list) 

233 and results 

234 and hasattr(results[0], "__dataclass_fields__") 

235 ): 

236 from dataclasses import asdict 

237 

238 results = [asdict(r) for r in results] 

239 print_json(list(results)) 

240 return 0 

241 if not results: 

242 print("No matches.") 

243 return 0 

244 for row in results: 

245 if hasattr(row, "__dataclass_fields__"): 

246 anchor = f" ({row.anchor})" if row.anchor else "" 

247 print(f"[{row.kind}] {row.content}{anchor}") 

248 else: 

249 anchor = f" ({row['anchor']})" if row.get("anchor") else "" 

250 print(f"[{row['kind']}] {row['content']}{anchor}") 

251 return 0 

252 

253 if args.command == "related": 

254 events = related_issue_events(args.issue_id, limit=args.limit, db=args.db) 

255 if args.json: 

256 from dataclasses import asdict 

257 

258 print_json([asdict(e) for e in events]) 

259 return 0 

260 if not events: 

261 print(f"No events for {args.issue_id}.") 

262 return 0 

263 for e in events: 

264 fields = ", ".join( 

265 f"{k}={getattr(e, k)}" 

266 for k in ("ts", "transition", "issue_type", "priority") 

267 if getattr(e, k) 

268 ) 

269 print(fields) 

270 return 0 

271 

272 if args.command == "recent": 

273 issue_filter = getattr(args, "issue", None) 

274 

275 # --issue only: show sessions that co-occurred with the issue 

276 if issue_filter and not args.kind: 

277 refs = sessions_for_issue(issue_filter, limit=args.limit, db=args.db) 

278 if args.json: 

279 from dataclasses import asdict 

280 

281 print_json([asdict(r) for r in refs]) 

282 return 0 

283 if not refs: 

284 print(f"No sessions found for {issue_filter}.") 

285 return 0 

286 for r in refs: 

287 path = r.jsonl_path or "(no path)" 

288 print(f"{r.session_id} {path}") 

289 return 0 

290 

291 if not args.kind: 

292 logger.error("recent: --kind is required unless --issue is given") 

293 return 1 

294 

295 rows = recent(args.db, kind=args.kind, limit=args.limit) 

296 if issue_filter: 

297 session_ids = {r.session_id for r in sessions_for_issue(issue_filter, db=args.db)} 

298 rows = [r for r in rows if r.get("session_id") in session_ids] 

299 if args.json: 

300 print_json(list(rows)) 

301 return 0 

302 if not rows: 

303 print(f"No {args.kind} events.") 

304 return 0 

305 for row in rows: 

306 fields = ", ".join( 

307 f"{k}={v}" for k, v in row.items() if k != "id" and v is not None 

308 ) 

309 print(fields) 

310 return 0 

311 

312 if args.command == "backfill": 

313 since_flag = getattr(args, "since", None) 

314 if since_flag is not None: 

315 from datetime import datetime 

316 

317 try: 

318 try: 

319 dt = datetime.fromisoformat(since_flag.replace("Z", "+00:00")) 

320 except ValueError: 

321 dt = datetime.strptime(since_flag, "%Y-%m-%d") 

322 since_ts = dt.timestamp() 

323 except ValueError: 

324 logger.error(f"Invalid date: {since_flag!r}. Use YYYY-MM-DD or ISO 8601.") 

325 return 1 

326 project_folder = get_project_folder(host=args.host) 

327 if project_folder is None: 

328 logger.error("No session project folder found; cannot discover JSONL files.") 

329 return 1 

330 jsonl_files = list(project_folder.glob("*.jsonl")) 

331 inc_counts = backfill_incremental( 

332 args.db, jsonl_files=jsonl_files, since_ts=since_ts 

333 ) 

334 inc_total = sum(inc_counts.values()) 

335 logger.success( 

336 f"Backfilled {inc_total} rows (incremental, since {since_flag}; " 

337 f"tools={inc_counts['tools']}, messages={inc_counts['messages']}, " 

338 f"sessions={inc_counts['sessions']}, corrections={inc_counts.get('corrections', 0)})" 

339 ) 

340 return 0 

341 # Full backfill (no --since): discover JSONL files so non-Claude-Code 

342 # hosts also get message/tool/session backfill (ENH-1945). 

343 project_folder = get_project_folder(host=args.host) 

344 full_jsonl_files: list[Path] | None = ( 

345 list(project_folder.glob("*.jsonl")) if project_folder else None 

346 ) 

347 counts = backfill(args.db, jsonl_files=full_jsonl_files) 

348 total = sum(counts.values()) 

349 logger.success( 

350 f"Backfilled {total} rows " 

351 f"(issues={counts['issues']}, loops={counts['loops']}, " 

352 f"tools={counts['tools']}, messages={counts.get('messages', 0)}, " 

353 f"sessions={counts.get('sessions', 0)}, corrections={counts.get('corrections', 0)}, " 

354 f"summaries={counts.get('summaries', 0)})" 

355 ) 

356 return 0 

357 

358 if args.command == "grep": 

359 grep_results = ll_grep( 

360 args.pattern, 

361 summary_id=args.summary_id, 

362 limit=args.limit, 

363 db=args.db, 

364 ) 

365 if args.json: 

366 from dataclasses import asdict 

367 

368 print_json([asdict(r) for r in grep_results]) 

369 return 0 

370 if not grep_results: 

371 print("No matches.") 

372 return 0 

373 for gr in grep_results: 

374 node_info = f" [node {gr.summary_id}/{gr.summary_kind}]" if gr.summary_id else "" 

375 snippet = gr.content[:120].replace("\n", " ") 

376 print(f"{gr.ts} {snippet}{node_info}") 

377 return 0 

378 

379 if args.command == "expand": 

380 messages = ll_expand(args.summary_id, db=args.db) 

381 if args.json: 

382 print_json(messages) 

383 return 0 

384 if not messages: 

385 print(f"No messages found for summary node {args.summary_id}.") 

386 return 0 

387 for m in messages: 

388 snippet = (m.get("content") or "")[:120].replace("\n", " ") 

389 print(f"{m.get('ts', '')} {snippet}") 

390 return 0 

391 

392 if args.command == "describe": 

393 node = ll_describe(args.node_id, db=args.db) 

394 if node is None: 

395 print(f"Summary node {args.node_id} not found.") 

396 return 1 

397 if args.json: 

398 from dataclasses import asdict 

399 

400 print_json(asdict(node)) 

401 return 0 

402 print(f"id={node.id} kind={node.kind} level={node.level} session={node.session_id}") 

403 print(f"ts_start={node.ts_start} ts_end={node.ts_end}") 

404 print(f"tokens={node.tokens} created_at={node.created_at}") 

405 print(f"content: {node.content[:200]}") 

406 return 0 

407 

408 if args.command == "prune": 

409 import json as _json 

410 

411 from little_loops.config.core import resolve_config_path 

412 

413 config: dict | None = None 

414 config_path = resolve_config_path(Path.cwd()) 

415 if config_path is not None: 

416 try: 

417 config = _json.loads(config_path.read_text(encoding="utf-8")) 

418 except (OSError, _json.JSONDecodeError): 

419 config = None 

420 

421 result = prune(args.db, config=config, dry_run=args.dry_run) 

422 

423 if args.json: 

424 print_json(result) 

425 return 0 

426 

427 if args.dry_run: 

428 print("DRY RUN — no rows deleted\n") 

429 

430 if result["gate_unmet"]: 

431 print("Gates unmet — pruning skipped:") 

432 for reason in result["gate_unmet"]: 

433 print(f" {reason}") 

434 print( 

435 f"\nDB: {result['db_size_mb']:.1f} MB | " 

436 f"project age: {result['project_age_days']}d" 

437 ) 

438 return 0 

439 

440 if not result["pruned"]: 

441 print("No pruning configured (raw_event_max_age_days is null).") 

442 return 0 

443 

444 deleted = result.get("deleted", {}) 

445 total = sum(deleted.values()) 

446 if total == 0: 

447 print("Gates met — no eligible rows found.") 

448 else: 

449 label = "Would delete" if args.dry_run else "Deleted" 

450 for table, count in deleted.items(): 

451 print(f" {table}: {count:,} rows") 

452 print(f"\n{label} {total:,} rows total.") 

453 

454 if not args.dry_run and result.get("vacuumed"): 

455 print("Database VACUUMed.") 

456 

457 return 0 

458 

459 return 1