Coverage for little_loops / user_messages.py: 15%

393 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-04 12:21 -0500

1"""Extract and analyze user messages from Claude Code logs. 

2 

3Provides functionality to extract user messages from Claude Code session 

4logs stored in ~/.claude/projects/. 

5 

6Usage as CLI: 

7 ll-messages # Last 100 messages to file 

8 ll-messages -n 50 # Last 50 messages 

9 ll-messages --since 2026-01-01 # Since date 

10 ll-messages -o output.jsonl # Custom output path 

11 ll-messages --stdout # Print to terminal instead of file 

12 

13Usage as library: 

14 from little_loops.user_messages import extract_user_messages, get_project_folder 

15 

16 project_folder = get_project_folder() 

17 messages = extract_user_messages(project_folder, limit=50) 

18""" 

19 

20from __future__ import annotations 

21 

22import json 

23from dataclasses import dataclass 

24from datetime import datetime, timedelta 

25from pathlib import Path 

26 

27__all__ = [ 

28 "UserMessage", 

29 "ResponseMetadata", 

30 "CommandRecord", 

31 "ExampleRecord", 

32 "get_project_folder", 

33 "extract_user_messages", 

34 "extract_commands", 

35 "build_examples", 

36 "extract_conversation_turns", 

37 "save_messages", 

38] 

39 

40 

41@dataclass 

42class UserMessage: 

43 """Extracted user message with metadata. 

44 

45 Attributes: 

46 content: The text content of the user message 

47 timestamp: When the message was sent 

48 session_id: Claude Code session identifier 

49 uuid: Unique message identifier 

50 cwd: Working directory when message was sent 

51 git_branch: Git branch active when message was sent 

52 is_sidechain: Whether this was a sidechain message 

53 """ 

54 

55 content: str 

56 timestamp: datetime 

57 session_id: str 

58 uuid: str 

59 cwd: str | None = None 

60 git_branch: str | None = None 

61 is_sidechain: bool = False 

62 

63 response_metadata: ResponseMetadata | None = None 

64 

65 def to_dict(self) -> dict[str, object]: 

66 """Convert to dictionary for JSON serialization.""" 

67 result: dict[str, object] = { 

68 "content": self.content, 

69 "timestamp": self.timestamp.isoformat(), 

70 "session_id": self.session_id, 

71 "uuid": self.uuid, 

72 "cwd": self.cwd, 

73 "git_branch": self.git_branch, 

74 "is_sidechain": self.is_sidechain, 

75 } 

76 if self.response_metadata is not None: 

77 result["response_metadata"] = self.response_metadata.to_dict() 

78 return result 

79 

80 

81@dataclass 

82class ResponseMetadata: 

83 """Metadata extracted from assistant response. 

84 

85 Attributes: 

86 tools_used: List of tools and their usage counts 

87 files_read: Files accessed via Read tool 

88 files_modified: Files changed via Edit/Write tools 

89 completion_status: "success", "failure", or "partial" 

90 error_message: Error text if failure detected 

91 """ 

92 

93 tools_used: list[dict[str, str | int]] 

94 files_read: list[str] 

95 files_modified: list[str] 

96 completion_status: str 

97 error_message: str | None = None 

98 

99 def to_dict(self) -> dict[str, object]: 

100 """Convert to dictionary for JSON serialization.""" 

101 return { 

102 "tools_used": self.tools_used, 

103 "files_read": self.files_read, 

104 "files_modified": self.files_modified, 

105 "completion_status": self.completion_status, 

106 "error_message": self.error_message, 

107 } 

108 

109 

110@dataclass 

111class CommandRecord: 

112 """Extracted CLI command from assistant tool_use. 

113 

114 Attributes: 

115 content: The command string that was executed 

116 timestamp: When the command was issued 

117 session_id: Claude Code session identifier 

118 uuid: Unique record identifier 

119 tool: Tool name (e.g., "Bash") 

120 cwd: Working directory when command was issued 

121 git_branch: Git branch active when command was issued 

122 """ 

