Coverage for little_loops / cli / session.py: 6%
245 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -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 cli_event_context,
42 connect,
43 prune,
44 recent,
45 search,
46)
47from little_loops.user_messages import get_project_folder
50def _build_parser() -> argparse.ArgumentParser:
51 """Build the argument parser for ll-session."""
52 parser = argparse.ArgumentParser(
53 prog="ll-session",
54 description="Query the unified session store (SQLite + FTS5)",
55 formatter_class=argparse.RawDescriptionHelpFormatter,
56 epilog="""
57Examples:
58 %(prog)s search --fts "rate limit" # Full-text search, BM25-ranked
59 %(prog)s search --fts "error" --kind loop # FTS5 search filtered by kind
60 %(prog)s recent --kind loop # Recent loop events
61 %(prog)s related BUG-1759 # Events for a specific issue
62 %(prog)s backfill # Seed the database from on-disk sources
63 %(prog)s grep "auth middleware" # Regex search over message_events
64 %(prog)s expand 42 # Messages covered by summary node 42
65 %(prog)s describe 42 # Metadata for summary node 42
66 %(prog)s prune --dry-run # Show what would be pruned
67 %(prog)s prune # Delete old raw events and VACUUM
68""",
69 )
70 parser.add_argument(
71 "--db",
72 type=Path,
73 default=DEFAULT_DB_PATH,
74 metavar="PATH",
75 help="Path to the session database (default: .ll/history.db)",
76 )
78 subparsers = parser.add_subparsers(dest="command", help="Available commands")
80 path_parser = subparsers.add_parser("path", help="Resolve JSONL file path for a session ID")
81 path_parser.add_argument("session_id", metavar="SESSION_ID", help="Session ID to look up")
83 search_parser = subparsers.add_parser("search", help="FTS5 full-text search")
84 search_parser.add_argument("--fts", required=True, metavar="QUERY", help="FTS5 match query")
85 search_parser.add_argument(
86 "--kind",
87 choices=["tool", "file", "issue", "loop", "correction", "message", "skill", "cli"],
88 default=None,
89 help="Filter results by event kind",
90 )
91 search_parser.add_argument(
92 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)"
93 )
94 add_json_arg(search_parser)
96 recent_parser = subparsers.add_parser("recent", help="Recent events by kind")
97 recent_parser.add_argument(
98 "--kind",
99 choices=["tool", "file", "issue", "loop", "correction", "message", "skill", "cli"],
100 default=None,
101 help="Event kind to list (required unless --issue is given)",
102 )
103 recent_parser.add_argument(
104 "--issue",
105 default=None,
106 metavar="ID",
107 help="Filter to sessions that touched this issue ID (e.g. ENH-1710)",
108 )
109 recent_parser.add_argument(
110 "--limit", type=int, default=20, metavar="N", help="Maximum rows (default: 20)"
111 )
112 recent_parser.add_argument(
113 "--json", action="store_true", dest="json", help="Output as JSON array"
114 )
116 related_parser = subparsers.add_parser("related", help="Issue events for an issue ID")
117 related_parser.add_argument("issue_id", metavar="ISSUE_ID", help="Issue ID (e.g., BUG-1759)")
118 related_parser.add_argument(
119 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)"
120 )
121 add_json_arg(related_parser)
123 backfill_parser = subparsers.add_parser(
124 "backfill", help="Seed the database from existing on-disk sources"
125 )
126 backfill_parser.add_argument(
127 "--since",
128 metavar="DATE",
129 default=None,
130 help="Only process JSONL files modified after DATE (ISO 8601 or YYYY-MM-DD); uses incremental mode",
131 )
132 backfill_parser.add_argument(
133 "--host",
134 choices=["claude-code", "codex", "opencode", "pi"],
135 default=None,
136 help="Host to discover session logs for (default: auto-detect from LL_HOOK_HOST env)",
137 )
139 grep_parser = subparsers.add_parser(
140 "grep", help="Regex search over message_events with summary node context"
141 )
142 grep_parser.add_argument("pattern", metavar="PATTERN", help="Regex pattern (case-insensitive)")
143 grep_parser.add_argument(
144 "--summary-id",
145 type=int,
146 default=None,
147 metavar="ID",
148 help="Restrict search to messages covered by this summary node ID",
149 )
150 grep_parser.add_argument(
151 "--limit", type=int, default=50, metavar="N", help="Maximum results (default: 50)"
152 )
153 add_json_arg(grep_parser)
155 expand_parser = subparsers.add_parser(
156 "expand", help="Return message_events covered by a summary node"
157 )
158 expand_parser.add_argument(
159 "summary_id", type=int, metavar="SUMMARY_ID", help="Summary node ID to expand"
160 )
161 add_json_arg(expand_parser)
163 describe_parser = subparsers.add_parser("describe", help="Show metadata for a summary node")
164 describe_parser.add_argument(
165 "node_id", type=int, metavar="NODE_ID", help="Summary node ID to describe"
166 )
167 add_json_arg(describe_parser)
169 prune_parser = subparsers.add_parser(
170 "prune",
171 help="Prune raw event rows older than configured max-age and VACUUM the database",
172 )
173 prune_parser.add_argument(
174 "--dry-run",
175 action="store_true",
176 default=False,
177 help="Report which rows would be deleted without actually deleting them",
178 )
179 add_json_arg(prune_parser)
181 return parser
184def _parse_args() -> argparse.Namespace:
185 """Parse command-line arguments. Exposed for testing."""
186 return _build_parser().parse_args()
189def main_session() -> int:
190 """Entry point for ll-session command.
192 Returns:
193 0 on success, 1 when no subcommand is given or on error.
194 """
195 with cli_event_context(DEFAULT_DB_PATH, "ll-session", sys.argv[1:]):
196 configure_output()
197 logger = Logger(use_color=use_color_enabled())
199 parser = _build_parser()
200 args = parser.parse_args()
202 if not args.command:
203 parser.print_help()
204 return 1
206 if args.command == "path":
207 conn = connect(args.db)
208 try:
209 row = conn.execute(
210 "SELECT jsonl_path FROM sessions WHERE session_id = ?", (args.session_id,)
211 ).fetchone()
212 finally:
213 conn.close()
214 if row is None:
215 print(f"Session {args.session_id} not found.")
216 return 1
217 print(row["jsonl_path"])
218 return 0
220 if args.command == "search":
221 results: list[Any]
222 if args.kind:
223 results = history_search(args.fts, kind=args.kind, limit=args.limit, db=args.db)
224 else:
225 try:
226 results = search(args.db, query=args.fts, limit=args.limit)
227 except ValueError as exc:
228 logger.error(str(exc))
229 return 1
230 if args.json:
231 if (
232 isinstance(results, list)
233 and results
234 and hasattr(results[0], "__dataclass_fields__")
235 ):
236 from dataclasses import asdict
238 results = [asdict(r) for r in results]
239 print_json(list(results))
240 return 0
241 if not results:
242 print("No matches.")
243 return 0
244 for row in results:
245 if hasattr(row, "__dataclass_fields__"):
246 anchor = f" ({row.anchor})" if row.anchor else ""
247 print(f"[{row.kind}] {row.content}{anchor}")
248 else:
249 anchor = f" ({row['anchor']})" if row.get("anchor") else ""
250 print(f"[{row['kind']}] {row['content']}{anchor}")
251 return 0
253 if args.command == "related":
254 events = related_issue_events(args.issue_id, limit=args.limit, db=args.db)
255 if args.json:
256 from dataclasses import asdict
258 print_json([asdict(e) for e in events])
259 return 0
260 if not events:
261 print(f"No events for {args.issue_id}.")
262 return 0
263 for e in events:
264 fields = ", ".join(
265 f"{k}={getattr(e, k)}"
266 for k in ("ts", "transition", "issue_type", "priority")
267 if getattr(e, k)
268 )
269 print(fields)
270 return 0
272 if args.command == "recent":
273 issue_filter = getattr(args, "issue", None)
275 # --issue only: show sessions that co-occurred with the issue
276 if issue_filter and not args.kind:
277 refs = sessions_for_issue(issue_filter, limit=args.limit, db=args.db)
278 if args.json:
279 from dataclasses import asdict
281 print_json([asdict(r) for r in refs])
282 return 0
283 if not refs:
284 print(f"No sessions found for {issue_filter}.")
285 return 0
286 for r in refs:
287 path = r.jsonl_path or "(no path)"
288 print(f"{r.session_id} {path}")
289 return 0
291 if not args.kind:
292 logger.error("recent: --kind is required unless --issue is given")
293 return 1
295 rows = recent(args.db, kind=args.kind, limit=args.limit)
296 if issue_filter:
297 session_ids = {r.session_id for r in sessions_for_issue(issue_filter, db=args.db)}
298 rows = [r for r in rows if r.get("session_id") in session_ids]
299 if args.json:
300 print_json(list(rows))
301 return 0
302 if not rows:
303 print(f"No {args.kind} events.")
304 return 0
305 for row in rows:
306 fields = ", ".join(
307 f"{k}={v}" for k, v in row.items() if k != "id" and v is not None
308 )
309 print(fields)
310 return 0
312 if args.command == "backfill":
313 since_flag = getattr(args, "since", None)
314 if since_flag is not None:
315 from datetime import datetime
317 try:
318 try:
319 dt = datetime.fromisoformat(since_flag.replace("Z", "+00:00"))
320 except ValueError:
321 dt = datetime.strptime(since_flag, "%Y-%m-%d")
322 since_ts = dt.timestamp()
323 except ValueError:
324 logger.error(f"Invalid date: {since_flag!r}. Use YYYY-MM-DD or ISO 8601.")
325 return 1
326 project_folder = get_project_folder(host=args.host)
327 if project_folder is None:
328 logger.error("No session project folder found; cannot discover JSONL files.")
329 return 1
330 jsonl_files = list(project_folder.glob("*.jsonl"))
331 inc_counts = backfill_incremental(
332 args.db, jsonl_files=jsonl_files, since_ts=since_ts
333 )
334 inc_total = sum(inc_counts.values())
335 logger.success(
336 f"Backfilled {inc_total} rows (incremental, since {since_flag}; "
337 f"tools={inc_counts['tools']}, messages={inc_counts['messages']}, "
338 f"sessions={inc_counts['sessions']}, corrections={inc_counts.get('corrections', 0)})"
339 )
340 return 0
341 # Full backfill (no --since): discover JSONL files so non-Claude-Code
342 # hosts also get message/tool/session backfill (ENH-1945).
343 project_folder = get_project_folder(host=args.host)
344 full_jsonl_files: list[Path] | None = (
345 list(project_folder.glob("*.jsonl")) if project_folder else None
346 )
347 counts = backfill(args.db, jsonl_files=full_jsonl_files)
348 total = sum(counts.values())
349 logger.success(
350 f"Backfilled {total} rows "
351 f"(issues={counts['issues']}, loops={counts['loops']}, "
352 f"tools={counts['tools']}, messages={counts.get('messages', 0)}, "
353 f"sessions={counts.get('sessions', 0)}, corrections={counts.get('corrections', 0)}, "
354 f"summaries={counts.get('summaries', 0)})"
355 )
356 return 0
358 if args.command == "grep":
359 grep_results = ll_grep(
360 args.pattern,
361 summary_id=args.summary_id,
362 limit=args.limit,
363 db=args.db,
364 )
365 if args.json:
366 from dataclasses import asdict
368 print_json([asdict(r) for r in grep_results])
369 return 0
370 if not grep_results:
371 print("No matches.")
372 return 0
373 for gr in grep_results:
374 node_info = f" [node {gr.summary_id}/{gr.summary_kind}]" if gr.summary_id else ""
375 snippet = gr.content[:120].replace("\n", " ")
376 print(f"{gr.ts} {snippet}{node_info}")
377 return 0
379 if args.command == "expand":
380 messages = ll_expand(args.summary_id, db=args.db)
381 if args.json:
382 print_json(messages)
383 return 0
384 if not messages:
385 print(f"No messages found for summary node {args.summary_id}.")
386 return 0
387 for m in messages:
388 snippet = (m.get("content") or "")[:120].replace("\n", " ")
389 print(f"{m.get('ts', '')} {snippet}")
390 return 0
392 if args.command == "describe":
393 node = ll_describe(args.node_id, db=args.db)
394 if node is None:
395 print(f"Summary node {args.node_id} not found.")
396 return 1
397 if args.json:
398 from dataclasses import asdict
400 print_json(asdict(node))
401 return 0
402 print(f"id={node.id} kind={node.kind} level={node.level} session={node.session_id}")
403 print(f"ts_start={node.ts_start} ts_end={node.ts_end}")
404 print(f"tokens={node.tokens} created_at={node.created_at}")
405 print(f"content: {node.content[:200]}")
406 return 0
408 if args.command == "prune":
409 import json as _json
411 from little_loops.config.core import resolve_config_path
413 config: dict | None = None
414 config_path = resolve_config_path(Path.cwd())
415 if config_path is not None:
416 try:
417 config = _json.loads(config_path.read_text(encoding="utf-8"))
418 except (OSError, _json.JSONDecodeError):
419 config = None
421 result = prune(args.db, config=config, dry_run=args.dry_run)
423 if args.json:
424 print_json(result)
425 return 0
427 if args.dry_run:
428 print("DRY RUN — no rows deleted\n")
430 if result["gate_unmet"]:
431 print("Gates unmet — pruning skipped:")
432 for reason in result["gate_unmet"]:
433 print(f" {reason}")
434 print(
435 f"\nDB: {result['db_size_mb']:.1f} MB | "
436 f"project age: {result['project_age_days']}d"
437 )
438 return 0
440 if not result["pruned"]:
441 print("No pruning configured (raw_event_max_age_days is null).")
442 return 0
444 deleted = result.get("deleted", {})
445 total = sum(deleted.values())
446 if total == 0:
447 print("Gates met — no eligible rows found.")
448 else:
449 label = "Would delete" if args.dry_run else "Deleted"
450 for table, count in deleted.items():
451 print(f" {table}: {count:,} rows")
452 print(f"\n{label} {total:,} rows total.")
454 if not args.dry_run and result.get("vacuumed"):
455 print("Database VACUUMed.")
457 return 0
459 return 1