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

297 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -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 export dump selected tables as JSONL for visualization or external tooling 

18""" 

19 

20from __future__ import annotations 

21 

22import argparse 

23import sys 

24from pathlib import Path 

25from typing import Any 

26 

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

28from little_loops.cli_args import add_json_arg 

29from little_loops.history_reader import ( 

30 ll_describe, 

31 ll_expand, 

32 ll_grep, 

33 related_issue_events, 

34 sessions_for_issue, 

35) 

36from little_loops.history_reader import search as history_search 

37from little_loops.logger import Logger 

38from little_loops.session_store import ( 

39 DEFAULT_DB_PATH, 

40 backfill, 

41 backfill_incremental, 

42 backfill_snapshots, 

43 cli_event_context, 

44 connect, 

45 export_history, 

46 prune, 

47 recent, 

48 search, 

49) 

50from little_loops.user_messages import get_project_folder 

51 

52 

53def _build_parser() -> argparse.ArgumentParser: 

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

55 parser = argparse.ArgumentParser( 

56 prog="ll-session", 

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

58 formatter_class=argparse.RawDescriptionHelpFormatter, 

59 epilog=""" 

60Examples: 

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

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

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

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

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

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

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

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

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

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

71""", 

72 ) 

73 parser.add_argument( 

74 "--db", 

75 type=Path, 

76 default=DEFAULT_DB_PATH, 

77 metavar="PATH", 

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

79 ) 

80 

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

82 

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

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

85 

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

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

88 search_parser.add_argument( 

89 "--kind", 

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

91 default=None, 

92 help="Filter results by event kind", 

93 ) 

94 search_parser.add_argument( 

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

96 ) 

97 add_json_arg(search_parser) 

98 

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

100 recent_parser.add_argument( 

101 "--kind", 

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

103 default=None, 

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

105 ) 

106 recent_parser.add_argument( 

107 "--issue", 

108 default=None, 

109 metavar="ID", 

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

111 ) 

112 recent_parser.add_argument( 

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

114 ) 

115 recent_parser.add_argument( 

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

117 ) 

118 

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

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

121 related_parser.add_argument( 

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

123 ) 

124 add_json_arg(related_parser) 

125 

126 backfill_parser = subparsers.add_parser( 

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

128 ) 

129 backfill_parser.add_argument( 

130 "--since", 

131 metavar="DATE", 

132 default=None, 

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

134 ) 

135 backfill_parser.add_argument( 

136 "--host", 

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

138 default=None, 

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

140 ) 

141 backfill_parser.add_argument( 

142 "--extract-decisions", 

143 action="store_true", 

144 default=False, 

145 dest="extract_decisions", 

146 help="After backfill, run extract-from-completed to mine completed issues for rules (ENH-2152)", 

147 ) 

148 backfill_parser.add_argument( 

149 "--snapshots", 

150 action="store_true", 

151 default=False, 

152 help="Hydrate issue_snapshots table from existing .issues/ files (ENH-2151)", 

153 ) 

154 backfill_parser.add_argument( 

155 "--max-sessions", 

156 type=int, 

157 default=None, 

158 metavar="N", 

159 dest="max_sessions", 

160 help="Cap the number of sessions compacted in this run (newest first); useful for large DBs", 

161 ) 

162 

163 export_parser = subparsers.add_parser( 

164 "export", 

165 help="Dump selected tables as JSONL for visualization or external tooling", 

166 ) 

167 export_parser.add_argument( 

168 "--tables", 

169 nargs="+", 

170 metavar="TYPE", 

171 default=None, 

172 help=( 

173 "Types to include (default: all non-message tables). " 

174 "Choices: session, issue_event, issue_snapshot, skill_event, " 

175 "loop_event, correction, summary_node, message_event" 

176 ), 

177 ) 

178 export_parser.add_argument( 

179 "--since", 

180 metavar="DATE", 

181 default=None, 

182 help="Only rows at or after this ISO 8601 date/datetime", 

183 ) 

184 export_parser.add_argument( 

185 "--include-messages", 

186 action="store_true", 

187 default=False, 

188 dest="include_messages", 

189 help="Also include message_events (~46 K rows); ignored when --tables is given", 

190 ) 

191 export_parser.add_argument( 

192 "-o", 

193 "--output", 

194 metavar="FILE", 

195 default=None, 

196 help="Write output to FILE instead of stdout", 

197 ) 

198 

199 grep_parser = subparsers.add_parser( 

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

201 ) 

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

203 grep_parser.add_argument( 

204 "--summary-id", 

205 type=int, 

206 default=None, 

207 metavar="ID", 

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

209 ) 

210 grep_parser.add_argument( 

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

212 ) 

213 add_json_arg(grep_parser) 

214 

215 expand_parser = subparsers.add_parser( 

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

217 ) 

218 expand_parser.add_argument( 

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

220 ) 

221 add_json_arg(expand_parser) 

222 

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

224 describe_parser.add_argument( 

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

226 ) 

227 add_json_arg(describe_parser) 

228 

229 prune_parser = subparsers.add_parser( 

230 "prune", 

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

232 ) 

233 prune_parser.add_argument( 

234 "--dry-run", 

235 action="store_true", 

236 default=False, 

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

238 ) 

239 add_json_arg(prune_parser) 

240 

241 return parser 

242 

243 

244def _parse_args() -> argparse.Namespace: 

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

246 return _build_parser().parse_args() 

247 

248 

249def _run_extract_decisions(since: str | None = None) -> None: 

250 """Invoke ll-issues decisions extract-from-completed after a backfill.""" 

251 import subprocess 

252 import sys 

253 

254 cmd = ["ll-issues", "decisions", "extract-from-completed"] 

255 if since: 

256 cmd += ["--since", since] 

257 try: 

258 result = subprocess.run(cmd, capture_output=False) 

259 if result.returncode != 0: 

260 print( 

261 "extract-from-completed exited non-zero; decisions.yaml unchanged", file=sys.stderr 

262 ) 

263 except FileNotFoundError: 

264 print("ll-issues not found; skipping extract-from-completed", file=sys.stderr) 

265 

266 

267def main_session() -> int: 

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

269 

270 Returns: 

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

272 """ 

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