123 

124 content: str 

125 timestamp: datetime 

126 session_id: str 

127 uuid: str 

128 tool: str 

129 cwd: str | None = None 

130 git_branch: str | None = None 

131 

132 def to_dict(self) -> dict[str, object]: 

133 """Convert to dictionary for JSON serialization.""" 

134 return { 

135 "type": "command", 

136 "content": self.content, 

137 "timestamp": self.timestamp.isoformat(), 

138 "session_id": self.session_id, 

139 "uuid": self.uuid, 

140 "tool": self.tool, 

141 "cwd": self.cwd, 

142 "git_branch": self.git_branch, 

143 } 

144 

145 

146@dataclass 

147class ExampleRecord: 

148 """Training example pair extracted from a skill invocation session. 

149 

150 Attributes: 

151 skill: The skill name (e.g., "capture-issue") 

152 input: Concatenated preceding user messages as context 

153 output: JSON-serialized ResponseMetadata summary (tools_used, files_modified, 

154 completion_status); free-text assistant response capture is deferred 

155 session_id: Claude Code session identifier 

156 timestamp: When the skill was invoked 

157 context_window: Number of preceding messages used as context 

158 """ 

159 

160 skill: str 

161 input: str 

162 output: str 

163 session_id: str 

164 timestamp: datetime 

165 context_window: int 

166 

167 def to_dict(self) -> dict[str, object]: 

168 """Convert to dictionary for JSON serialization.""" 

169 return { 

170 "type": "example", 

171 "skill": self.skill, 

172 "input": self.input, 

173 "output": self.output, 

174 "session_id": self.session_id, 

175 "timestamp": self.timestamp.isoformat(), 

176 "context_window": self.context_window, 

177 } 

178 

179 

180def _extract_response_metadata(response_record: dict) -> ResponseMetadata | None: 

181 """Extract metadata from an assistant response record. 

182 

183 Args: 

184 response_record: The assistant record from JSONL 

185 

186 Returns: 

187 ResponseMetadata if parseable, None otherwise 

188 """ 

189 message_data = response_record.get("message", {}) 

190 content = message_data.get("content", []) 

191 

192 if not isinstance(content, list): 

193 return None 

194 

195 tools_used: dict[str, int] = {} 

196 files_read: list[str] = [] 

197 files_modified: list[str] = [] 

198 

199 for block in content: 

200 if not isinstance(block, dict): 

201 continue 

202 if block.get("type") != "tool_use": 

203 continue 

204 

205 tool_name = block.get("name", "") 

206 tools_used[tool_name] = tools_used.get(tool_name, 0) + 1 

207 

208 tool_input = block.get("input", {}) 

209 if tool_name == "Read": 

210 file_path = tool_input.get("file_path") 

211 if file_path: 

212 files_read.append(file_path) 

213 elif tool_name in ("Edit", "Write"): 

214 file_path = tool_input.get("file_path") 

215 if file_path: 

216 files_modified.append(file_path) 

217 

218 # Detect completion status from text content 

219 completion_status = _detect_completion_status(content) 

220 error_message = _detect_error_message(content) if completion_status == "failure" else None 

221 

222 # Convert tools_used dict to list format 

223 tools_list: list[dict[str, str | int]] = [ 

224 {"tool": name, "count": count} for name, count in tools_used.items() 

225 ] 

226 

227 return ResponseMetadata( 

228 tools_used=tools_list, 

229 files_read=files_read, 

230 files_modified=files_modified, 

231 completion_status=completion_status, 

232 error_message=error_message, 

233 ) 

234 

235 

236def _aggregate_response_metadata(responses: list[dict]) -> ResponseMetadata | None: 

