1"""ll-messages: Extract user messages from Claude Code session logs."""

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7 from pathlib import Path

8

9 from little_loops.logger import Logger

10 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

11

12

13 def main_messages() -> int:

14 """Entry point for ll-messages command.

15

16 Extract user messages from Claude Code session logs.

17

18 Returns:

19 Exit code (0 = success)

20 """

21 with cli_event_context(DEFAULT_DB_PATH, "ll-messages", sys.argv[1:]):

22 import json

23 from datetime import datetime

24

25 from little_loops.user_messages import (

26 CommandRecord,

27 UserMessage,

28 build_examples,

29 extract_commands,

30 extract_user_messages,

31 get_project_folder,

32 )

33

34 parser = argparse.ArgumentParser(

35 description="Extract user messages from Claude Code logs",

36 formatter_class=argparse.RawDescriptionHelpFormatter,

37 epilog="""

38 Examples:

39 %(prog)s # Last 100 messages to file

40 %(prog)s -n 50 # Last 50 messages

41 %(prog)s --since 2026-01-01 # Messages since date

42 %(prog)s -o output.jsonl # Custom output path

43 %(prog)s --stdout # Print to terminal

44 %(prog)s --include-response-context # Include response metadata

45 %(prog)s --skip-cli # Exclude CLI commands from output

46 %(prog)s --commands-only # Extract only CLI commands

47 %(prog)s --skill capture-issue # Filter to sessions where skill was invoked

48 %(prog)s --skill capture-issue --examples-format # Output (input, output) training pairs

49 %(prog)s --skill refine-issue --examples-format --context-window 5 --stdout

50 %(prog)s --sft-format chatml --stdout

51 %(prog)s --sft-format sharegpt --context-window 3 --since 2026-05-01 --stdout

52 %(prog)s --sft-format alpaca --output data/sft/raw.jsonl

53 %(prog)s --sft-format chatml --reader db --stdout

54 %(prog)s --sft-format chatml --reader jsonl --stdout

55

56 Pipeline with ll-workflows (use the conventional path so ll-workflows finds it automatically):

57 %(prog)s --output .ll/workflow-analysis/step1-patterns.jsonl

58 ll-workflows analyze --patterns .ll/workflow-analysis/step1-patterns.yaml

59""",

60 )

61 parser.add_argument(

62 "-n",

63 "--limit",

64 type=int,

65 default=100,

66 help="Maximum number of messages to extract (default: 100)",

67 )

68 parser.add_argument(

69 "--since",

70 "-S",

71 type=str,

72 help="Only include messages after this date (YYYY-MM-DD or ISO format)",

73 )

74 parser.add_argument(

75 "-o",

76 "--output",

77 type=Path,

78 help="Output file path (default: .ll/user-messages-{timestamp}.jsonl)",

79 )

80 parser.add_argument(

81 "--cwd",

82 type=Path,

83 help="Working directory to use (default: current directory)",

84 )

85 parser.add_argument(

86 "--exclude-agents",

87 action="store_true",

88 help="Exclude agent session files (agent-*.jsonl)",

89 )

90 parser.add_argument(

91 "--stdout",

92 action="store_true",

93 help="Print messages to stdout instead of writing to file",

94 )

95 parser.add_argument(

96 "-v",

97 "--verbose",

98 action="store_true",

99 help="Print verbose progress information",

100 )

101 parser.add_argument(

102 "--include-response-context",

103 action="store_true",

104 help="Include metadata from assistant responses (tools used, files modified)",

105 )

106 parser.add_argument(

107 "--skip-cli",

108 action="store_true",

109 help="Exclude CLI commands from output (included by default)",

110 )

111 parser.add_argument(

112 "--commands-only",

113 action="store_true",

114 help="Extract only CLI commands, no user messages",

115 )

116 parser.add_argument(

117 "--tools",

118 type=str,

119 default="Bash",

120 help="Comma-separated list of tools to extract commands from (default: Bash)",

121 )

122 parser.add_argument(

123 "--skill",

124 type=str,

125 help="Filter to sessions where this skill was invoked (e.g. capture-issue)",

126 )

127 format_group = parser.add_mutually_exclusive_group()

128 format_group.add_argument(

129 "--examples-format",

130 action="store_true",

131 help="Output (input, output) training pairs for prompt optimization instead of raw messages",

132 )

133 format_group.add_argument(

134 "--sft-format",

135 choices=["chatml", "alpaca", "sharegpt"],

136 help="Output conversation turns in SFT training format as JSON-lines (chatml, alpaca, sharegpt)",

137 )

138 parser.add_argument(

139 "--context-window",

140 type=int,

141 default=3,

142 help="Number of preceding messages to include as context in --examples-format or --sft-format (default: 3)",

143 )

144 parser.add_argument(

145 "--reader",

146 choices=["auto", "db", "jsonl"],

147 default="auto",

148 help="Data source for --sft-format: auto (DB first, JSONL fallback), "

149 "db (DB only, error if unavailable), jsonl (JSONL only) (default: auto)",

150 )

151

152 args = parser.parse_args()

153

154 logger = Logger(verbose=args.verbose)

155

156 # Parse since date if provided

157 since = None

158 if args.since:

159 try:

160 # Try ISO format first

161 since = datetime.fromisoformat(args.since.replace("Z", "+00:00"))

