Coverage for little_loops / cli / session.py: 0%
60 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -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
9 recent most recent rows for an event kind (tool, file, issue, loop, correction)
10 backfill seed the database from existing on-disk sources
11"""
13from __future__ import annotations
15import argparse
16from pathlib import Path
18from little_loops.cli.output import configure_output, print_json, use_color_enabled
19from little_loops.logger import Logger
20from little_loops.session_store import DEFAULT_DB_PATH, backfill, recent, search
23def _build_parser() -> argparse.ArgumentParser:
24 """Build the argument parser for ll-session."""
25 parser = argparse.ArgumentParser(
26 prog="ll-session",
27 description="Query the unified session store (SQLite + FTS5)",
28 formatter_class=argparse.RawDescriptionHelpFormatter,
29 epilog="""
30Examples:
31 %(prog)s search --fts "rate limit" # Full-text search, BM25-ranked
32 %(prog)s recent --kind loop # Recent loop events
33 %(prog)s backfill # Seed the database from on-disk sources
34""",
35 )
36 parser.add_argument(
37 "--db",
38 type=Path,
39 default=DEFAULT_DB_PATH,
40 metavar="PATH",
41 help="Path to the session database (default: .ll/history.db)",
42 )
44 subparsers = parser.add_subparsers(dest="command", help="Available commands")
46 search_parser = subparsers.add_parser("search", help="FTS5 full-text search")
47 search_parser.add_argument("--fts", required=True, metavar="QUERY", help="FTS5 match query")
48 search_parser.add_argument(
49 "--limit", type=int, default=20, metavar="N", help="Maximum results (default: 20)"
50 )
52 recent_parser = subparsers.add_parser("recent", help="Recent events by kind")
53 recent_parser.add_argument(
54 "--kind",
55 required=True,
56 choices=["tool", "file", "issue", "loop", "correction", "message"],
57 help="Event kind to list",
58 )
59 recent_parser.add_argument(
60 "--limit", type=int, default=20, metavar="N", help="Maximum rows (default: 20)"
61 )
62 recent_parser.add_argument(
63 "--json", action="store_true", dest="json", help="Output as JSON array"
64 )
66 subparsers.add_parser("backfill", help="Seed the database from existing on-disk sources")
68 return parser
71def _parse_args() -> argparse.Namespace:
72 """Parse command-line arguments. Exposed for testing."""
73 return _build_parser().parse_args()
76def main_session() -> int:
77 """Entry point for ll-session command.
79 Returns:
80 0 on success, 1 when no subcommand is given or on error.
81 """
82 configure_output()
83 logger = Logger(use_color=use_color_enabled())
85 parser = _build_parser()
86 args = parser.parse_args()
88 if not args.command:
89 parser.print_help()
90 return 1
92 if args.command == "search":
93 try:
94 results = search(args.db, query=args.fts, limit=args.limit)
95 except ValueError as exc:
96 logger.error(str(exc))
97 return 1
98 if not results:
99 print("No matches.")
100 return 0
101 for row in results:
102 anchor = f" ({row['anchor']})" if row.get("anchor") else ""
103 print(f"[{row['kind']}] {row['content']}{anchor}")
104 return 0
106 if args.command == "recent":
107 rows = recent(args.db, kind=args.kind, limit=args.limit)
108 if args.json:
109 print_json(list(rows))
110 return 0
111 if not rows:
112 print(f"No {args.kind} events.")
113 return 0
114 for row in rows:
115 fields = ", ".join(f"{k}={v}" for k, v in row.items() if k != "id" and v is not None)
116 print(fields)
117 return 0
119 if args.command == "backfill":
120 counts = backfill(args.db)
121 total = sum(counts.values())
122 logger.success(
123 f"Backfilled {total} rows "
124 f"(issues={counts['issues']}, loops={counts['loops']}, "
125 f"tools={counts['tools']}, messages={counts.get('messages', 0)})"
126 )
127 return 0
129 return 1