237 """Aggregate metadata from multiple assistant response records. 

238 

239 Combines tool counts, file lists, and uses completion status from final response. 

240 

241 Args: 

242 responses: List of assistant records from JSONL 

243 

244 Returns: 

245 Aggregated ResponseMetadata, or None if no valid responses 

246 """ 

247 if not responses: 

248 return None 

249 

250 tools_used: dict[str, int] = {} 

251 files_read: set[str] = set() 

252 files_modified: set[str] = set() 

253 completion_status = "success" 

254 error_message: str | None = None 

255 

256 for response_record in responses: 

257 message_data = response_record.get("message", {}) 

258 content = message_data.get("content", []) 

259 

260 if not isinstance(content, list): 

261 continue 

262 

263 for block in content: 

264 if not isinstance(block, dict): 

265 continue 

266 if block.get("type") != "tool_use": 

267 continue 

268 

269 tool_name = block.get("name", "") 

270 tools_used[tool_name] = tools_used.get(tool_name, 0) + 1 

271 

272 tool_input = block.get("input", {}) 

273 if tool_name == "Read": 

274 file_path = tool_input.get("file_path") 

275 if file_path: 

276 files_read.add(file_path) 

277 elif tool_name in ("Edit", "Write"): 

278 file_path = tool_input.get("file_path") 

279 if file_path: 

280 files_modified.add(file_path) 

281 

282 # Use completion status from the final response 

283 final_content = responses[-1].get("message", {}).get("content", []) 

284 if isinstance(final_content, list): 

285 completion_status = _detect_completion_status(final_content) 

286 if completion_status == "failure": 

287 error_message = _detect_error_message(final_content) 

288 

289 # Convert to output format 

290 tools_list: list[dict[str, str | int]] = [ 

291 {"tool": name, "count": count} for name, count in tools_used.items() 

292 ] 

293 

294 return ResponseMetadata( 

295 tools_used=tools_list, 

296 files_read=sorted(files_read), 

297 files_modified=sorted(files_modified), 

298 completion_status=completion_status, 

299 error_message=error_message, 

300 ) 

301 

302 

303def _detect_completion_status(content: list) -> str: 

304 """Detect completion status from response content. 

305 

306 Args: 

307 content: List of content blocks from assistant response 

308 

309 Returns: 

310 "success", "failure", or "partial" 

311 """ 

312 text_parts = [] 

313 for block in content: 

314 if isinstance(block, dict) and block.get("type") == "text": 

315 text_parts.append(block.get("text", "")) 

316 

317 text = " ".join(text_parts).lower() 

318 

319 # Check for error indicators 

320 error_patterns = ["error", "failed", "couldn't", "unable to", "cannot"] 

321 if any(pattern in text for pattern in error_patterns): 

322 return "failure" 

323 

324 # Check for partial completion 

325 partial_patterns = ["partially", "some of", "not all", "incomplete"] 

326 if any(pattern in text for pattern in partial_patterns): 

327 return "partial" 

328 

329 return "success" 

330 

331 

332def _detect_error_message(content: list) -> str | None: 

333 """Extract error message from response content. 

334 

335 Args: 

336 content: List of content blocks from assistant response 

337 

338 Returns: 

339 Error message if found, None otherwise 

340 """ 

341 for block in content: 

342 if isinstance(block, dict) and block.get("type") == "text": 

343 text = block.get("text", "") 

344 # Look for common error message patterns 

345 lower_text = text.lower() 

346 if "error:" in lower_text or "failed:" in lower_text: 

347 # Extract the line containing the error 

348 for line in text.split("\n"): 

349 if "error" in line.lower() or "failed" in line.lower(): 

350 result = line.strip()[:200] # Limit length 

351 return result if isinstance(result, str) else None 

352 return None 

353 

354 

355def get_project_folder(cwd: Path | None = None) -> Path | None: 

