Coverage for little_loops / cli / session.py: 7%
203 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -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"""
18from __future__ import annotations
20import argparse
21import sys
22from pathlib import Path
23from typing import Any
25from little_loops.cli.output import configure_output, print_json, use_color_enabled
26from little_loops.cli_args import add_json_arg
27from little_loops.history_reader import (
28 ll_describe,
29 ll_expand,
30 ll_grep,
31 related_issue_events,
32 sessions_for_issue,
33)
34from little_loops.history_reader import search as history_search
35from little_loops.logger import Logger
36from little_loops.session_store import (
37 DEFAULT_DB_PATH,
38 backfill,
39 backfill_incremental,
40 cli_event_context,
41 connect,
42 recent,
43 search,
44)
45from little_loops.user_messages import get_project_folder
48def _build_parser() -> argparse.ArgumentParser:
49 """Build the argument parser for ll-session."""
50 parser = argparse.ArgumentParser(
51 prog="ll-session",
52 description="Query the unified session store (SQLite + FTS5)",
53 formatter_class=argparse.RawDescriptionHelpFormatter,
54 epilog="""
55Examples:
56 %(prog)s search --fts "rate limit" # Full-text search, BM25-ranked
57 %(prog)s search --fts "error" --kind loop # FTS5 search filtered by kind
58 %(prog)s recent --kind loop # Recent loop events
59 %(prog)s related BUG-1759 # Events for a specific issue
60 %(prog)s backfill # Seed the database from on-disk sources
61 %(prog)s grep "auth middleware" # Regex search over message_events
62 %(prog)s expand 42 # Messages covered by summary node 42
63 %(prog)s describe 42 # Metadata for summary node 42
64""",
65 )
66 parser.add_argument(
67 "--db",
68 type=Path,
69 default=DEFAULT_DB_PATH,
70 metavar="PATH",
71 help="Path to the session database (default: .ll/history.db)",
72 )
74 subparsers = parser.add_subparsers(dest="command", help="Available commands")
76 path_parser = subparsers.add_parser("path", help="Resolve JSONL file path for a session ID")
77 path_parser.add_argument("session_id", metavar="SESSION_ID", help="Session ID to look up")
79 search_parser = subparsers.add_parser("search", help="FTS5 full-text search")
80 search_parser.add_argument("--fts", required=True, metavar="QUERY", help="FTS5 match query")
81 search_parser.add_argument(
82 "--kind",
83 choices=["tool", "file", "issue", "loop", "correction", "message", "skill", "cli"],
84 default=None,
85 help="Filter results by event kind",
86 )
87 search_parser.add_argument(
88 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)"
89 )
90 add_json_arg(search_parser)
92 recent_parser = subparsers.add_parser("recent", help="Recent events by kind")
93 recent_parser.add_argument(
94 "--kind",
95 choices=["tool", "file", "issue", "loop", "correction", "message", "skill", "cli"],
96 default=None,
97 help="Event kind to list (required unless --issue is given)",
98 )
99 recent_parser.add_argument(
100 "--issue",
101 default=None,
102 metavar="ID",
103 help="Filter to sessions that touched this issue ID (e.g. ENH-1710)",
104 )
105 recent_parser.add_argument(
106 "--limit", type=int, default=20, metavar="N", help="Maximum rows (default: 20)"
107 )
108 recent_parser.add_argument(
109 "--json", action="store_true", dest="json", help="Output as JSON array"
110 )
112 related_parser = subparsers.add_parser("related", help="Issue events for an issue ID")
113 related_parser.add_argument("issue_id", metavar="ISSUE_ID", help="Issue ID (e.g., BUG-1759)")
114 related_parser.add_argument(
115 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)"
116 )
117 add_json_arg(related_parser)
119 backfill_parser = subparsers.add_parser(
120 "backfill", help="Seed the database from existing on-disk sources"
121 )
122 backfill_parser.add_argument(
123 "--since",
124 metavar="DATE",
125 default=None,
126 help="Only process JSONL files modified after DATE (ISO 8601 or YYYY-MM-DD); uses incremental mode",
127 )
129 grep_parser = subparsers.add_parser(
130 "grep", help="Regex search over message_events with summary node context"
131 )
132 grep_parser.add_argument("pattern", metavar="PATTERN", help="Regex pattern (case-insensitive)")
133 grep_parser.add_argument(
134 "--summary-id",
135 type=int,
136 default=None,
137 metavar="ID",
138 help="Restrict search to messages covered by this summary node ID",
139 )
140 grep_parser.add_argument(
141 "--limit", type=int, default=50, metavar="N", help="Maximum results (default: 50)"
142 )
143 add_json_arg(grep_parser)
145 expand_parser = subparsers.add_parser(
146 "expand", help="Return message_events covered by a summary node"
147 )
148 expand_parser.add_argument(
149 "summary_id", type=int, metavar="SUMMARY_ID", help="Summary node ID to expand"
150 )
151 add_json_arg(expand_parser)
153 describe_parser = subparsers.add_parser("describe", help="Show metadata for a summary node")
154 describe_parser.add_argument(
155 "node_id", type=int, metavar="NODE_ID", help="Summary node ID to describe"
156 )
157 add_json_arg(describe_parser)
159 return parser
162def _parse_args() -> argparse.Namespace:
163 """Parse command-line arguments. Exposed for testing."""
164 return _build_parser().parse_args()
167def main_session() -> int:
168 """Entry point for ll-session command.
170 Returns:
171 0 on success, 1 when no subcommand is given or on error.
172 """
173 with cli_event_context(DEFAULT_DB_PATH, "ll-session", sys.argv[1:]):
174 configure_output()
175 logger = Logger(use_color=use_color_enabled())
177 parser = _build_parser()
178 args = parser.parse_args()
180 if not args.command:
181 parser.print_help()
182 return 1
184 if args.command == "path":
185 conn = connect(args.db)
186 try:
187 row = conn.execute(
188 "SELECT jsonl_path FROM sessions WHERE session_id = ?", (args.session_id,)
189 ).fetchone()
190 finally:
191 conn.close()
192 if row is None:
193 print(f"Session {args.session_id} not found.")
194 return 1
195 print(row["jsonl_path"])
196 return 0
198 if args.command == "search":
199 results: list[Any]
200 if args.kind:
201 results = history_search(args.fts, kind=args.kind, limit=args.limit, db=args.db)
202 else:
203 try:
204 results = search(args.db, query=args.fts, limit=args.limit)
205 except ValueError as exc:
206 logger.error(str(exc))
207 return 1
208 if args.json:
209 if (
210 isinstance(results, list)
211 and results
212 and hasattr(results[0], "__dataclass_fields__")
213 ):
214 from dataclasses import asdict
216 results = [asdict(r) for r in results]
217 print_json(list(results))
218 return 0
219 if not results:
220 print("No matches.")
221 return 0
222 for row in results:
223 if hasattr(row, "__dataclass_fields__"):
224 anchor = f" ({row.anchor})" if row.anchor else ""
225 print(f"[{row.kind}] {row.content}{anchor}")
226 else:
227 anchor = f" ({row['anchor']})" if row.get("anchor") else ""
228 print(f"[{row['kind']}] {row['content']}{anchor}")
229 return 0
231 if args.command == "related":
232 events = related_issue_events(args.issue_id, limit=args.limit, db=args.db)
233 if args.json:
234 from dataclasses import asdict
236 print_json([asdict(e) for e in events])
237 return 0
238 if not events:
239 print(f"No events for {args.issue_id}.")
240 return 0
241 for e in events:
242 fields = ", ".join(
243 f"{k}={getattr(e, k)}"
244 for k in ("ts", "transition", "issue_type", "priority")
245 if getattr(e, k)
246 )
247 print(fields)
248 return 0
250 if args.command == "recent":
251 issue_filter = getattr(args, "issue", None)
253 # --issue only: show sessions that co-occurred with the issue
254 if issue_filter and not args.kind:
255 refs = sessions_for_issue(issue_filter, limit=args.limit, db=args.db)
256 if args.json:
257 from dataclasses import asdict
259 print_json([asdict(r) for r in refs])
260 return 0
261 if not refs:
262 print(f"No sessions found for {issue_filter}.")
263 return 0
264 for r in refs:
265 path = r.jsonl_path or "(no path)"
266 print(f"{r.session_id} {path}")
267 return 0
269 if not args.kind:
270 logger.error("recent: --kind is required unless --issue is given")
271 return 1
273 rows = recent(args.db, kind=args.kind, limit=args.limit)
274 if issue_filter:
275 session_ids = {r.session_id for r in sessions_for_issue(issue_filter, db=args.db)}
276 rows = [r for r in rows if r.get("session_id") in session_ids]
277 if args.json:
278 print_json(list(rows))
279 return 0
280 if not rows:
281 print(f"No {args.kind} events.")
282 return 0
283 for row in rows:
284 fields = ", ".join(
285 f"{k}={v}" for k, v in row.items() if k != "id" and v is not None
286 )
287 print(fields)
288 return 0
290 if args.command == "backfill":
291 since_flag = getattr(args, "since", None)
292 if since_flag is not None:
293 from datetime import datetime
295 try:
296 try:
297 dt = datetime.fromisoformat(since_flag.replace("Z", "+00:00"))
298 except ValueError:
299 dt = datetime.strptime(since_flag, "%Y-%m-%d")
300 since_ts = dt.timestamp()
301 except ValueError:
302 logger.error(f"Invalid date: {since_flag!r}. Use YYYY-MM-DD or ISO 8601.")
303 return 1
304 project_folder = get_project_folder()
305 if project_folder is None:
306 logger.error("No Claude project folder found; cannot discover JSONL files.")
307 return 1
308 jsonl_files = list(project_folder.glob("*.jsonl"))
309 inc_counts = backfill_incremental(
310 args.db, jsonl_files=jsonl_files, since_ts=since_ts
311 )
312 inc_total = sum(inc_counts.values())
313 logger.success(
314 f"Backfilled {inc_total} rows (incremental, since {since_flag}; "
315 f"tools={inc_counts['tools']}, messages={inc_counts['messages']}, "
316 f"sessions={inc_counts['sessions']}, corrections={inc_counts.get('corrections', 0)})"
317 )
318 return 0
319 counts = backfill(args.db)
320 total = sum(counts.values())
321 logger.success(
322 f"Backfilled {total} rows "
323 f"(issues={counts['issues']}, loops={counts['loops']}, "
324 f"tools={counts['tools']}, messages={counts.get('messages', 0)}, "
325 f"sessions={counts.get('sessions', 0)}, corrections={counts.get('corrections', 0)}, "
326 f"summaries={counts.get('summaries', 0)})"
327 )
328 return 0
330 if args.command == "grep":
331 grep_results = ll_grep(
332 args.pattern,
333 summary_id=args.summary_id,
334 limit=args.limit,
335 db=args.db,
336 )
337 if args.json:
338 from dataclasses import asdict
340 print_json([asdict(r) for r in grep_results])
341 return 0
342 if not grep_results:
343 print("No matches.")
344 return 0
345 for gr in grep_results:
346 node_info = f" [node {gr.summary_id}/{gr.summary_kind}]" if gr.summary_id else ""
347 snippet = gr.content[:120].replace("\n", " ")
348 print(f"{gr.ts} {snippet}{node_info}")
349 return 0
351 if args.command == "expand":
352 messages = ll_expand(args.summary_id, db=args.db)
353 if args.json:
354 print_json(messages)
355 return 0
356 if not messages:
357 print(f"No messages found for summary node {args.summary_id}.")
358 return 0
359 for m in messages:
360 snippet = (m.get("content") or "")[:120].replace("\n", " ")
361 print(f"{m.get('ts', '')} {snippet}")
362 return 0
364 if args.command == "describe":
365 node = ll_describe(args.node_id, db=args.db)
366 if node is None:
367 print(f"Summary node {args.node_id} not found.")
368 return 1
369 if args.json:
370 from dataclasses import asdict
372 print_json(asdict(node))
373 return 0
374 print(f"id={node.id} kind={node.kind} session={node.session_id}")
375 print(f"ts_start={node.ts_start} ts_end={node.ts_end}")
376 print(f"tokens={node.tokens} created_at={node.created_at}")
377 print(f"content: {node.content[:200]}")
378 return 0
380 return 1