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

1"""ll-session: query the unified session store (SQLite + FTS5). 

2 

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. 

6 

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""" 

17 

18from __future__ import annotations 

19 

20import argparse 

21import sys 

22from pathlib import Path 

23from typing import Any 

24 

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 

46 

47 

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 ) 

73 

74 subparsers = parser.add_subparsers(dest="command", help="Available commands") 

75 

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") 

78 

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) 

91 

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 ) 

111 

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) 

118 

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 ) 

128 

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) 

144 

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) 

152 

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) 

158 

159 return parser 

160 

161 

162def _parse_args() -> argparse.Namespace: 

163 """Parse command-line arguments. Exposed for testing.""" 

164 return _build_parser().parse_args() 

165 

166 

167def main_session() -> int: 

168 """Entry point for ll-session command. 

169 

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()) 

176 

177 parser = _build_parser() 

178 args = parser.parse_args() 

179 

180 if not args.command: 

181 parser.print_help() 

182 return 1 

183 

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 

197 

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 

215 

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 

230 

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 

235 

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 

249 

250 if args.command == "recent": 

251 issue_filter = getattr(args, "issue", None) 

252 

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 

258 

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 

268 

269 if not args.kind: 

270 logger.error("recent: --kind is required unless --issue is given") 

271 return 1 

272 

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 

289 

290 if args.command == "backfill": 

291 since_flag = getattr(args, "since", None) 

292 if since_flag is not None: 

293 from datetime import datetime 

294 

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 

329 

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 

339 

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 

350 

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 

363 

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 

371 

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 

379 

380 return 1