162 except ValueError:

163 try:

164 # Try YYYY-MM-DD format

165 since = datetime.strptime(args.since, "%Y-%m-%d")

166 except ValueError:

167 logger.error(f"Invalid date format: {args.since}")

168 logger.error("Use YYYY-MM-DD or ISO format")

169 return 1

170

171 # Get project folder

172 cwd = args.cwd or Path.cwd()

173 project_folder = get_project_folder(cwd)

174

175 if project_folder is None:

176 logger.error(f"No session project folder found for: {cwd}")

177 return 1

178

179 logger.info(f"Project folder: {project_folder}")

180 logger.info(f"Limit: {args.limit}")

181 if since:

182 logger.info(f"Since: {since}")

183

184 # Parse tools list

185 tools_list = [t.strip() for t in args.tools.split(",")]

186

187 # Extract data based on flags

188 messages: list[UserMessage] = []

189 commands: list[CommandRecord] = []

190

191 if not args.commands_only:

192 messages = extract_user_messages(

193 project_folder=project_folder,

194 limit=None, # Apply limit after merging

195 since=since,

196 include_agent_sessions=not args.exclude_agents,

197 include_response_context=args.include_response_context or args.examples_format,

198 )

199

200 if not args.skip_cli or args.commands_only:

201 commands = extract_commands(

202 project_folder=project_folder,

203 limit=None, # Apply limit after merging

204 since=since,

205 include_agent_sessions=not args.exclude_agents,

206 tools=tools_list,

207 )

208

209 # Apply skill filter (session-level): keep only sessions where skill was invoked

210 if args.skill:

211 import re

212

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

214 matching_sessions = {

215 msg.session_id for msg in messages if skill_pattern.search(msg.content)

216 }

217 messages = [msg for msg in messages if msg.session_id in matching_sessions]

218 commands = [c for c in commands if c.session_id in matching_sessions]

219

220 if not messages and not commands:

221 logger.warning("No user messages or commands found")

222 return 0

223

224 # When --examples-format is set, reshape to ExampleRecord output and return early

225 if args.examples_format:

226 if not args.skill:

227 logger.error("--examples-format requires --skill to be specified")

228 return 1

229 examples = build_examples(messages, args.skill, args.context_window)

230 examples.sort(key=lambda x: x.timestamp, reverse=True)

231 if args.limit is not None:

232 examples = examples[: args.limit]

233 logger.info(f"Found {len(examples)} examples")

234 if args.stdout:

235 for item in examples:

236 print(json.dumps(item.to_dict()))

237 else:

238 output_path = _save_combined(examples, args.output)

239 logger.success(f"Saved {len(examples)} examples to: {output_path}")

240 return 0

241

242 # When --sft-format is set, emit conversation turns in SFT training format

243 if args.sft_format:

244 from little_loops.sft_formatter import to_alpaca, to_chatml, to_sharegpt

245 from little_loops.user_messages import extract_conversation_turns

246

247 formatter = {"chatml": to_chatml, "alpaca": to_alpaca, "sharegpt": to_sharegpt}[

248 args.sft_format

249 ]

250 windows = extract_conversation_turns(

251 project_folder=project_folder,

252 since=since,

253 context_window=args.context_window,

254 include_agent_sessions=not args.exclude_agents,

255 reader=args.reader,

256 )

257 if args.limit is not None:

258 windows = windows[: args.limit]

259 logger.info(f"Found {len(windows)} conversation windows")

260 if args.stdout:

261 for window in windows:

262 print(json.dumps(formatter(window)))

263 else:

264 output_path = _save_combined([_SFTItem(formatter(w)) for w in windows], args.output)

265 logger.success(f"Saved {len(windows)} windows to: {output_path}")

266 return 0

267

268 # Merge and sort by timestamp

269 combined: list[UserMessage | CommandRecord] = []

270 combined.extend(messages)

271 combined.extend(commands)

272 combined.sort(key=lambda x: x.timestamp, reverse=True)

273

274 # Apply limit

275 if args.limit is not None:

276 combined = combined[: args.limit]

277

278 msg_count = len([x for x in combined if isinstance(x, UserMessage)])

279 cmd_count = len([x for x in combined if isinstance(x, CommandRecord)])

280 logger.info(f"Found {msg_count} messages, {cmd_count} commands")

281

282 # Output

283 if args.stdout:

284 for record in combined:

285 print(json.dumps(record.to_dict()))

286 else:

287 output_path = _save_combined(combined, args.output)

288 logger.success(f"Saved {len(combined)} records to: {output_path}")

289

290 return 0

291

292

293 def _save_combined(

294 items: list,

295 output_path: Path | None = None,

296) -> Path:

297 """Save combined messages and commands to JSONL file."""

298 import json

299 from datetime import datetime

300

301 if output_path is None:

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

303 output_dir = Path.cwd() / ".claude"

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

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

306

307 output_path = Path(output_path)

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

309

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

311 for item in items:

312 f.write(json.dumps(item.to_dict()) + "\n")

313

314 return output_path

315

316

317 class _SFTItem:

318 """Thin wrapper to make an SFT dict compatible with _save_combined()."""

319

320 def __init__(self, data: dict) -> None:

321 self._data = data

322

323 def to_dict(self) -> dict:

324 return self._data