274 configure_output() 

275 logger = Logger(use_color=use_color_enabled()) 

276 

277 parser = _build_parser() 

278 args = parser.parse_args() 

279 

280 if not args.command: 

281 parser.print_help() 

282 return 1 

283 

284 if args.command == "path": 

285 conn = connect(args.db) 

286 try: 

287 row = conn.execute( 

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

289 ).fetchone() 

290 finally: 

291 conn.close() 

292 if row is None: 

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

294 return 1 

295 print(row["jsonl_path"]) 

296 return 0 

297 

298 if args.command == "search": 

299 results: list[Any] 

300 if args.kind: 

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

302 else: 

303 try: 

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

305 except ValueError as exc: 

306 logger.error(str(exc)) 

307 return 1 

308 if args.json: 

309 if ( 

310 isinstance(results, list) 

311 and results 

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

313 ): 

314 from dataclasses import asdict 

315 

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

317 print_json(list(results)) 

318 return 0 

319 if not results: 

320 print("No matches.") 

321 return 0 

322 for row in results: 

323 if hasattr(row, "__dataclass_fields__"): 

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

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

326 else: 

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

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

329 return 0 

330 

331 if args.command == "related": 

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

333 if args.json: 

334 from dataclasses import asdict 

335 

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

337 return 0 

338 if not events: 

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

340 return 0 

341 for e in events: 

342 fields = ", ".join( 

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

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

345 if getattr(e, k) 

346 ) 

347 print(fields) 

348 return 0 

349 

350 if args.command == "recent": 

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

352 

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

354 if issue_filter and not args.kind: 

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

356 if args.json: 

357 from dataclasses import asdict 

358 

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

360 return 0 

361 if not refs: 

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

363 return 0 

364 for r in refs: 

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

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

367 return 0 

368 

369 if not args.kind: 

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

371 return 1 

372 

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

374 if issue_filter: 

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

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

377 if args.json: 

378 print_json(list(rows)) 

379 return 0 

380 if not rows: 

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

382 return 0 

383 for row in rows: 

384 fields = ", ".join( 

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

386 ) 

387 print(fields) 

388 return 0 

389 

390 if args.command == "backfill": 

391 if getattr(args, "snapshots", False): 

392 count = backfill_snapshots(args.db) 

393 logger.success(f"Backfilled {count} issue snapshots.") 

394 return 0 

395 

396 # Read project config so compaction settings are respected (same 

397 # pattern as the prune handler). 

