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
« 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).
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.
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"""
20from __future__ import annotations
22import argparse
23import sys
24from pathlib import Path
25from typing import Any
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
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 )
81 subparsers = parser.add_subparsers(dest="command", help="Available commands")
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")
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)
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 )
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)
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 )
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 )
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)
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)
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)
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)
241 return parser
244def _parse_args() -> argparse.Namespace:
245 """Parse command-line arguments. Exposed for testing."""
246 return _build_parser().parse_args()
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
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)
267def main_session() -> int:
268 """Entry point for ll-session command.
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())
277 parser = _build_parser()
278 args = parser.parse_args()
280 if not args.command:
281 parser.print_help()
282 return 1
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
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
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
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
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
350 if args.command == "recent":
351 issue_filter = getattr(args, "issue", None)
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
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
369 if not args.kind:
370 logger.error("recent: --kind is required unless --issue is given")
371 return 1
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
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
396 # Read project config so compaction settings are respected (same
397 # pattern as the prune handler).
398 import json as _json
400 from little_loops.config.core import resolve_config_path
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
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
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
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
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
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
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
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
515 if args.command == "prune":
516 import json as _json
518 from little_loops.config.core import resolve_config_path
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
528 result = prune(args.db, config=config, dry_run=args.dry_run)
530 if args.json:
531 print_json(result)
532 return 0
534 if args.dry_run:
535 print("DRY RUN — no rows deleted\n")
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
547 if not result["pruned"]:
548 print("No pruning configured (raw_event_max_age_days is null).")
549 return 0
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.")
561 if not args.dry_run and result.get("vacuumed"):
562 print("Database VACUUMed.")
564 return 0
566 if args.command == "export":
567 import json as _json
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
588 return 1