356 """Map current directory to Claude Code project folder. 

357 

358 Converts: /home/user/foo/bar -> ~/.claude/projects/-home-user-foo-bar 

359 

360 Args: 

361 cwd: Working directory to map. If None, uses current directory. 

362 

363 Returns: 

364 Path to Claude project folder, or None if it doesn't exist. 

365 """ 

366 if cwd is None: 

367 cwd = Path.cwd() 

368 

369 # Convert path to dash-separated format 

370 # /home/user/foo/bar -> -home-user-foo-bar 

371 path_str = str(cwd.resolve()) 

372 encoded_path = path_str.replace("/", "-") 

373 

374 # Build project folder path 

375 claude_projects = Path.home() / ".claude" / "projects" 

376 project_folder = claude_projects / encoded_path 

377 

378 if project_folder.exists(): 

379 return project_folder 

380 

381 return None 

382 

383 

384def extract_user_messages( 

385 project_folder: Path, 

386 limit: int | None = None, 

387 since: datetime | None = None, 

388 include_agent_sessions: bool = True, 

389 include_response_context: bool = False, 

390) -> list[UserMessage]: 

391 """Extract user messages from all JSONL session files. 

392 

393 Filters: 

394 - type == "user" 

395 - message.content is string (real user input) 

396 - message.content is array but [0].type != "tool_result" 

397 

398 Args: 

399 project_folder: Path to Claude project folder 

400 limit: Maximum number of messages to return 

401 since: Only include messages after this datetime 

402 include_agent_sessions: Whether to include agent-*.jsonl files 

403 include_response_context: Whether to include metadata from assistant responses 

404 

405 Returns: 

406 Messages sorted by timestamp, most recent first. 

407 """ 

408 messages: list[UserMessage] = [] 

409 

410 # Find all JSONL files 

411 pattern = "*.jsonl" 

412 jsonl_files = list(project_folder.glob(pattern)) 

413 

414 for jsonl_file in jsonl_files: 

415 # Skip agent sessions if requested 

416 if not include_agent_sessions and jsonl_file.name.startswith("agent-"): 

417 continue 

418 

419 try: 

420 # If we need response context, read all records first to pair user/assistant 

421 if include_response_context: 

422 all_records: list[dict] = [] 

423 with open(jsonl_file, encoding="utf-8") as f: 

424 for line in f: 

425 line = line.strip() 

426 if not line: 

427 continue 

428 try: 

429 record = json.loads(line) 

430 all_records.append(record) 

431 except json.JSONDecodeError: 

432 continue 

433 

434 # Process records, pairing user messages with their responses 

435 messages.extend(_extract_messages_with_context(all_records, jsonl_file, since)) 

436 else: 

437 # Original behavior: stream through file 

438 with open(jsonl_file, encoding="utf-8") as f: 

439 for line in f: 

440 line = line.strip() 

441 if not line: 

442 continue 

443 

444 try: 

445 record = json.loads(line) 

446 except json.JSONDecodeError: 

447 continue 

448 

449 msg = _parse_user_record(record, jsonl_file, since) 

450 if msg is not None: 

451 messages.append(msg) 

452 

453 except OSError: 

454 # Skip files that can't be read 

455 continue 

456 

457 # Sort by timestamp, most recent first 

458 messages.sort(key=lambda m: m.timestamp, reverse=True) 

459 

460 # Apply limit 

461 if limit is not None: 

462 messages = messages[:limit] 

463 

464 return messages 

465 

466 

467def extract_commands( 

468 project_folder: Path, 

469 limit: int | None = None, 

470 since: datetime | None = None, 

471 include_agent_sessions: bool = True, 

472 tools: list[str] | None = None, 

473) -> list[CommandRecord]: 

