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

267 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:30 -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 backfill_snapshots, 

42 cli_event_context, 

43 connect, 

44 prune, 

45 recent, 

46 search, 

47) 

48from little_loops.user_messages import get_project_folder 

49 

50 

51def _build_parser() -> argparse.ArgumentParser: 

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

53 parser = argparse.ArgumentParser( 

54 prog="ll-session", 

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

56 formatter_class=argparse.RawDescriptionHelpFormatter, 

57 epilog=""" 

58Examples: 

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

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

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

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

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

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

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

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

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

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

69""", 

70 ) 

71 parser.add_argument( 

72 "--db", 

73 type=Path, 

74 default=DEFAULT_DB_PATH, 

75 metavar="PATH", 

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

77 ) 

78 

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

80 

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

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

83 

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

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

86 search_parser.add_argument( 

87 "--kind", 

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

89 default=None, 

90 help="Filter results by event kind", 

91 ) 

92 search_parser.add_argument( 

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

94 ) 

95 add_json_arg(search_parser) 

96 

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

98 recent_parser.add_argument( 

99 "--kind", 

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

101 default=None, 

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

103 ) 

104 recent_parser.add_argument( 

105 "--issue", 

106 default=None, 

107 metavar="ID", 

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

109 ) 

110 recent_parser.add_argument( 

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

112 ) 

113 recent_parser.add_argument( 

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

115 ) 

116 

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

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

119 related_parser.add_argument( 

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

121 ) 

122 add_json_arg(related_parser) 

123 

124 backfill_parser = subparsers.add_parser( 

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

126 ) 

127 backfill_parser.add_argument( 

128 "--since", 

129 metavar="DATE", 

130 default=None, 

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

132 ) 

133 backfill_parser.add_argument( 

134 "--host", 

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

136 default=None, 

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

138 ) 

139 backfill_parser.add_argument( 

140 "--extract-decisions", 

141 action="store_true", 

142 default=False, 

143 dest="extract_decisions", 

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

145 ) 

146 backfill_parser.add_argument( 

147 "--snapshots", 

148 action="store_true", 

149 default=False, 

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

151 ) 

152 

153 grep_parser = subparsers.add_parser( 

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

155 ) 

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

157 grep_parser.add_argument( 

158 "--summary-id", 

159 type=int, 

160 default=None, 

161 metavar="ID", 

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

163 ) 

164 grep_parser.add_argument( 

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

166 ) 

167 add_json_arg(grep_parser) 

168 

169 expand_parser = subparsers.add_parser( 

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

171 ) 

172 expand_parser.add_argument( 

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

174 ) 

175 add_json_arg(expand_parser) 

176 

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

178 describe_parser.add_argument( 

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

180 ) 

181 add_json_arg(describe_parser) 

182 

183 prune_parser = subparsers.add_parser( 

184 "prune", 

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

186 ) 

187 prune_parser.add_argument( 

188 "--dry-run", 

189 action="store_true", 

190 default=False, 

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

192 ) 

193 add_json_arg(prune_parser) 

194 

195 return parser 

196 

197 

198def _parse_args() -> argparse.Namespace: 

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

200 return _build_parser().parse_args() 

201 

202 

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

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

205 import subprocess 

206 import sys 

207 

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

209 if since: 

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

211 try: 

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

213 if result.returncode != 0: 

214 print( 

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

216 ) 

217 except FileNotFoundError: 

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

219 

220 

221def main_session() -> int: 

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

223 

224 Returns: 

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

