claude-agent-sdk 0.16.9 → 0.17.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +1 -1
- data/docs/sessions.md +79 -0
- data/lib/claude_agent_sdk/command_builder.rb +4 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +14 -2
- data/lib/claude_agent_sdk/message_parser.rb +1 -0
- data/lib/claude_agent_sdk/query.rb +62 -8
- data/lib/claude_agent_sdk/session_mutations.rb +248 -63
- data/lib/claude_agent_sdk/session_resume.rb +444 -0
- data/lib/claude_agent_sdk/session_store.rb +353 -0
- data/lib/claude_agent_sdk/session_summary.rb +188 -0
- data/lib/claude_agent_sdk/sessions.rb +460 -7
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +84 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +295 -0
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +218 -0
- data/lib/claude_agent_sdk/types.rb +43 -11
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +292 -62
- metadata +9 -4
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'sessions'
|
|
5
|
+
require_relative 'session_summary'
|
|
6
|
+
|
|
7
|
+
module ClaudeAgentSDK
|
|
8
|
+
# Controls when transcript-mirror entries are flushed to a SessionStore.
|
|
9
|
+
#
|
|
10
|
+
# - "batched" (default): buffer entries and flush once per turn (on the
|
|
11
|
+
# `result` message) or when the pending buffer exceeds 500 entries / 1 MiB.
|
|
12
|
+
# - "eager": trigger a background flush after every transcript_mirror frame
|
|
13
|
+
# so SessionStore#append sees entries in near real time.
|
|
14
|
+
SESSION_STORE_FLUSH_MODES = %w[batched eager].freeze
|
|
15
|
+
|
|
16
|
+
# Adapter for mirroring session transcripts to external storage.
|
|
17
|
+
#
|
|
18
|
+
# The subprocess still writes to local disk; the adapter receives a secondary
|
|
19
|
+
# copy via SessionStore#append, and `resume` can materialize from the store
|
|
20
|
+
# via SessionStore#load when the local file is absent.
|
|
21
|
+
#
|
|
22
|
+
# Only #append and #load are required. The remaining methods are optional:
|
|
23
|
+
# implementers may omit them, and the SDK probes for their presence via
|
|
24
|
+
# SessionStore.implements? before invoking (it never uses `is_a?` for this —
|
|
25
|
+
# a duck-typed adapter need not subclass SessionStore). The default
|
|
26
|
+
# implementations here raise NotImplementedError so subclasses inherit them as
|
|
27
|
+
# "absent" markers.
|
|
28
|
+
#
|
|
29
|
+
# All keys/entries cross the adapter boundary as Hashes with STRING keys:
|
|
30
|
+
# - SessionKey: { 'project_key' => String, 'session_id' => String,
|
|
31
|
+
# 'subpath' => String (optional; omit for the main transcript) }
|
|
32
|
+
# - entries: raw JSONL transcript objects (opaque pass-through blobs)
|
|
33
|
+
# - list_sessions result: [{ 'session_id' => String, 'mtime' => Integer }]
|
|
34
|
+
# - summary entries: { 'session_id', 'mtime', 'data' } (see SessionSummary)
|
|
35
|
+
class SessionStore
|
|
36
|
+
# True if +store+ overrides +method+ rather than inheriting the base
|
|
37
|
+
# implementation that raises NotImplementedError. Works for both subclasses
|
|
38
|
+
# and duck-typed adapters (whose method owner is their own class).
|
|
39
|
+
def self.implements?(store, method)
|
|
40
|
+
return false unless store.respond_to?(method)
|
|
41
|
+
|
|
42
|
+
store.method(method).owner != SessionStore
|
|
43
|
+
rescue NameError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Mirror a batch of transcript entries. Called AFTER the subprocess's local
|
|
48
|
+
# write succeeds. Required.
|
|
49
|
+
#
|
|
50
|
+
# Appends for a given key are normally serialized by the batcher, but if an
|
|
51
|
+
# #append exceeds the send timeout the batcher abandons that (still-running)
|
|
52
|
+
# call and proceeds, so a later #append for the SAME key can overlap it.
|
|
53
|
+
# Implementations must therefore be thread-safe per key (a per-call
|
|
54
|
+
# connection — Postgres/Redis/etc. — satisfies this) and should dedupe by
|
|
55
|
+
# entry["uuid"] when present, since a retried/overlapping batch may repeat
|
|
56
|
+
# a prior write.
|
|
57
|
+
def append(_key, _entries)
|
|
58
|
+
raise NotImplementedError, "#{self.class} must implement #append"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Load a full session for resume, or nil for a key that was never written.
|
|
62
|
+
# Required.
|
|
63
|
+
def load(_key)
|
|
64
|
+
raise NotImplementedError, "#{self.class} must implement #load"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# List sessions for a project_key as [{ 'session_id', 'mtime' }]. Optional —
|
|
68
|
+
# if unimplemented, list_sessions_from_store raises.
|
|
69
|
+
def list_sessions(_project_key)
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Return incrementally-maintained summaries for all sessions in one call.
|
|
74
|
+
# Optional — if unimplemented, list_sessions_from_store falls back to
|
|
75
|
+
# list_sessions + per-session load.
|
|
76
|
+
def list_session_summaries(_project_key)
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Delete a session. Deleting a main-transcript key (no subpath) must cascade
|
|
81
|
+
# to all subkeys. Optional — if unimplemented, deletion is a no-op.
|
|
82
|
+
def delete(_key)
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# List all subpath keys under a session (e.g. subagent transcripts).
|
|
87
|
+
# Optional — if unimplemented, resume only materializes the main transcript.
|
|
88
|
+
def list_subkeys(_key)
|
|
89
|
+
raise NotImplementedError
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# In-memory SessionStore for testing and development. Data is lost when the
|
|
94
|
+
# process exits — not suitable for production.
|
|
95
|
+
class InMemorySessionStore < SessionStore
|
|
96
|
+
def initialize
|
|
97
|
+
super
|
|
98
|
+
@store = {}
|
|
99
|
+
@mtimes = {}
|
|
100
|
+
@summaries = {}
|
|
101
|
+
@last_mtime = 0
|
|
102
|
+
# The SessionStore#append contract requires per-key thread-safety, and two
|
|
103
|
+
# concurrent sessions can share one store (each with its own batcher and
|
|
104
|
+
# semaphore, so nothing serializes appends across them). Guard all access.
|
|
105
|
+
@mutex = Mutex.new
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def append(key, entries)
|
|
109
|
+
# Same guard as the reference adapters: append(key, []) must not create a
|
|
110
|
+
# phantom key (load would return [] instead of nil and the session would
|
|
111
|
+
# appear in listings).
|
|
112
|
+
return if entries.nil? || entries.empty?
|
|
113
|
+
|
|
114
|
+
@mutex.synchronize do
|
|
115
|
+
k = key_to_string(key)
|
|
116
|
+
(@store[k] ||= []).concat(entries)
|
|
117
|
+
now_ms = next_mtime
|
|
118
|
+
# Maintain the per-session summary sidecar incrementally so
|
|
119
|
+
# #list_session_summaries never re-reads. Subagent subpaths don't
|
|
120
|
+
# contribute to the main session's summary.
|
|
121
|
+
if main_transcript_key?(key)
|
|
122
|
+
sk = [key['project_key'], key['session_id']]
|
|
123
|
+
folded = SessionSummary.fold_session_summary(@summaries[sk], key, entries)
|
|
124
|
+
# Stamp with this adapter's storage write time — the SAME clock
|
|
125
|
+
# #list_sessions exposes, so the fast-path staleness check works.
|
|
126
|
+
folded['mtime'] = now_ms
|
|
127
|
+
@summaries[sk] = folded
|
|
128
|
+
end
|
|
129
|
+
@mtimes[k] = now_ms
|
|
130
|
+
end
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def load(key)
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
entries = @store[key_to_string(key)]
|
|
137
|
+
entries&.dup
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def list_sessions(project_key)
|
|
142
|
+
prefix = "#{project_key}/"
|
|
143
|
+
results = []
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@store.each_key do |k|
|
|
146
|
+
next unless k.start_with?(prefix)
|
|
147
|
+
|
|
148
|
+
rest = k[prefix.length..]
|
|
149
|
+
# Only main transcripts (no subpath, so no second '/').
|
|
150
|
+
results << { 'session_id' => rest, 'mtime' => @mtimes[k] || 0 } unless rest.include?('/')
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
results
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def list_session_summaries(project_key)
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
# Return COPIES, not the internal summary objects: #load dups, so this
|
|
159
|
+
# must too, or a caller mutating a returned summary's data would corrupt
|
|
160
|
+
# the sidecar that the next fold builds on.
|
|
161
|
+
@summaries.filter_map do |(pk, _sid), summary|
|
|
162
|
+
next unless pk == project_key
|
|
163
|
+
|
|
164
|
+
{ 'session_id' => summary['session_id'], 'mtime' => summary['mtime'], 'data' => summary['data'].dup }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def delete(key)
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
k = key_to_string(key)
|
|
172
|
+
@store.delete(k)
|
|
173
|
+
@mtimes.delete(k)
|
|
174
|
+
# Deleting the main transcript cascades to its subkeys so they aren't
|
|
175
|
+
# orphaned. A targeted delete with an explicit subpath removes only that
|
|
176
|
+
# one entry. An empty-string subpath is treated as "no subpath" (main),
|
|
177
|
+
# consistent with key_to_string / append, so it cascades like nil.
|
|
178
|
+
next unless main_transcript_key?(key)
|
|
179
|
+
|
|
180
|
+
@summaries.delete([key['project_key'], key['session_id']])
|
|
181
|
+
prefix = "#{key['project_key']}/#{key['session_id']}/"
|
|
182
|
+
@store.keys.select { |sk| sk.start_with?(prefix) }.each do |sk|
|
|
183
|
+
@store.delete(sk)
|
|
184
|
+
@mtimes.delete(sk)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def list_subkeys(key)
|
|
191
|
+
prefix = "#{key['project_key']}/#{key['session_id']}/"
|
|
192
|
+
@mutex.synchronize do
|
|
193
|
+
@store.keys.select { |k| k.start_with?(prefix) }.map { |k| k[prefix.length..] }
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# -- Test helpers --
|
|
198
|
+
|
|
199
|
+
# All entries for a key (empty array if absent).
|
|
200
|
+
def get_entries(key)
|
|
201
|
+
@mutex.synchronize { (@store[key_to_string(key)] || []).dup }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Number of stored sessions (main transcripts only).
|
|
205
|
+
def size
|
|
206
|
+
@mutex.synchronize do
|
|
207
|
+
@store.keys.count do |k|
|
|
208
|
+
first_slash = k.index('/')
|
|
209
|
+
first_slash && !k[(first_slash + 1)..].include?('/')
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def clear
|
|
215
|
+
@mutex.synchronize do
|
|
216
|
+
@store.clear
|
|
217
|
+
@mtimes.clear
|
|
218
|
+
@summaries.clear
|
|
219
|
+
@last_mtime = 0
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# True for a main-transcript key: no subpath, or an empty-string subpath
|
|
226
|
+
# (which key_to_string already folds into the main key).
|
|
227
|
+
def main_transcript_key?(key)
|
|
228
|
+
sub = key['subpath']
|
|
229
|
+
sub.nil? || sub.empty?
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def key_to_string(key)
|
|
233
|
+
parts = [key['project_key'], key['session_id']]
|
|
234
|
+
subpath = key['subpath']
|
|
235
|
+
parts << subpath if subpath && !subpath.empty?
|
|
236
|
+
parts.join('/')
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Storage write time in Unix epoch ms, strictly monotonically increasing so
|
|
240
|
+
# back-to-back appends always produce distinct mtimes (real backends get
|
|
241
|
+
# this from commit ordering).
|
|
242
|
+
def next_mtime
|
|
243
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
244
|
+
now_ms = @last_mtime + 1 if now_ms <= @last_mtime
|
|
245
|
+
@last_mtime = now_ms
|
|
246
|
+
now_ms
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Internal SessionStore support functions (path mapping, option validation).
|
|
251
|
+
module SessionStores
|
|
252
|
+
module_function
|
|
253
|
+
|
|
254
|
+
# Derive a SessionKey from an absolute transcript file path.
|
|
255
|
+
#
|
|
256
|
+
# Main: <projects_dir>/<project_key>/<session_id>.jsonl
|
|
257
|
+
# Subagent: <projects_dir>/<project_key>/<session_id>/subagents/agent-<id>.jsonl
|
|
258
|
+
#
|
|
259
|
+
# Returns nil if +file_path+ is not under +projects_dir+ or has an
|
|
260
|
+
# unrecognized shape.
|
|
261
|
+
def file_path_to_session_key(file_path, projects_dir)
|
|
262
|
+
# A frame with a missing/non-String filePath would make Pathname.new raise
|
|
263
|
+
# TypeError (not the ArgumentError handled below), which propagates out of
|
|
264
|
+
# do_flush and drops the entire coalesced drain batch. Treat it as
|
|
265
|
+
# "not under projects_dir" so only the bad frame is skipped.
|
|
266
|
+
return nil unless file_path.is_a?(String) && !file_path.empty?
|
|
267
|
+
|
|
268
|
+
begin
|
|
269
|
+
rel = Pathname.new(file_path).relative_path_from(Pathname.new(projects_dir)).to_s
|
|
270
|
+
rescue ArgumentError
|
|
271
|
+
# Different drives on Windows — treat as "not under projects_dir".
|
|
272
|
+
return nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
parts = rel.split('/')
|
|
276
|
+
# Reject paths that escape projects_dir: a leading ".." *segment* (exact
|
|
277
|
+
# match, so a legitimate dir like "..foo" still maps), the "." self-ref,
|
|
278
|
+
# or an absolute path. Comparing parts[0] rather than rel.start_with?("..")
|
|
279
|
+
# avoids the "..foo" false positive that would silently drop valid frames.
|
|
280
|
+
return nil if parts.empty? || parts[0] == '..' || rel == '.' || Pathname.new(rel).absolute?
|
|
281
|
+
return nil if parts.length < 2
|
|
282
|
+
|
|
283
|
+
project_key = parts[0]
|
|
284
|
+
second = parts[1]
|
|
285
|
+
|
|
286
|
+
# Main transcript: <project_key>/<session_id>.jsonl
|
|
287
|
+
return { 'project_key' => project_key, 'session_id' => second.delete_suffix('.jsonl') } if parts.length == 2 && second.end_with?('.jsonl')
|
|
288
|
+
|
|
289
|
+
# Subagent transcript: <project_key>/<session_id>/subagents/.../agent-<id>.jsonl
|
|
290
|
+
if parts.length >= 4
|
|
291
|
+
subpath_parts = parts[2..]
|
|
292
|
+
subpath_parts[-1] = subpath_parts[-1].delete_suffix('.jsonl')
|
|
293
|
+
# Subpaths are always /-joined so keys are portable across platforms.
|
|
294
|
+
return { 'project_key' => project_key, 'session_id' => second, 'subpath' => subpath_parts.join('/') }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Raise ArgumentError for invalid session_store option combinations. Called
|
|
301
|
+
# before subprocess spawn so misconfiguration fails fast.
|
|
302
|
+
def validate_session_store_options(options)
|
|
303
|
+
store = options.session_store
|
|
304
|
+
return if store.nil?
|
|
305
|
+
|
|
306
|
+
# #append/#load are required, but a subclass inheriting the base stubs
|
|
307
|
+
# would only fail at first use — with NotImplementedError, a ScriptError
|
|
308
|
+
# that rescue StandardError layers don't catch. Fail fast here instead.
|
|
309
|
+
%i[append load].each do |method|
|
|
310
|
+
raise ArgumentError, "session_store must implement ##{method}" unless SessionStore.implements?(store, method)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
flush = options.session_store_flush.to_s
|
|
314
|
+
unless SESSION_STORE_FLUSH_MODES.include?(flush)
|
|
315
|
+
raise ArgumentError,
|
|
316
|
+
"invalid session_store_flush: #{options.session_store_flush.inspect} " \
|
|
317
|
+
"(expected one of #{SESSION_STORE_FLUSH_MODES.join(', ')})"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# When resume is explicitly set, list_sessions is provably never called
|
|
321
|
+
# (resume wins over continue), so a minimal store is fine.
|
|
322
|
+
if options.continue_conversation && options.resume.nil? && !SessionStore.implements?(store, :list_sessions)
|
|
323
|
+
raise ArgumentError,
|
|
324
|
+
'continue_conversation with session_store requires the store to implement #list_sessions'
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
return unless options.enable_file_checkpointing
|
|
328
|
+
|
|
329
|
+
raise ArgumentError,
|
|
330
|
+
'session_store cannot be combined with enable_file_checkpointing ' \
|
|
331
|
+
'(checkpoints are local-disk only and would diverge from the mirrored transcript)'
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Path to the rel-from base where session transcripts live, honoring a
|
|
335
|
+
# CLAUDE_CONFIG_DIR override passed to the subprocess via options.env.
|
|
336
|
+
# Mirrors Sessions#config_dir but consults an explicit env override first.
|
|
337
|
+
#
|
|
338
|
+
# Presence is detected by KEY, not value: the transport treats an explicit
|
|
339
|
+
# nil value as "unset the var for the child", so the CLI then writes under
|
|
340
|
+
# the default ~/.claude — not under the parent's CLAUDE_CONFIG_DIR. Empty
|
|
341
|
+
# strings get the same treatment (the Node CLI treats "" as unset).
|
|
342
|
+
def projects_dir(env_override = nil)
|
|
343
|
+
if env_override.respond_to?(:key?) &&
|
|
344
|
+
(env_override.key?('CLAUDE_CONFIG_DIR') || env_override.key?(:CLAUDE_CONFIG_DIR))
|
|
345
|
+
override = env_override['CLAUDE_CONFIG_DIR'] || env_override[:CLAUDE_CONFIG_DIR]
|
|
346
|
+
override = nil if override.respond_to?(:empty?) && override.empty?
|
|
347
|
+
return File.join(override || File.expand_path('~/.claude'), 'projects')
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
File.join(Sessions.config_dir, 'projects')
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative 'sessions'
|
|
5
|
+
|
|
6
|
+
module ClaudeAgentSDK
|
|
7
|
+
# Incremental session-summary derivation for SessionStore adapters.
|
|
8
|
+
#
|
|
9
|
+
# fold_session_summary lets a store maintain a per-session summary sidecar
|
|
10
|
+
# incrementally inside #append so list_sessions_from_store can fetch all
|
|
11
|
+
# metadata in a single #list_session_summaries call instead of N per-session
|
|
12
|
+
# #load calls. Every derived field is append-incremental (set-once or
|
|
13
|
+
# last-wins) so adapters never need to re-read previously appended entries.
|
|
14
|
+
#
|
|
15
|
+
# All structures use STRING keys throughout: entries are raw JSONL objects
|
|
16
|
+
# (string keys from JSON), and the summary's opaque +data+ dict is persisted
|
|
17
|
+
# verbatim by adapters — string keys survive a JSON round-trip (Postgres
|
|
18
|
+
# JSONB, Redis) losslessly, whereas symbol keys would not.
|
|
19
|
+
module SessionSummary
|
|
20
|
+
# JSONL entry keys -> summary data keys for last-wins string fields. Each
|
|
21
|
+
# appended entry overwrites the previous value when present.
|
|
22
|
+
LAST_WINS_FIELDS = {
|
|
23
|
+
'customTitle' => 'custom_title',
|
|
24
|
+
'aiTitle' => 'ai_title',
|
|
25
|
+
'lastPrompt' => 'last_prompt',
|
|
26
|
+
'summary' => 'summary_hint',
|
|
27
|
+
'gitBranch' => 'git_branch'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Fold a batch of appended entries into the running summary for +key+.
|
|
33
|
+
#
|
|
34
|
+
# Stores call this from inside #append to keep a summary sidecar up to date
|
|
35
|
+
# without re-reading the transcript. +prev+ is the previous summary for the
|
|
36
|
+
# same key (or nil for the first append).
|
|
37
|
+
#
|
|
38
|
+
# Do NOT call this for keys with a +subpath+ — subagent transcripts must
|
|
39
|
+
# not contribute to the main session's summary. Guard with
|
|
40
|
+
# `if key['subpath'].nil?` before calling.
|
|
41
|
+
#
|
|
42
|
+
# +mtime+ is NOT touched by the fold — it is the sidecar's storage write
|
|
43
|
+
# time and must be stamped by the adapter after persisting (sharing a clock
|
|
44
|
+
# with the mtime returned by SessionStore#list_sessions). For a new session
|
|
45
|
+
# (prev nil) the fold returns mtime 0 as a placeholder for the adapter to
|
|
46
|
+
# overwrite.
|
|
47
|
+
#
|
|
48
|
+
# @param prev [Hash, nil] previous summary entry for this key
|
|
49
|
+
# @param key [Hash] the SessionKey (string keys)
|
|
50
|
+
# @param entries [Array<Hash>] newly appended transcript entries
|
|
51
|
+
# @return [Hash] the updated summary entry ({ 'session_id', 'mtime', 'data' })
|
|
52
|
+
def fold_session_summary(prev, key, entries)
|
|
53
|
+
summary = if prev
|
|
54
|
+
{ 'session_id' => prev['session_id'], 'mtime' => prev['mtime'], 'data' => prev['data'].dup }
|
|
55
|
+
else
|
|
56
|
+
{ 'session_id' => key['session_id'], 'mtime' => 0, 'data' => {} }
|
|
57
|
+
end
|
|
58
|
+
data = summary['data']
|
|
59
|
+
|
|
60
|
+
entries.each do |entry|
|
|
61
|
+
next unless entry.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
data['is_sidechain'] = (entry['isSidechain'] == true) unless data.key?('is_sidechain')
|
|
64
|
+
|
|
65
|
+
# created_at is set-once, so skip the (regex + Time.iso8601) parse for
|
|
66
|
+
# every entry after the first timestamped one — folds run over whole
|
|
67
|
+
# transcripts on the store read paths.
|
|
68
|
+
ms = data.key?('created_at') ? nil : Sessions.parse_iso_timestamp_ms(entry['timestamp'])
|
|
69
|
+
data['created_at'] = ms if ms
|
|
70
|
+
|
|
71
|
+
unless data.key?('cwd')
|
|
72
|
+
cwd = entry['cwd']
|
|
73
|
+
data['cwd'] = cwd if cwd.is_a?(String) && !cwd.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
fold_first_prompt(data, entry)
|
|
77
|
+
|
|
78
|
+
LAST_WINS_FIELDS.each do |src, dst|
|
|
79
|
+
val = entry[src]
|
|
80
|
+
data[dst] = val if val.is_a?(String)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
next unless entry['type'] == 'tag'
|
|
84
|
+
|
|
85
|
+
tag_val = entry['tag']
|
|
86
|
+
if tag_val.is_a?(String) && !tag_val.empty?
|
|
87
|
+
data['tag'] = tag_val
|
|
88
|
+
else
|
|
89
|
+
# Empty string or absent tag clears the tag.
|
|
90
|
+
data.delete('tag')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
summary
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Convert a summary entry to SDKSessionInfo. Returns nil for sidechain
|
|
98
|
+
# sessions or sessions with no extractable summary, matching the disk
|
|
99
|
+
# lite-parse's filtering in Sessions#build_session_info.
|
|
100
|
+
#
|
|
101
|
+
# @param entry [Hash] a summary entry from SessionStore#list_session_summaries
|
|
102
|
+
# @param project_path [String, nil] fallback cwd
|
|
103
|
+
# @return [SDKSessionInfo, nil]
|
|
104
|
+
def summary_entry_to_sdk_info(entry, project_path)
|
|
105
|
+
data = entry['data'] || {}
|
|
106
|
+
return nil if data['is_sidechain']
|
|
107
|
+
|
|
108
|
+
first_prompt = presence(data['first_prompt_locked'] ? data['first_prompt'] : data['command_fallback'])
|
|
109
|
+
custom_title = presence(data['custom_title']) || presence(data['ai_title'])
|
|
110
|
+
summary = custom_title || presence(data['last_prompt']) || presence(data['summary_hint']) || first_prompt
|
|
111
|
+
return nil unless summary
|
|
112
|
+
|
|
113
|
+
SDKSessionInfo.new(
|
|
114
|
+
session_id: entry['session_id'],
|
|
115
|
+
summary: summary,
|
|
116
|
+
last_modified: entry['mtime'],
|
|
117
|
+
# file_size is a JSONL byte count — meaningful only for the local-disk
|
|
118
|
+
# path. Stores have no equivalent.
|
|
119
|
+
file_size: nil,
|
|
120
|
+
custom_title: custom_title,
|
|
121
|
+
first_prompt: first_prompt,
|
|
122
|
+
git_branch: presence(data['git_branch']),
|
|
123
|
+
cwd: presence(data['cwd']) || presence(project_path),
|
|
124
|
+
tag: presence(data['tag']),
|
|
125
|
+
created_at: data['created_at']
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Python's `x or None`: nil for nil/empty-string, else the value.
|
|
130
|
+
def presence(val)
|
|
131
|
+
return nil if val.nil?
|
|
132
|
+
return nil if val.is_a?(String) && val.empty?
|
|
133
|
+
|
|
134
|
+
val
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Replicate Sessions#extract_first_prompt_from_head for a single parsed
|
|
138
|
+
# entry. Mutates +data+ in place: sets first_prompt + first_prompt_locked on
|
|
139
|
+
# a real match, or stashes a command_fallback for slash-command messages.
|
|
140
|
+
#
|
|
141
|
+
# The newline normalization (gsub(/\n+/, ' ')) and no-rstrip truncation
|
|
142
|
+
# deliberately match the disk extractor (not Python's per-char replace) so
|
|
143
|
+
# the Ruby store path and disk path produce identical first_prompt values
|
|
144
|
+
# for the same transcript.
|
|
145
|
+
def fold_first_prompt(data, entry)
|
|
146
|
+
return if data['first_prompt_locked']
|
|
147
|
+
return unless entry['type'] == 'user'
|
|
148
|
+
return if entry['isMeta'] == true || entry['isCompactSummary'] == true
|
|
149
|
+
|
|
150
|
+
message = entry['message']
|
|
151
|
+
if message.is_a?(Hash)
|
|
152
|
+
content = message['content']
|
|
153
|
+
return if content.is_a?(Array) && content.any? { |b| b.is_a?(Hash) && b['type'] == 'tool_result' }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
entry_text_blocks(entry).each do |raw|
|
|
157
|
+
text = raw.gsub(/\n+/, ' ').strip
|
|
158
|
+
next if text.empty?
|
|
159
|
+
|
|
160
|
+
if (match = Sessions::COMMAND_NAME_RE.match(text))
|
|
161
|
+
data['command_fallback'] ||= match[1]
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
next if text.match?(Sessions::SKIP_FIRST_PROMPT_PATTERN)
|
|
165
|
+
|
|
166
|
+
data['first_prompt'] = text.length > 200 ? "#{text[0, 200]}…" : text
|
|
167
|
+
data['first_prompt_locked'] = true
|
|
168
|
+
break
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Extract text strings from a type=="user" entry's message content.
|
|
173
|
+
def entry_text_blocks(entry)
|
|
174
|
+
message = entry['message']
|
|
175
|
+
return [] unless message.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
content = message['content']
|
|
178
|
+
case content
|
|
179
|
+
when String then [content]
|
|
180
|
+
when Array
|
|
181
|
+
content.filter_map { |b| b['text'] if b.is_a?(Hash) && b['type'] == 'text' && b['text'].is_a?(String) }
|
|
182
|
+
else []
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private_class_method :presence, :fold_first_prompt, :entry_text_blocks
|
|
187
|
+
end
|
|
188
|
+
end
|