474 """Extract CLI commands from assistant tool_use messages. 

475 

476 Parses assistant messages for tool_use blocks and extracts command strings. 

477 

478 Args: 

479 project_folder: Path to Claude project folder 

480 limit: Maximum number of commands to return 

481 since: Only include commands after this datetime 

482 include_agent_sessions: Whether to include agent-*.jsonl files 

483 tools: Filter to specific tools (default: ["Bash"]) 

484 

485 Returns: 

486 Commands sorted by timestamp, most recent first. 

487 """ 

488 if tools is None: 

489 tools = ["Bash"] 

490 

491 commands: list[CommandRecord] = [] 

492 

493 # Find all JSONL files 

494 pattern = "*.jsonl" 

495 jsonl_files = list(project_folder.glob(pattern)) 

496 

497 for jsonl_file in jsonl_files: 

498 # Skip agent sessions if requested 

499 if not include_agent_sessions and jsonl_file.name.startswith("agent-"): 

500 continue 

501 

502 try: 

503 with open(jsonl_file, encoding="utf-8") as f: 

504 for line in f: 

505 line = line.strip() 

506 if not line: 

507 continue 

508 

509 try: 

510 record = json.loads(line) 

511 except json.JSONDecodeError: 

512 continue 

513 

514 cmds = _parse_command_record(record, jsonl_file, since, tools) 

515 commands.extend(cmds) 

516 

517 except OSError: 

518 # Skip files that can't be read 

519 continue 

520 

521 # Sort by timestamp, most recent first 

522 commands.sort(key=lambda c: c.timestamp, reverse=True) 

523 

524 # Apply limit 

525 if limit is not None: 

526 commands = commands[:limit] 

527 

528 return commands 

529 

530 

531def _parse_command_record( 

532 record: dict, 

533 jsonl_file: Path, 

534 since: datetime | None, 

535 tools: list[str], 

536) -> list[CommandRecord]: 

537 """Parse CLI commands from an assistant record. 

538 

539 Args: 

540 record: The JSON record from JSONL 

541 jsonl_file: Source file (for fallback timestamp) 

542 since: Filter for commands after this datetime 

543 tools: Tool names to extract (e.g., ["Bash"]) 

544 

545 Returns: 

546 List of CommandRecord for each matching tool_use block 

547 """ 

548 # Filter for assistant messages only 

549 if record.get("type") != "assistant": 

550 return [] 

551 

552 message_data = record.get("message", {}) 

553 content = message_data.get("content", []) 

554 

555 if not isinstance(content, list): 

556 return [] 

557 

558 # Parse timestamp 

559 timestamp_str = record.get("timestamp", "") 

560 try: 

561 timestamp_str = timestamp_str.replace("Z", "+00:00") 

562 timestamp = datetime.fromisoformat(timestamp_str) 

563 if timestamp.tzinfo is not None: 

564 timestamp = timestamp.replace(tzinfo=None) 

565 except (ValueError, AttributeError): 

566 timestamp = datetime.fromtimestamp(jsonl_file.stat().st_mtime) 

567 

568 # Apply since filter 

569 if since and timestamp < since: 

570 return [] 

571 

572 commands: list[CommandRecord] = [] 

573 

574 for block in content: 

575 if not isinstance(block, dict): 

576 continue 

577 if block.get("type") != "tool_use": 

578 continue 

579 

580 tool_name = block.get("name", "") 

581 if tool_name not in tools: 

582 continue 

583 

584 tool_input = block.get("input", {}) 

585 command_str = tool_input.get("command", "") 

586 if not command_str: 

587 continue 

588 

589 commands.append( 

590 CommandRecord( 

591 content=command_str, 

592 timestamp=timestamp, 

593 session_id=record.get("sessionId", ""), 

594 uuid=record.get("uuid", ""), 

595 tool=tool_name, 

596 cwd=record.get("cwd"), 

597 git_branch=record.get("gitBranch"), 

598 ) 

599 ) 

600 

601 return commands 

602 

603 

604def _parse_user_record( 

605 record: dict, 

606 jsonl_file: Path, 

607 since: datetime | None, 

608) -> UserMessage | None: 