398 import json as _json 

399 

400 from little_loops.config.core import resolve_config_path 

401 

402 _config: dict | None = None 

403 _config_path = resolve_config_path(Path.cwd()) 

404 if _config_path is not None: 

405 try: 

406 _config = _json.loads(_config_path.read_text(encoding="utf-8")) 

407 except (OSError, _json.JSONDecodeError): 

408 _config = None 

409 

410 max_sessions = getattr(args, "max_sessions", None) 

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

412 if since_flag is not None: 

413 from datetime import datetime 

414 

415 try: 

416 try: 

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

418 except ValueError: 

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

420 since_ts = dt.timestamp() 

421 except ValueError: 

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

423 return 1 

424 project_folder = get_project_folder(host=args.host) 

425 if project_folder is None: 

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

427 return 1 

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

429 inc_counts = backfill_incremental( 

430 args.db, jsonl_files=jsonl_files, since_ts=since_ts, config=_config 

431 ) 

432 inc_total = sum(inc_counts.values()) 

433 logger.success( 

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

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

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

437 ) 

438 if getattr(args, "extract_decisions", False): 

439 _run_extract_decisions(since=since_flag) 

440 return 0 

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

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

443 project_folder = get_project_folder(host=args.host) 

444 full_jsonl_files: list[Path] | None = ( 

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

446 ) 

447 counts = backfill( 

448 args.db, 

449 jsonl_files=full_jsonl_files, 

450 config=_config, 

451 max_sessions=max_sessions, 

452 ) 

453 total = sum(counts.values()) 

454 logger.success( 

455 f"Backfilled {total} rows " 

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

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

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

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

460 ) 

461 if getattr(args, "extract_decisions", False): 

462 _run_extract_decisions(since=None) 

463 return 0 

464 

465 if args.command == "grep": 

466 grep_results = ll_grep( 

467 args.pattern, 

468 summary_id=args.summary_id, 

469 limit=args.limit, 

470 db=args.db, 

471 ) 

472 if args.json: 

473 from dataclasses import asdict 

474 

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

476 return 0 

477 if not grep_results: 

478 print("No matches.") 

479 return 0 

480 for gr in grep_results: 

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

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

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

484 return 0 

485 

486 if args.command == "expand": 

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

488 if args.json: 

489 print_json(messages) 

490 return 0 

491 if not messages: 

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

493 return 0 

494 for m in messages: 

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

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

497 return 0 

498 

499 if args.command == "describe": 

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

501 if node is None: 

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

503 return 1 

504 if args.json: 

505 from dataclasses import asdict 

506 

507 print_json(asdict(node)) 

508 return 0 

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

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

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

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

513 return 0 

514 

515 if args.command == "prune": 

516 import json as _json 

517 

518 from little_loops.config.core import resolve_config_path 

519 

520 config: dict | None = None 

521 config_path = resolve_config_path(Path.cwd()) 

522 if config_path is not None: 

523 try: 

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

525 except (OSError, _json.JSONDecodeError): 

526 config = None 

527 

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

529 

530 if args.json: 

531 print_json(result) 

532 return 0 

533 

534 if args.dry_run: 

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

536 

537 if result["gate_unmet"]: 

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

539 for reason in result["gate_unmet"]: 

540 print(f" {reason}") 

541 print( 

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

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

544 ) 

545 return 0 

546 

547 if not result["pruned"]: 

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

549 return 0 

550 

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

552 total = sum(deleted.values()) 

553 if total == 0: 

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

555 else: 

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

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

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

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

560 

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

562 print("Database VACUUMed.") 

563 

564 return 0 

565 

566 if args.command == "export": 

567 import json as _json 

568 

569 out_path = getattr(args, "output", None) 

570 out = open(out_path, "w", encoding="utf-8") if out_path else sys.stdout 

571 count = 0 

572 try: 

573 for record in export_history( 

574 args.db, 

575 tables=getattr(args, "tables", None), 

576 since=getattr(args, "since", None), 

577 include_messages=getattr(args, "include_messages", False), 

578 ): 

579 out.write(_json.dumps(record, default=str) + "\n") 

580 count += 1 

581 finally: 

582 if out_path: 

583 out.close() 

584 if out_path: 

585 logger.success(f"Exported {count:,} records to {out_path}") 

586 return 0 

587 

588 return 1