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

2

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

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

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

6

7 Subcommands:

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

20 from __future__ import annotations

21

22 import argparse

23 import sys

24 from pathlib import Path

25 from typing import Any

26

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

28 from little_loops.cli_args import add_json_arg

29 from little_loops.history_reader import (

30 ll_describe,

31 ll_expand,

32 ll_grep,

33 related_issue_events,

34 sessions_for_issue,

35)

36 from little_loops.history_reader import search as history_search

37 from little_loops.logger import Logger

38 from 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)

50 from little_loops.user_messages import get_project_folder

51

52

53 def _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="""

60 Examples:

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

244 def _parse_args() -> argparse.Namespace:

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

246 return _build_parser().parse_args()

247

248

249 def _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

267 def 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