609 """Parse a single user record into a UserMessage. 

610 

611 Args: 

612 record: The JSON record from JSONL 

613 jsonl_file: Source file (for fallback timestamp) 

614 since: Filter for messages after this datetime 

615 

616 Returns: 

617 UserMessage if valid user message, None otherwise 

618 """ 

619 # Filter for user messages only 

620 if record.get("type") != "user": 

621 return None 

622 

623 message_data = record.get("message", {}) 

624 content = message_data.get("content") 

625 

626 # Skip if no content 

627 if content is None: 

628 return None 

629 

630 # Check if this is a real user message or tool_result 

631 if isinstance(content, str): 

632 # String content = real user message 

633 message_content = content 

634 elif isinstance(content, list): 

635 # Array content - check first element 

636 if len(content) > 0 and content[0].get("type") == "tool_result": 

637 # This is a tool result, skip it 

638 return None 

639 # Extract text from array (could be text blocks) 

640 text_parts = [] 

641 for block in content: 

642 if isinstance(block, dict): 

643 if block.get("type") == "text": 

644 text_parts.append(block.get("text", "")) 

645 elif "content" in block: 

646 text_parts.append(str(block.get("content", ""))) 

647 message_content = "\n".join(text_parts) if text_parts else str(content) 

648 else: 

649 return None 

650 

651 # Parse timestamp 

652 timestamp_str = record.get("timestamp", "") 

653 try: 

654 # Handle ISO 8601 format with Z suffix 

655 timestamp_str = timestamp_str.replace("Z", "+00:00") 

656 timestamp = datetime.fromisoformat(timestamp_str) 

657 # Convert to naive datetime for consistent comparison 

658 if timestamp.tzinfo is not None: 

659 timestamp = timestamp.replace(tzinfo=None) 

660 except (ValueError, AttributeError): 

661 # Use file modification time as fallback 

662 timestamp = datetime.fromtimestamp(jsonl_file.stat().st_mtime) 

663 

664 # Apply since filter 

665 if since and timestamp < since: 

666 return None 

667 

668 # Create message object 

669 return UserMessage( 

670 content=message_content, 

671 timestamp=timestamp, 

672 session_id=record.get("sessionId", ""), 

673 uuid=record.get("uuid", ""), 

674 cwd=record.get("cwd"), 

675 git_branch=record.get("gitBranch"), 

676 is_sidechain=record.get("isSidechain", False), 

677 ) 

678 

679 

680def _extract_messages_with_context( 

681 records: list[dict], 

682 jsonl_file: Path, 

683 since: datetime | None, 

684) -> list[UserMessage]: 

685 """Extract user messages with response context from a list of records. 

686 

687 Pairs each user message with ALL following assistant responses until the 

688 next user message, aggregating tool usage and file changes. 

689 

690 Args: 

691 records: List of all records from a JSONL file 

692 jsonl_file: Source file (for fallback timestamp) 

693 since: Filter for messages after this datetime 

694 

695 Returns: 

696 List of UserMessages with response_metadata populated 

697 """ 

698 messages: list[UserMessage] = [] 

699 

700 current_msg: UserMessage | None = None 

701 current_responses: list[dict] = [] 

702 

703 for record in records: 

704 if record.get("type") == "user": 

705 if current_msg is not None: 

706 current_msg.response_metadata = _aggregate_response_metadata(current_responses) 

707 messages.append(current_msg) 

708 current_msg = _parse_user_record(record, jsonl_file, since) 

709 current_responses = [] 

710 elif record.get("type") == "assistant" and current_msg is not None: 

711 current_responses.append(record) 

712 

713 # Emit the final group 

714 if current_msg is not None: 

715 current_msg.response_metadata = _aggregate_response_metadata(current_responses) 

716 messages.append(current_msg) 

717 

718 return messages 

719 

720 

