claude_agent 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeAgent
6
+ # Discovers and lists past Claude Code sessions from disk.
7
+ #
8
+ # Reads session metadata directly from ~/.claude/projects/ without spawning
9
+ # a CLI subprocess. Matches the TypeScript SDK's listSessions() behavior.
10
+ #
11
+ # @example List all sessions
12
+ # sessions = ClaudeAgent.list_sessions
13
+ # sessions.each { |s| puts "#{s.summary} (#{s.session_id})" }
14
+ #
15
+ # @example List sessions for a specific directory
16
+ # sessions = ClaudeAgent.list_sessions(dir: "/path/to/project", limit: 10)
17
+ #
18
+ module ListSessions
19
+ # Constants matching TypeScript SDK
20
+ BUFFER_SIZE = 65_536 # 64KB
21
+
22
+ # Patterns for filtering non-meaningful user prompts (matches TypeScript MM regex)
23
+ META_MESSAGE_PATTERN = /\A(?:<local-command-stdout>|<session-start-hook>|<tick>|<goal>|\[Request interrupted by user[^\]]*\]|\s*<ide_opened_file>[\s\S]*<\/ide_opened_file>\s*\z|\s*<ide_selection>[\s\S]*<\/ide_selection>\s*\z)/
24
+
25
+ # Pattern for extracting command names
26
+ COMMAND_NAME_PATTERN = /<command-name>(.*?)<\/command-name>/
27
+
28
+ class << self
29
+ # List past sessions with metadata.
30
+ #
31
+ # @param dir [String, nil] Directory to scope sessions to (with worktree support).
32
+ # When nil, returns sessions from all projects.
33
+ # @param limit [Integer, nil] Maximum number of sessions to return.
34
+ # @return [Array<SessionInfo>] Sessions sorted by last_modified descending.
35
+ def call(dir: nil, limit: nil)
36
+ if dir
37
+ list_for_directory(dir, limit)
38
+ else
39
+ list_all(limit)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # --- Session File Reading ---
46
+
47
+ # Read head and tail buffers from a session file.
48
+ #
49
+ # @param path [String] Path to .jsonl file
50
+ # @return [Hash, nil] { mtime:, size:, head:, tail: } or nil on error
51
+ def read_file_buffers(path)
52
+ stat = File.stat(path)
53
+ File.open(path, "r:UTF-8") do |f|
54
+ head = f.read(BUFFER_SIZE)
55
+ return nil if head.nil? || head.empty?
56
+
57
+ tail_offset = [ 0, stat.size - BUFFER_SIZE ].max
58
+ tail = if tail_offset > 0
59
+ f.seek(tail_offset)
60
+ f.read(BUFFER_SIZE) || head
61
+ else
62
+ head
63
+ end
64
+
65
+ { mtime: (stat.mtime.to_f * 1000).to_i, size: stat.size, head: head, tail: tail }
66
+ end
67
+ rescue SystemCallError
68
+ nil
69
+ end
70
+
71
+ # --- JSON String Scanning ---
72
+ # These match TypeScript's K9 (first occurrence) and V9 (last occurrence)
73
+ # functions. They scan for JSON key-value pairs without full parsing.
74
+
75
+ # Extract the first occurrence of a JSON string value for the given key.
76
+ #
77
+ # @param buffer [String] Text to scan
78
+ # @param key [String] JSON key name
79
+ # @return [String, nil] Extracted value or nil
80
+ def extract_first(buffer, key)
81
+ prefixes = [ "\"#{key}\":\"", "\"#{key}\": \"" ]
82
+ prefixes.each do |prefix|
83
+ idx = buffer.index(prefix)
84
+ next unless idx
85
+
86
+ start = idx + prefix.length
87
+ pos = start
88
+ while pos < buffer.length
89
+ if buffer[pos] == "\\"
90
+ pos += 2
91
+ next
92
+ end
93
+ if buffer[pos] == '"'
94
+ return unescape_json_string(buffer[start...pos])
95
+ end
96
+ pos += 1
97
+ end
98
+ end
99
+ nil
100
+ end
101
+
102
+ # Extract the last occurrence of a JSON string value for the given key.
103
+ #
104
+ # @param buffer [String] Text to scan
105
+ # @param key [String] JSON key name
106
+ # @return [String, nil] Extracted value or nil
107
+ def extract_last(buffer, key)
108
+ prefixes = [ "\"#{key}\":\"", "\"#{key}\": \"" ]
109
+ result = nil
110
+ prefixes.each do |prefix|
111
+ search_from = 0
112
+ loop do
113
+ idx = buffer.index(prefix, search_from)
114
+ break unless idx
115
+
116
+ start = idx + prefix.length
117
+ pos = start
118
+ while pos < buffer.length
119
+ if buffer[pos] == "\\"
120
+ pos += 2
121
+ next
122
+ end
123
+ if buffer[pos] == '"'
124
+ result = unescape_json_string(buffer[start...pos])
125
+ break
126
+ end
127
+ pos += 1
128
+ end
129
+ search_from = pos + 1
130
+ end
131
+ end
132
+ result
133
+ end
134
+
135
+ # Unescape a JSON string value (handles \\, \", \n, etc.).
136
+ #
137
+ # @param str [String]
138
+ # @return [String]
139
+ def unescape_json_string(str)
140
+ return str unless str.include?("\\")
141
+
142
+ JSON.parse("\"#{str}\"")
143
+ rescue JSON::ParserError
144
+ str
145
+ end
146
+
147
+ # --- First Prompt Extraction ---
148
+
149
+ # Extract the first meaningful user prompt from the head buffer.
150
+ # Matches TypeScript's mW function.
151
+ #
152
+ # @param head [String] First 64KB of the session file
153
+ # @return [String, nil] First meaningful prompt or nil
154
+ def extract_first_prompt(head)
155
+ command_name = nil
156
+
157
+ head.each_line do |line|
158
+ line = line.chomp
159
+ # Skip non-user messages (quick string check before parsing)
160
+ next unless line.include?('"type":"user"') || line.include?('"type": "user"')
161
+ # Skip tool results
162
+ next if line.include?('"tool_result"')
163
+ # Skip meta messages
164
+ next if line.include?('"isMeta":true') || line.include?('"isMeta": true')
165
+ # Skip compact summaries
166
+ next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true')
167
+
168
+ parsed = JSON.parse(line)
169
+ next unless parsed["type"] == "user"
170
+
171
+ message = parsed["message"]
172
+ next unless message
173
+
174
+ content = message["content"]
175
+ texts = extract_text_parts(content)
176
+
177
+ texts.each do |text|
178
+ cleaned = text.gsub("\n", " ").strip
179
+ next if cleaned.empty?
180
+
181
+ # Check for command name pattern
182
+ match = COMMAND_NAME_PATTERN.match(cleaned)
183
+ if match
184
+ command_name ||= match[1]
185
+ next
186
+ end
187
+
188
+ # Skip meta message patterns
189
+ next if META_MESSAGE_PATTERN.match?(cleaned)
190
+
191
+ # Truncate long prompts at 200 chars
192
+ cleaned = "#{cleaned[0, 200].strip}\u2026" if cleaned.length > 200
193
+ return cleaned
194
+ end
195
+ rescue JSON::ParserError
196
+ next
197
+ end
198
+
199
+ command_name
200
+ end
201
+
202
+ # Extract text parts from message content (string or array of blocks).
203
+ #
204
+ # @param content [String, Array, nil]
205
+ # @return [Array<String>]
206
+ def extract_text_parts(content)
207
+ case content
208
+ when String
209
+ [ content ]
210
+ when Array
211
+ content.filter_map do |block|
212
+ block["text"] if block.is_a?(Hash) && block["type"] == "text" && block["text"].is_a?(String)
213
+ end
214
+ else
215
+ []
216
+ end
217
+ end
218
+
219
+ # --- Session Metadata Parsing ---
220
+
221
+ # Parse a single session file into a SessionInfo.
222
+ #
223
+ # @param path [String] Path to .jsonl file
224
+ # @param session_id [String] UUID extracted from filename
225
+ # @return [SessionInfo, nil] Parsed session info or nil if filtered
226
+ def parse_session_file(path, session_id)
227
+ buffers = read_file_buffers(path)
228
+ return nil unless buffers
229
+
230
+ head = buffers[:head]
231
+ tail = buffers[:tail]
232
+
233
+ # Check first line only for sidechain
234
+ first_newline = head.index("\n")
235
+ first_line = first_newline ? head[0...first_newline] : head
236
+ return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')
237
+
238
+ # Filter team sessions
239
+ return nil if extract_first(head, "teamName")
240
+
241
+ # Extract metadata
242
+ custom_title = extract_last(tail, "customTitle")
243
+ first_prompt = extract_first_prompt(head)
244
+ git_branch = extract_last(tail, "gitBranch") || extract_first(head, "gitBranch")
245
+ cwd = extract_first(head, "cwd")
246
+
247
+ # Build summary: customTitle > last summary > firstPrompt > "(session)"
248
+ summary_from_file = extract_last(tail, "summary")
249
+ summary = custom_title || summary_from_file || first_prompt || "(session)"
250
+
251
+ SessionInfo.new(
252
+ session_id: session_id,
253
+ summary: summary,
254
+ last_modified: buffers[:mtime],
255
+ file_size: buffers[:size],
256
+ custom_title: custom_title,
257
+ first_prompt: first_prompt,
258
+ git_branch: git_branch,
259
+ cwd: cwd
260
+ )
261
+ end
262
+
263
+ # --- Directory Scanning ---
264
+
265
+ # Scan a project directory for session files.
266
+ #
267
+ # @param dir_path [String] Path to a project directory under ~/.claude/projects/
268
+ # @return [Array<SessionInfo>] Parsed sessions (unfiltered duplicates possible)
269
+ def scan_project_dir(dir_path)
270
+ return [] unless File.directory?(dir_path)
271
+
272
+ entries = Dir.entries(dir_path)
273
+ sessions = []
274
+
275
+ entries.each do |entry|
276
+ next unless entry.end_with?(".jsonl")
277
+
278
+ stem = entry[0...-6] # Remove .jsonl extension
279
+ next unless SessionPaths::UUID_PATTERN.match?(stem)
280
+
281
+ full_path = File.join(dir_path, entry)
282
+ session = parse_session_file(full_path, stem)
283
+ sessions << session if session
284
+ end
285
+
286
+ sessions
287
+ end
288
+
289
+ # --- Listing Modes ---
290
+
291
+ # List sessions for a specific directory, including worktree siblings.
292
+ # Matches TypeScript's bM function.
293
+ #
294
+ # @param dir [String]
295
+ # @param limit [Integer, nil]
296
+ # @return [Array<SessionInfo>]
297
+ def list_for_directory(dir, limit)
298
+ resolved = SessionPaths.realpath(dir)
299
+ worktrees = SessionPaths.git_worktrees(resolved)
300
+
301
+ # Simple case: not in a worktree (or single worktree)
302
+ if worktrees.length <= 1
303
+ project_dir = SessionPaths.find_project_dir(resolved)
304
+ return [] unless project_dir
305
+ return sort_and_limit(scan_project_dir(project_dir), limit)
306
+ end
307
+
308
+ # Complex case: multiple worktrees - scan all related project directories
309
+ base = SessionPaths.projects_dir
310
+
311
+ # Build prefix list from worktree paths (longest first for matching)
312
+ prefixes = worktrees.map { |wt| { path: wt, prefix: SessionPaths.encode_project_dir(wt) } }
313
+ prefixes.sort_by! { |p| -p[:prefix].length }
314
+
315
+ all_sessions = []
316
+ seen_dirs = Set.new
317
+
318
+ # First: sessions from the exact directory
319
+ project_dir = SessionPaths.find_project_dir(resolved)
320
+ if project_dir
321
+ seen_dirs.add(File.basename(project_dir))
322
+ all_sessions.concat(scan_project_dir(project_dir))
323
+ end
324
+
325
+ # Then: scan all project directories matching worktree prefixes
326
+ if File.directory?(base)
327
+ Dir.entries(base).each do |entry|
328
+ next if entry.start_with?(".")
329
+ next if seen_dirs.include?(entry)
330
+ entry_path = File.join(base, entry)
331
+ next unless File.directory?(entry_path)
332
+
333
+ prefixes.each do |p|
334
+ prefix = p[:prefix]
335
+ if entry == prefix || (prefix.length >= SessionPaths::MAX_SLUG_LENGTH && entry.start_with?("#{prefix[0, SessionPaths::MAX_SLUG_LENGTH]}-"))
336
+ seen_dirs.add(entry)
337
+ all_sessions.concat(scan_project_dir(entry_path))
338
+ break
339
+ end
340
+ end
341
+ end
342
+ end
343
+
344
+ sort_and_limit(deduplicate(all_sessions), limit)
345
+ end
346
+
347
+ # List sessions from all project directories.
348
+ # Matches TypeScript's EM function.
349
+ #
350
+ # @param limit [Integer, nil]
351
+ # @return [Array<SessionInfo>]
352
+ def list_all(limit)
353
+ base = SessionPaths.projects_dir
354
+ return [] unless File.directory?(base)
355
+
356
+ all_sessions = []
357
+
358
+ Dir.entries(base).each do |entry|
359
+ next if entry.start_with?(".")
360
+ dir_path = File.join(base, entry)
361
+ next unless File.directory?(dir_path)
362
+ all_sessions.concat(scan_project_dir(dir_path))
363
+ end
364
+
365
+ sort_and_limit(deduplicate(all_sessions), limit)
366
+ end
367
+
368
+ # --- Helpers ---
369
+
370
+ # Deduplicate sessions by session_id, keeping the most recent.
371
+ # Matches TypeScript's cW function.
372
+ #
373
+ # @param sessions [Array<SessionInfo>]
374
+ # @return [Array<SessionInfo>]
375
+ def deduplicate(sessions)
376
+ by_id = {}
377
+ sessions.each do |session|
378
+ existing = by_id[session.session_id]
379
+ if !existing || session.last_modified > existing.last_modified
380
+ by_id[session.session_id] = session
381
+ end
382
+ end
383
+ by_id.values
384
+ end
385
+
386
+ # Sort by last_modified descending and apply optional limit.
387
+ # Matches TypeScript's E4 function.
388
+ #
389
+ # @param sessions [Array<SessionInfo>]
390
+ # @param limit [Integer, nil]
391
+ # @return [Array<SessionInfo>]
392
+ def sort_and_limit(sessions, limit)
393
+ sorted = sessions.sort_by { |s| -s.last_modified }
394
+ limit ? sorted.first(limit) : sorted
395
+ end
396
+ end
397
+ end
398
+ end
@@ -15,7 +15,7 @@ module ClaudeAgent
15
15
  # name: "add",