226 """ 

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

228 configure_output() 

229 logger = Logger(use_color=use_color_enabled()) 

230 

231 parser = _build_parser() 

232 args = parser.parse_args() 

233 

234 if not args.command: 

235 parser.print_help() 

236 return 1 

237 

238 if args.command == "path": 

239 conn = connect(args.db) 

240 try: 

241 row = conn.execute( 

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

243 ).fetchone() 

244 finally: 

245 conn.close() 

246 if row is None: 

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

248 return 1 

249 print(row["jsonl_path"]) 

250 return 0 

251 

252 if args.command == "search": 

253 results: list[Any] 

254 if args.kind: 

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

256 else: 

257 try: 

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

259 except ValueError as exc: 

260 logger.error(str(exc)) 

261 return 1 

262 if args.json: 

263 if ( 

264 isinstance(results, list) 

265 and results 

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

267 ): 

268 from dataclasses import asdict 

269 

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

271 print_json(list(results)) 

272 return 0 

273 if not results: 

274 print("No matches.") 

275 return 0 

276 for row in results: 

277 if hasattr(row, "__dataclass_fields__"): 

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

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

280 else: 

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

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

283 return 0 

284 

285 if args.command == "related": 

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

287 if args.json: 

288 from dataclasses import asdict 

289 

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

291 return 0 

292 if not events: 

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

294 return 0 

295 for e in events: 

296 fields = ", ".join( 

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

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

299 if getattr(e, k) 

300 ) 

301 print(fields) 

302 return 0 

303 

304 if args.command == "recent": 

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

306 

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

308 if issue_filter and not args.kind: 

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

310 if args.json: 

311 from dataclasses import asdict 

312 

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

314 return 0 

315 if not refs: 

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

317 return 0 

318 for r in refs: 

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

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

321 return 0 

322 

323 if not args.kind: 

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

325 return 1 

326 

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

328 if issue_filter: 

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

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

331 if args.json: 

332 print_json(list(rows)) 

333 return 0 

334 if not rows: 

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

336 return 0 

337 for row in rows: 

338 fields = ", ".join( 

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

340 ) 

341 print(fields) 

342 return 0 

343 

344 if args.command == "backfill": 

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

346 count = backfill_snapshots(args.db) 

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

348 return 0 

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

350 if since_flag is not None: 

351 from datetime import datetime 

352 

353 try: 

354 try: 

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

356 except ValueError: 

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

358 since_ts = dt.timestamp() 

359 except ValueError: 

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

361 return 1 

362 project_folder = get_project_folder(host=args.host) 

363 if project_folder is None: 

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

365 return 1 

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

367 inc_counts = backfill_incremental( 

368 args.db, jsonl_files=jsonl_files, since_ts=since_ts 

369 ) 

370 inc_total = sum(inc_counts.values()) 

371 logger.success( 

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

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

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

375 ) 

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

377 _run_extract_decisions(since=since_flag) 

378 return 0 

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

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

381 project_folder = get_project_folder(host=args.host) 

382 full_jsonl_files: list[Path] | None = ( 

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

384 ) 

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

386 total = sum(counts.values()) 

387 logger.success( 

388 f"Backfilled {total} rows " 

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

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

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

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

393 ) 

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

395 _run_extract_decisions(since=None) 

396 return 0 

397 

398 if args.command == "grep": 

399 grep_results = ll_grep( 

400 args.pattern, 

401 summary_id=args.summary_id, 

402 limit=args.limit, 

403 db=args.db, 

404 ) 

405 if args.json: 

406 from dataclasses import asdict 

407 

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

409 return 0 

410 if not grep_results: 

411 print("No matches.") 

412 return 0 

413 for gr in grep_results: 

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

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

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

417 return 0 

418 

419 if args.command == "expand": 

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

421 if args.json: 

422 print_json(messages) 

423 return 0 

424 if not messages: 

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

426 return 0 

427 for m in messages: 

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

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

430 return 0 

431 

432 if args.command == "describe": 

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

434 if node is None: 

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

436 return 1 

437 if args.json: 

438 from dataclasses import asdict 

439 

440 print_json(asdict(node)) 

441 return 0 

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

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

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

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

446 return 0 

447 

448 if args.command == "prune": 

449 import json as _json 

450 

451 from little_loops.config.core import resolve_config_path 

452 

453 config: dict | None = None 

454 config_path = resolve_config_path(Path.cwd()) 

455 if config_path is not None: 

456 try: 

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

458 except (OSError, _json.JSONDecodeError): 

459 config = None 

460 

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

462 

463 if args.json: 

464 print_json(result) 

465 return 0 

466 

467 if args.dry_run: 

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

469 

470 if result["gate_unmet"]: 

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

472 for reason in result["gate_unmet"]: 

473 print(f" {reason}") 

474 print( 

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

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

477 ) 

478 return 0 

479 

480 if not result["pruned"]: 

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

482 return 0 

483 

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

485 total = sum(deleted.values()) 

486 if total == 0: 

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

488 else: 

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

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

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

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

493 

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

495 print("Database VACUUMed.") 

496 

497 return 0 

498 

499 return 1