721def _extract_turn_pairs( 

722 records: list[dict], 

723 jsonl_file: Path, 

724 since: datetime | None, 

725) -> list[tuple[str, str]]: 

726 """Extract (user_text, assistant_text) pairs from a record stream. 

727 

728 Collects all assistant text blocks between consecutive user messages and 

729 pairs them with the preceding user message. 

730 

731 Args: 

732 records: All records from a JSONL session file 

733 jsonl_file: Source file (for _parse_user_record fallback timestamp) 

734 since: Filter for turns after this datetime 

735 

736 Returns: 

737 List of (user_text, assistant_text) pairs in chronological order 

738 """ 

739 pairs: list[tuple[str, str]] = [] 

740 current_user: str | None = None 

741 assistant_texts: list[str] = [] 

742 

743 for record in records: 

744 if record.get("type") == "user": 

745 if current_user is not None and assistant_texts: 

746 pairs.append((current_user, "\n\n".join(assistant_texts))) 

747 msg = _parse_user_record(record, jsonl_file, since) 

748 current_user = msg.content if msg is not None else None 

749 assistant_texts = [] 

750 elif record.get("type") == "assistant" and current_user is not None: 

751 content = record.get("message", {}).get("content", []) 

752 if isinstance(content, list): 

753 for block in content: 

754 if isinstance(block, dict) and block.get("type") == "text": 

755 text = block.get("text", "").strip() 

756 if text: 

757 assistant_texts.append(text) 

758 

759 if current_user is not None and assistant_texts: 

760 pairs.append((current_user, "\n\n".join(assistant_texts))) 

761 

762 return pairs 

763 

764 

765def _mtime(path: Path) -> float: 

766 """Return file modification time as a Unix float, or 0.0 if inaccessible.""" 

767 try: 

768 return path.stat().st_mtime 

769 except OSError: 

770 return 0.0 

771 

772 

773def extract_conversation_turns( 

774 project_folder: Path, 

775 since: datetime | None = None, 

776 context_window: int = 3, 

777 include_agent_sessions: bool = True, 

778) -> list[list[tuple[str, str]]]: 

779 """Extract conversation turns (user + assistant) from session logs. 

780 

781 Reads JSONL session files and extracts alternating user/assistant turn pairs, 

782 grouping them into sliding windows of context_window turn-pairs each. 

783 

784 Args: 

785 project_folder: Path to Claude project folder 

786 since: Only include turns from sessions containing messages after this datetime 

787 context_window: Number of (user, assistant) turn pairs per output window 

788 include_agent_sessions: Whether to include agent-*.jsonl files 

789 

790 Returns: 

791 List of conversation windows; each window is a list of (role, content) tuples 

792 alternating between "user" and "assistant". 

793 """ 

794 windows: list[list[tuple[str, str]]] = [] 

795 

796 jsonl_files = list(project_folder.glob("*.jsonl")) 

797 if since is not None: 

798 cutoff_ts = (since - timedelta(seconds=60)).timestamp() 

799 jsonl_files = [f for f in jsonl_files if _mtime(f) >= cutoff_ts] 

800 

801 for jsonl_file in jsonl_files: 

802 if not include_agent_sessions and jsonl_file.name.startswith("agent-"): 

803 continue 

804 

805 try: 

806 all_records: list[dict] = [] 

807 with open(jsonl_file, encoding="utf-8") as f: 

808 for line in f: 

809 line = line.strip() 

810 if not line: 

811 continue 

812 try: 

813 all_records.append(json.loads(line)) 

814 except json.JSONDecodeError: 

815 continue 

816 

817 turn_pairs = _extract_turn_pairs(all_records, jsonl_file, since) 

818 

819 # Emit sliding windows of context_window turn-pairs 

820 n = len(turn_pairs) 

821 if n == 0: 

822 continue 

823 for i in range(max(1, n - context_window + 1)): 

824 window_pairs = turn_pairs[i : i + context_window] 