16
16
  # description: "Add two numbers",
17
17
  # schema: {a: Float, b: Float}
18
- # ) { |args| args["a"] + args["b"] }
18
+ # ) { |args| args[:a] + args[:b] }
19
19
  #
20
20
  # server = ClaudeAgent::MCP::Server.new(
21
21
  # name: "calculator",
@@ -113,7 +113,7 @@ module ClaudeAgent
113
113
 
114
114
  def handle_tools_call(params)
115
115
  tool_name = params["name"]
116
- arguments = params["arguments"] || {}
116
+ arguments = (params["arguments"] || {}).deep_symbolize_keys
117
117
 
118
118
  tool = @tools[tool_name]
119
119
  unless tool
@@ -156,7 +156,7 @@ module ClaudeAgent
156
156
  #
157
157
  # @example
158
158
  # tool = ClaudeAgent::MCP.tool("greet", "Greet someone", {name: String}) do |args|
159
- # "Hello, #{args['name']}!"
159
+ # "Hello, #{args[:name]}!"
160
160
  # end
161
161
  #
162
162
  def self.tool(name, description, schema = {}, annotations: nil, &handler)
@@ -10,7 +10,7 @@ module ClaudeAgent
10
10
  # description: "Greet a person",
11
11
  # schema: {name: String}
12
12
  # ) do |args|
13
- # "Hello, #{args['name']}!"
13
+ # "Hello, #{args[:name]}!"
14
14
  # end
15
15
  #
16
16
  # @example Tool with complex schema
@@ -27,9 +27,9 @@ module ClaudeAgent
27
27
  # required: ["operation", "a", "b"]
28
28
  # }
29
29
  # ) do |args|
30
- # case args["operation"]
31
- # when "add" then args["a"] + args["b"]
32
- # when "subtract" then args["a"] - args["b"]
30
+ # case args[:operation]
31
+ # when "add" then args[:a] + args[:b]
32
+ # when "subtract" then args[:a] - args[:b]
33
33
  # end
34
34
  # end
35
35
  #