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
« 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).
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"""
19from __future__ import annotations
21import argparse
22import sys
23from pathlib import Path
24from typing import Any
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
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 )
79 subparsers = parser.add_subparsers(dest="command", help="Available commands")
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")
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)
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 )
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)
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 )
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)
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)
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)
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)
195 return parser
198def _parse_args() -> argparse.Namespace:
199 """Parse command-line arguments. Exposed for testing."""
200 return _build_parser().parse_args()
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
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)
221def main_session() -> int:
222 """Entry point for ll-session command.
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())
231 parser = _build_parser()
232 args = parser.parse_args()
234 if not args.command:
235 parser.print_help()
236 return 1
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
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
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
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
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
304 if args.command == "recent":
305 issue_filter = getattr(args, "issue", None)
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
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
323 if not args.kind:
324 logger.error("recent: --kind is required unless --issue is given")
325 return 1
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
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
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
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
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
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
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
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
448 if args.command == "prune":
449 import json as _json
451 from little_loops.config.core import resolve_config_path
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
461 result = prune(args.db, config=config, dry_run=args.dry_run)
463 if args.json:
464 print_json(result)
465 return 0
467 if args.dry_run:
468 print("DRY RUN — no rows deleted\n")
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
480 if not result["pruned"]:
481 print("No pruning configured (raw_event_max_age_days is null).")
482 return 0
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.")
494 if not args.dry_run and result.get("vacuumed"):
495 print("Database VACUUMed.")
497 return 0
499 return 1