825 window: list[tuple[str, str]] = [] 

826 for user_text, assistant_text in window_pairs: 

827 window.append(("user", user_text)) 

828 window.append(("assistant", assistant_text)) 

829 windows.append(window) 

830 except OSError: 

831 continue 

832 

833 return windows 

834 

835 

836def save_messages( 

837 messages: list[UserMessage], 

838 output_path: Path | None = None, 

839) -> Path: 

840 """Save messages to timestamped JSONL file. 

841 

842 Args: 

843 messages: List of UserMessage objects to save 

844 output_path: Output file path. If None, uses default location. 

845 

846 Returns: 

847 Path to the saved file. 

848 """ 

849 if output_path is None: 

850 # Default: ./.ll/user-messages-{timestamp}.jsonl 

851 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") 

852 output_dir = Path.cwd() / ".ll" 

853 output_dir.mkdir(parents=True, exist_ok=True) 

854 output_path = output_dir / f"user-messages-{timestamp}.jsonl" 

855 

856 output_path = Path(output_path) 

857 output_path.parent.mkdir(parents=True, exist_ok=True) 

858 

859 with open(output_path, "w", encoding="utf-8") as f: 

860 for msg in messages: 

861 f.write(json.dumps(msg.to_dict()) + "\n") 

862 

863 return output_path 

864 

865 

866def build_examples( 

867 messages: list[UserMessage], 

868 skill: str, 

869 context_window: int = 3, 

870) -> list[ExampleRecord]: 

871 """Build training example pairs from skill invocation sessions. 

872 

873 Groups messages by session, identifies skill trigger records (the user-side record 

874 whose content contains ``<command-name>/ll:SKILL_NAME</command-name>``), and pairs 

875 each trigger with the N preceding messages as input context. 

876 

877 Args: 

878 messages: UserMessage list (already filtered to skill-matching sessions) 

879 skill: The skill name to build examples for (e.g. "capture-issue") 

880 context_window: Number of preceding messages to include as context (default 3) 

881 

882 Returns: 

883 List of ExampleRecord objects, one per skill trigger record found. 

884 """ 

885 import re 

886 

887 skill_pattern = re.compile(rf"<command-name>/ll:{re.escape(skill)}</command-name>") 

888 

889 # Group by session_id, sorted ascending by timestamp 

890 sessions: dict[str, list[UserMessage]] = {} 

891 for msg in messages: 

892 sessions.setdefault(msg.session_id, []).append(msg) 

893 for session_msgs in sessions.values(): 

894 session_msgs.sort(key=lambda m: m.timestamp) 

895 

896 examples: list[ExampleRecord] = [] 

897 for session_id, session_msgs in sessions.items(): 

898 for idx, msg in enumerate(session_msgs): 

899 if not skill_pattern.search(msg.content): 

900 continue 

901 

902 # Collect N preceding messages as context 

903 preceding = session_msgs[max(0, idx - context_window) : idx] 

904 input_text = "\n\n".join(m.content for m in preceding) 

905 

906 # Serialize response_metadata as output 

907 if msg.response_metadata is not None: 

908 output_str = json.dumps(msg.response_metadata.to_dict()) 

909 else: 

910 output_str = "{}" 

911 

912 examples.append( 

913 ExampleRecord( 

914 skill=skill, 

915 input=input_text, 

916 output=output_str, 

917 session_id=session_id, 

918 timestamp=msg.timestamp, 

919 context_window=context_window, 

920 ) 

921 ) 

922 

923 return examples 

924 

925 

926def print_messages_to_stdout(messages: list[UserMessage]) -> None: 

927 """Print messages to stdout in JSONL format. 

928 

929 Args: 

930 messages: List of UserMessage objects to print 

931 """ 

932 import sys 

933 

934 for msg in messages: 

935 print(json.dumps(msg.to_dict()), file=sys.stdout)