kward 0.66.0 → 0.67.1

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.
@@ -10,19 +10,16 @@ module Kward
10
10
  @fields = ToolMetadata.normalized_tool_fields(@tool_call)
11
11
  end
12
12
 
13
- def call_payload(legacy_tool: nil)
13
+ def call_payload
14
14
  {
15
- toolCall: @tool_call,
16
- rawToolCall: @tool_call,
17
- tool: legacy_tool,
18
15
  toolCallId: @fields[:toolCallId],
19
16
  toolName: @fields[:toolName],
20
17
  args: @fields[:args]
21
18
  }.compact
22
19
  end
23
20
 
24
- def result_payload(legacy_tool: nil)
25
- call_payload(legacy_tool: legacy_tool).merge(
21
+ def result_payload
22
+ call_payload.merge(
26
23
  content: @content,
27
24
  result: normalized_result
28
25
  )
@@ -48,8 +45,10 @@ module Kward
48
45
  is_error = ToolMetadata.error_result?(text)
49
46
  result = { content: text, isError: is_error, images: [] }
50
47
  unless is_error
51
- diff = ToolMetadata.extract_unified_diff(text)
52
- result[:diff] = diff if diff
48
+ if mutation_tool?
49
+ diff = ToolMetadata.extract_unified_diff(text)
50
+ result[:diff] = diff if diff
51
+ end
53
52
 
54
53
  files = changed_files
55
54
  result[:changedFiles] = files if files.any?
@@ -58,11 +57,15 @@ module Kward
58
57
  end
59
58
 
60
59
  def changed_files
61
- return [] unless ["edit", "write"].include?(@fields[:toolName])
60
+ return [] unless mutation_tool?
62
61
 
63
62
  path = @fields.dig(:args, :path)
64
63
  path.to_s.empty? ? [] : [path]
65
64
  end
65
+
66
+ def mutation_tool?
67
+ ["edit", "write"].include?(@fields[:toolName])
68
+ end
66
69
  end
67
70
  end
68
71
  end
@@ -21,7 +21,7 @@ module Kward
21
21
  def normalize_message(message)
22
22
  return nil unless message.is_a?(Hash)
23
23
 
24
- case value(message, :role).to_s
24
+ case ToolCall.value(message, :role).to_s
25
25
  when "system"
26
26
  nil
27
27
  when "user"
@@ -42,9 +42,9 @@ module Kward
42
42
  end
43
43
 
44
44
  def normalize_compaction_summary(message)
45
- summary = value(message, :summary) || value(message, :content)
45
+ summary = ToolCall.value(message, :summary) || ToolCall.value(message, :content)
46
46
  result = { role: "compactionSummary", summary: summary.to_s }
47
- tokens_before = value(message, :tokensBefore) || value(message, :tokens_before)
47
+ tokens_before = ToolCall.value(message, :tokensBefore) || ToolCall.value(message, :tokens_before)
48
48
  result[:tokensBefore] = tokens_before if tokens_before
49
49
  result
50
50
  end
@@ -52,12 +52,12 @@ module Kward
52
52
  def normalize_user_message(message)
53
53
  {
54
54
  role: "user",
55
- content: normalize_content(value(message, :content))
55
+ content: normalize_content(ToolCall.value(message, :content))
56
56
  }
57
57
  end
58
58
 
59
59
  def normalize_assistant_message(message)
60
- content = reasoning_first_content(normalize_content(value(message, :content), preserve_thinking: true))
60
+ content = reasoning_first_content(normalize_content(ToolCall.value(message, :content), preserve_thinking: true))
61
61
  reasoning = normalize_reasoning_summary(message)
62
62
  content.unshift(reasoning) if reasoning && !thinking_content?(content)
63
63
  tool_calls(message).each do |tool_call|
@@ -69,17 +69,17 @@ module Kward
69
69
  end
70
70
 
71
71
  result = { role: "assistant", content: content }
72
- error_message = value(message, :errorMessage) || value(message, :error_message)
72
+ error_message = ToolCall.value(message, :errorMessage) || ToolCall.value(message, :error_message)
73
73
  result[:errorMessage] = error_message unless error_message.to_s.empty?
74
74
  result
75
75
  end
76
76
 
77
77
  def normalize_tool_result_message(message)
78
- tool_call_id = value(message, :toolCallId) || value(message, :tool_call_id)
78
+ tool_call_id = ToolCall.value(message, :toolCallId) || ToolCall.value(message, :tool_call_id)
79
79
  matching_call = @tool_calls_by_id[tool_call_id]
80
- raw_name = value(message, :toolName) || value(message, :tool_name) || value(message, :name)
80
+ raw_name = ToolCall.value(message, :toolName) || ToolCall.value(message, :tool_name) || ToolCall.value(message, :name)
81
81
  tool_name = normalize_tool_name(raw_name) || raw_name || matching_call&.dig(:name)
82
- content = normalize_content(value(message, :content))
82
+ content = normalize_content(ToolCall.value(message, :content))
83
83
 
84
84
  result = {
85
85
  role: "toolResult",
@@ -120,10 +120,10 @@ module Kward
120
120
  def normalize_content_part(part, preserve_thinking: false)
121
121
  return { type: "text", text: part.to_s } unless part.is_a?(Hash)
122
122
 
123
- type = value(part, :type).to_s
123
+ type = ToolCall.value(part, :type).to_s
124
124
  case type
125
125
  when "text"
126
- text = value(part, :text)
126
+ text = ToolCall.value(part, :text)
127
127
  text.nil? ? nil : { type: "text", text: text.to_s }
128
128
  when "image"
129
129
  normalize_image_part(part)
@@ -137,17 +137,17 @@ module Kward
137
137
  end
138
138
 
139
139
  def normalize_unknown_content_part(part)
140
- text = value(part, :text)
140
+ text = ToolCall.value(part, :text)
141
141
  text.nil? ? nil : { type: "text", text: text.to_s }
142
142
  end
143
143
 
144
144
  def normalize_thinking_part(part)
145
- thinking = value(part, :thinking) || value(part, :reasoning) || value(part, :text)
145
+ thinking = ToolCall.value(part, :thinking) || ToolCall.value(part, :reasoning) || ToolCall.value(part, :text)
146
146
  thinking.nil? ? nil : { type: "thinking", thinking: thinking.to_s }
147
147
  end
148
148
 
149
149
  def normalize_reasoning_summary(message)
150
- summary = value(message, :reasoning_summary) || value(message, :reasoningSummary)
150
+ summary = ToolCall.value(message, :reasoning_summary) || ToolCall.value(message, :reasoningSummary)
151
151
  summary.to_s.empty? ? nil : { type: "thinking", thinking: summary.to_s }
152
152
  end
153
153
 
@@ -161,36 +161,36 @@ module Kward
161
161
  end
162
162
 
163
163
  def thinking_content_part?(part)
164
- part.is_a?(Hash) && value(part, :type) == "thinking"
164
+ part.is_a?(Hash) && ToolCall.value(part, :type) == "thinking"
165
165
  end
166
166
 
167
167
  def normalize_image_part(part)
168
- mime_type = value(part, :mimeType) || value(part, :mime_type) || value(part, :media_type)
168
+ mime_type = ToolCall.value(part, :mimeType) || ToolCall.value(part, :mime_type) || ToolCall.value(part, :media_type)
169
169
  mime_type = normalize_mime_type(mime_type)
170
170
  return nil unless IMAGE_MIME_TYPES.include?(mime_type)
171
171
 
172
- data = value(part, :data)
172
+ data = ToolCall.value(part, :data)
173
173
  return nil if data.to_s.empty?
174
174
 
175
175
  result = { type: "image", data: data, mimeType: mime_type }
176
- alt = value(part, :alt) || image_alt_from_path(value(part, :path))
176
+ alt = ToolCall.value(part, :alt) || image_alt_from_path(ToolCall.value(part, :path))
177
177
  result[:alt] = alt unless alt.to_s.empty?
178
178
  result
179
179
  end
180
180
 
181
181
  def normalize_existing_tool_call_part(part)
182
- raw_name = value(part, :name)
183
- arguments = value(part, :arguments) || {}
182
+ raw_name = ToolCall.value(part, :name)
183
+ arguments = ToolCall.value(part, :arguments) || {}
184
184
  {
185
185
  type: "toolCall",
186
- id: value(part, :id),
186
+ id: ToolCall.value(part, :id),
187
187
  name: normalize_tool_name(raw_name) || raw_name,
188
188
  arguments: normalize_tool_arguments(raw_name, arguments)
189
189
  }.compact
190
190
  end
191
191
 
192
192
  def tool_result_details(message, matching_call, content)
193
- explicit_details = value(message, :details)
193
+ explicit_details = ToolCall.value(message, :details)
194
194
  details = explicit_details.is_a?(Hash) ? safe_details(explicit_details) : {}
195
195
  text = content_text(content)
196
196
 
@@ -205,9 +205,9 @@ module Kward
205
205
 
206
206
  def safe_details(details)
207
207
  allowed = {}
208
- diff = value(details, :diff)
208
+ diff = ToolCall.value(details, :diff)
209
209
  allowed[:diff] = diff if diff
210
- changed_files = value(details, :changedFiles) || value(details, :changed_files)
210
+ changed_files = ToolCall.value(details, :changedFiles) || ToolCall.value(details, :changed_files)
211
211
  allowed[:changedFiles] = changed_files if changed_files.is_a?(Array)
212
212
  allowed
213
213
  end
@@ -228,8 +228,8 @@ module Kward
228
228
  end
229
229
 
230
230
  def error_tool_result?(message, content)
231
- return value(message, :isError) if has_key?(message, :isError)
232
- return value(message, :is_error) if has_key?(message, :is_error)
231
+ return ToolCall.value(message, :isError) if has_key?(message, :isError)
232
+ return ToolCall.value(message, :is_error) if has_key?(message, :is_error)
233
233
 
234
234
  ToolMetadata.error_result?(content_text(content))
235
235
  end
@@ -241,7 +241,7 @@ module Kward
241
241
  end
242
242
 
243
243
  def tool_calls(message)
244
- calls = value(message, :tool_calls) || value(message, :toolCalls)
244
+ calls = ToolCall.value(message, :tool_calls) || ToolCall.value(message, :toolCalls)
245
245
  calls.is_a?(Array) ? calls : []
246
246
  end
247
247
 
@@ -253,40 +253,22 @@ module Kward
253
253
  args = ToolCall.parse_arguments(arguments)
254
254
  case name.to_s
255
255
  when "edit_file", "edit"
256
- normalize_edit_args(args)
256
+ ToolMetadata.normalize_tool_args(name, args)
257
257
  when "run_shell_command", "bash"
258
258
  normalize_bash_args(args)
259
259
  else
260
- camelize_tool_args(args)
260
+ ToolCall.camelize_args(args)
261
261
  end
262
262
  end
263
263
 
264
- def normalize_edit_args(args)
265
- normalized = camelize_tool_args(args)
266
- edits = Array(value(args, :edits)).filter_map do |edit|
267
- next unless edit.is_a?(Hash)
268
-
269
- {
270
- oldText: value(edit, :oldText) || value(edit, :old_text),
271
- newText: value(edit, :newText) || value(edit, :new_text)
272
- }.compact
273
- end
274
- normalized[:edits] = edits if edits.any?
275
- normalized
276
- end
277
-
278
264
  def normalize_bash_args(args)
279
- normalized = camelize_tool_args(args)
280
- timeout = value(args, :timeoutSeconds) || value(args, :timeout_seconds)
265
+ normalized = ToolCall.camelize_args(args)
266
+ timeout = ToolCall.value(args, :timeoutSeconds) || ToolCall.value(args, :timeout_seconds)
281
267
  normalized[:timeoutSeconds] = timeout if timeout
282
268
  normalized.delete(:timeout_seconds)
283
269
  normalized
284
270
  end
285
271
 
286
- def camelize_tool_args(args)
287
- ToolCall.camelize_args(args)
288
- end
289
-
290
272
  def normalize_mime_type(mime_type)
291
273
  mime_type.to_s.downcase.sub("image/jpg", "image/jpeg")
292
274
  end
@@ -295,10 +277,6 @@ module Kward
295
277
  path ? File.basename(path.to_s) : nil
296
278
  end
297
279
 
298
- def value(object, key)
299
- ToolCall.value(object, key)
300
- end
301
-
302
280
  def has_key?(object, key)
303
281
  object.respond_to?(:key?) && (object.key?(key) || object.key?(key.to_s))
304
282
  end
@@ -5,21 +5,23 @@ require "time"
5
5
  require_relative "config_files"
6
6
  require_relative "conversation"
7
7
  require_relative "message_access"
8
+ require_relative "private_file"
8
9
  require_relative "rpc/tool_event_normalizer"
9
10
  require_relative "tools/tool_call"
10
11
  require_relative "workspace"
11
12
 
12
13
  module Kward
13
14
  class SessionStore
14
- VERSION = 1
15
+ VERSION = 2
16
+ LAST_SESSION_FILENAME = "last_session.json"
15
17
 
16
18
  SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
17
19
 
18
20
  class Session
19
21
  attr_reader :id, :path, :cwd, :created_at, :parent_id, :parent_path
20
- attr_accessor :name
22
+ attr_accessor :name, :leaf_id
21
23
 
22
- def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil)
24
+ def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil, leaf_id: nil)
23
25
  @store = store
24
26
  @id = id
25
27
  @path = path
@@ -28,6 +30,7 @@ module Kward
28
30
  @name = name
29
31
  @parent_id = parent_id
30
32
  @parent_path = parent_path
33
+ @leaf_id = leaf_id
31
34
  end
32
35
 
33
36
  def attach(conversation)
@@ -38,19 +41,15 @@ module Kward
38
41
  end
39
42
 
40
43
  def append_message(message)
41
- @store.append_record(@path, {
42
- type: "message",
43
- timestamp: Time.now.utc.iso8601(3),
44
- message: message
45
- })
44
+ record = @store.build_tree_record(@path, "message", @leaf_id, message: message)
45
+ @leaf_id = record[:id]
46
+ @store.append_record(@path, record)
46
47
  end
47
48
 
48
49
  def compact(message)
49
- @store.append_record(@path, {
50
- type: "compaction",
51
- timestamp: Time.now.utc.iso8601(3),
52
- message: message
53
- })
50
+ record = @store.build_tree_record(@path, "compaction", @leaf_id, message: message)
51
+ @leaf_id = record[:id]
52
+ @store.append_record(@path, record)
54
53
  end
55
54
 
56
55
  def append_tool_execution(tool_call, content)
@@ -75,6 +74,26 @@ module Kward
75
74
  })
76
75
  end
77
76
 
77
+ def branch(entry_id)
78
+ @leaf_id = entry_id.to_s.empty? ? nil : entry_id.to_s
79
+ @store.append_leaf_change(@path, @leaf_id)
80
+ end
81
+
82
+ def reset_leaf
83
+ branch(nil)
84
+ end
85
+
86
+ def append_label_change(entry_id, label)
87
+ @store.append_label_change(@path, entry_id, label)
88
+ end
89
+
90
+ def append_branch_summary(parent_id, from_id:, summary:, details: {})
91
+ record = @store.build_tree_record(@path, "branch_summary", parent_id, fromId: from_id, summary: summary, details: details || {})
92
+ @leaf_id = record[:id]
93
+ @store.append_record(@path, record)
94
+ record[:id]
95
+ end
96
+
78
97
  def update_runtime(model:, reasoning_effort:)
79
98
  @store.append_record(@path, {
80
99
  type: "session_info",
@@ -121,11 +140,12 @@ module Kward
121
140
  end
122
141
  File.chmod(0o600, path)
123
142
 
124
- Session.new(store: self, id: id, path: path, cwd: @cwd, created_at: created_at, parent_id: parent_id, parent_path: parent_path)
143
+ Session.new(store: self, id: id, path: path, cwd: @cwd, created_at: created_at, parent_id: parent_id, parent_path: parent_path, leaf_id: nil)
125
144
  end
126
145
 
127
146
  def create_from_conversation(conversation, parent_session: nil)
128
147
  session = create(model: conversation.model, reasoning_effort: conversation.reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
148
+ session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
129
149
  persisted_messages(conversation).each { |message| session.append_message(message) }
130
150
  session.attach(conversation)
131
151
  session
@@ -143,6 +163,7 @@ module Kward
143
163
 
144
164
  def create_independent_from_messages(messages, read_paths: [], model: nil, reasoning_effort: nil, parent_session: nil)
145
165
  session = create(model: model, reasoning_effort: reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
166
+ session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
146
167
  persisted = deep_copy(messages)
147
168
  persisted.each { |message| session.append_message(message) }
148
169
  conversation = Conversation.new(messages: deep_copy(persisted), read_paths: read_paths, workspace_root: @cwd, model: model, reasoning_effort: reasoning_effort)
@@ -162,6 +183,7 @@ module Kward
162
183
  records = records_from_file(resolved_path)
163
184
  header = session_header(records, resolved_path)
164
185
 
186
+ leaf_id = current_leaf_id(records)
165
187
  messages = restored_messages(records)
166
188
  name = session_name(records)
167
189
  read_paths = restored_read_paths(messages, workspace)
@@ -186,18 +208,44 @@ module Kward
186
208
  created_at: parse_time(header["timestamp"]) || File.mtime(resolved_path),
187
209
  name: name,
188
210
  parent_id: header["parentId"],
189
- parent_path: header["parentPath"]
211
+ parent_path: header["parentPath"],
212
+ leaf_id: leaf_id
190
213
  )
191
214
  session.attach(conversation)
192
215
  [session, conversation]
193
216
  end
194
217
 
195
- def recent(limit: 20)
196
- recent_sessions.first(limit)
218
+ def recent(limit: 20, keep_empty_path: nil)
219
+ sessions = recent_sessions(keep_empty_path: keep_empty_path)
220
+ limit ? sessions.first(limit) : sessions
197
221
  end
198
222
 
199
- def recent_tree(limit: 20)
200
- decorate_tree(recent_sessions.first(limit))
223
+ def remember_last_session(session)
224
+ return unless session&.path
225
+
226
+ FileUtils.mkdir_p(session_dir, mode: 0o700)
227
+ PrivateFile.write_json(last_session_path, { "path" => File.expand_path(session.path), "timestamp" => Time.now.utc.iso8601(3) })
228
+ end
229
+
230
+ def last_session_path
231
+ File.join(session_dir, LAST_SESSION_FILENAME)
232
+ end
233
+
234
+ def remembered_last_session_path
235
+ return nil unless File.file?(last_session_path)
236
+
237
+ path = JSON.parse(File.read(last_session_path))["path"].to_s
238
+ return nil if path.empty? || !File.file?(path)
239
+
240
+ path
241
+ rescue JSON::ParserError
242
+ nil
243
+ end
244
+
245
+ def recent_tree(limit: 20, keep_empty_path: nil)
246
+ sessions = recent_sessions(keep_empty_path: keep_empty_path)
247
+ sessions = sessions.first(limit) if limit
248
+ decorate_tree(sessions)
201
249
  end
202
250
 
203
251
  def delete_unused_session(session)
@@ -215,6 +263,63 @@ module Kward
215
263
  File.join(@config_dir, "sessions", self.class.safe_cwd(@cwd))
216
264
  end
217
265
 
266
+
267
+ def build_tree_record(path, type, parent_id, fields = {})
268
+ message = fields[:message]
269
+ id = message_entry_id(message) || next_entry_id(path)
270
+ assign_message_entry_id(message, id) if message.is_a?(Hash)
271
+ {
272
+ type: type,
273
+ id: id,
274
+ parentId: parent_id,
275
+ timestamp: Time.now.utc.iso8601(3)
276
+ }.merge(fields).delete_if { |_key, value| value.nil? }
277
+ end
278
+
279
+ def append_leaf_change(path, leaf_id)
280
+ append_record(path, {
281
+ type: "leaf",
282
+ timestamp: Time.now.utc.iso8601(3),
283
+ targetId: leaf_id
284
+ })
285
+ end
286
+
287
+ def append_label_change(path, entry_id, label)
288
+ append_record(path, {
289
+ type: "label",
290
+ id: next_entry_id(path),
291
+ timestamp: Time.now.utc.iso8601(3),
292
+ targetId: entry_id.to_s,
293
+ label: label.to_s.strip.empty? ? nil : label.to_s.strip
294
+ })
295
+ end
296
+
297
+ def session_tree(path)
298
+ records = records_from_file(resolve_session_path(path))
299
+ build_session_tree(records)
300
+ end
301
+
302
+ def session_entries(path)
303
+ records = records_from_file(resolve_session_path(path))
304
+ labels = labels_by_target(records)
305
+ timestamps = label_timestamps_by_target(records)
306
+ records.select { |record| tree_entry_record?(record) }.map do |record|
307
+ id = record["id"].to_s
308
+ record.dup.tap do |copy|
309
+ copy["resolvedLabel"] = labels[id] if labels.key?(id)
310
+ copy["labelTimestamp"] = timestamps[id] if timestamps.key?(id)
311
+ end
312
+ end
313
+ end
314
+
315
+ def session_entry(path, entry_id)
316
+ session_entries(path).find { |record| record["id"].to_s == entry_id.to_s }
317
+ end
318
+
319
+ def current_leaf(path)
320
+ current_leaf_id(records_from_file(resolve_session_path(path)))
321
+ end
322
+
218
323
  def append_record(path, record)
219
324
  File.open(path, "a", 0o600) do |file|
220
325
  file.write(JSON.generate(record))
@@ -237,11 +342,27 @@ module Kward
237
342
  end
238
343
 
239
344
  def records_from_file(path)
240
- File.readlines(path, chomp: true).filter_map do |line|
345
+ records = File.readlines(path, chomp: true).filter_map do |line|
241
346
  JSON.parse(line)
242
347
  rescue JSON::ParserError
243
348
  nil
244
349
  end
350
+ normalize_tree_records(records)
351
+ end
352
+
353
+ def normalize_tree_records(records)
354
+ parent_id = nil
355
+ entry_index = 0
356
+ records.each do |record|
357
+ next unless tree_entry_record?(record)
358
+
359
+ record["id"] = "message:#{entry_index}" if record["id"].to_s.empty?
360
+ record["parentId"] = parent_id unless record.key?("parentId")
361
+ assign_message_entry_id(record["message"], record["id"]) if record["message"].is_a?(Hash) && message_entry_id(record["message"]).to_s.empty?
362
+ parent_id = record["id"]
363
+ entry_index += 1
364
+ end
365
+ records
245
366
  end
246
367
 
247
368
  def session_header(records, path)
@@ -306,17 +427,127 @@ module Kward
306
427
  end
307
428
 
308
429
  def restored_messages(records)
309
- records.each_with_object([]) do |record, messages|
430
+ branch_records(records).each_with_object([]) do |record, messages|
310
431
  message = record["message"]
311
432
  case record["type"]
312
433
  when "message"
313
434
  messages << message if message.is_a?(Hash)
314
435
  when "compaction"
315
436
  messages.replace(rebuilt_compacted_messages(message, messages)) if message.is_a?(Hash)
437
+ when "branch_summary"
438
+ messages << { "role" => "branchSummary", "content" => record["summary"].to_s, "id" => record["id"] }
439
+ end
440
+ end
441
+ end
442
+
443
+
444
+ def build_session_tree(records)
445
+ entries = records.select { |record| tree_entry_record?(record) }
446
+ labels = labels_by_target(records)
447
+ label_timestamps = label_timestamps_by_target(records)
448
+ nodes = entries.each_with_object({}) do |entry, map|
449
+ id = entry["id"].to_s
450
+ next if id.empty?
451
+
452
+ node = { "entry" => decorate_tree_entry(entry), "children" => [] }
453
+ node["label"] = labels[id] if labels.key?(id)
454
+ node["labelTimestamp"] = label_timestamps[id] if label_timestamps.key?(id)
455
+ map[id] = node
456
+ end
457
+ roots = []
458
+ entries.each do |entry|
459
+ id = entry["id"].to_s
460
+ node = nodes[id]
461
+ next unless node
462
+
463
+ parent = nodes[entry["parentId"].to_s]
464
+ parent ? parent["children"] << node : roots << node
465
+ end
466
+ roots
467
+ end
468
+
469
+ def decorate_tree_entry(entry)
470
+ entry.dup
471
+ end
472
+
473
+ def branch_records(records)
474
+ return legacy_branch_records(records) unless records.any? { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
475
+
476
+ entries = records.select { |record| tree_entry_record?(record) }
477
+ by_id = entries.to_h { |record| [record["id"].to_s, record] }
478
+ leaf_id = current_leaf_id(records)
479
+ return [] if leaf_id.nil?
480
+
481
+ branch = []
482
+ seen = {}
483
+ current = by_id[leaf_id.to_s]
484
+ while current && !seen[current["id"].to_s]
485
+ seen[current["id"].to_s] = true
486
+ branch << current
487
+ parent_id = current["parentId"]
488
+ current = parent_id ? by_id[parent_id.to_s] : nil
489
+ end
490
+ branch.reverse
491
+ end
492
+
493
+ def legacy_branch_records(records)
494
+ records.select { |record| ["message", "compaction"].include?(record["type"]) }
495
+ end
496
+
497
+ def current_leaf_id(records)
498
+ latest = records.reverse.find { |record| record["type"] == "leaf" || (tree_entry_record?(record) && !record["id"].to_s.empty?) }
499
+ return nil unless latest
500
+ return latest["targetId"] if latest["type"] == "leaf"
501
+
502
+ latest["id"]
503
+ end
504
+
505
+ def tree_entry_record?(record)
506
+ ["message", "compaction", "branch_summary"].include?(record["type"])
507
+ end
508
+
509
+ def labels_by_target(records)
510
+ records.each_with_object({}) do |record, labels|
511
+ next unless record["type"] == "label"
512
+
513
+ target = record["targetId"].to_s
514
+ next if target.empty?
515
+
516
+ label = record["label"].to_s.strip
517
+ label.empty? ? labels.delete(target) : labels[target] = label
518
+ end
519
+ end
520
+
521
+ def label_timestamps_by_target(records)
522
+ records.each_with_object({}) do |record, timestamps|
523
+ next unless record["type"] == "label"
524
+
525
+ target = record["targetId"].to_s
526
+ next if target.empty?
527
+
528
+ if record["label"].to_s.strip.empty?
529
+ timestamps.delete(target)
530
+ else
531
+ timestamps[target] = record["timestamp"]
316
532
  end
317
533
  end
318
534
  end
319
535
 
536
+ def next_entry_id(_path)
537
+ SecureRandom.hex(4)
538
+ end
539
+
540
+ def message_entry_id(message)
541
+ return nil unless message.respond_to?(:key?)
542
+
543
+ message["id"] || message[:id]
544
+ end
545
+
546
+ def assign_message_entry_id(message, id)
547
+ message["id"] = id
548
+ message.delete(:id) if message.key?(:id)
549
+ end
550
+
320
551
  def rebuilt_compacted_messages(compaction_message, previous_messages)
321
552
  first_kept_entry_id = compaction_message["first_kept_entry_id"] || compaction_message["firstKeptEntryId"]
322
553
  return [compaction_message] if first_kept_entry_id.to_s.empty?
@@ -345,12 +576,27 @@ module Kward
345
576
  nil
346
577
  end
347
578
 
348
- def recent_sessions
579
+ def recent_sessions(keep_empty_path: nil)
580
+ keep_empty_path = File.expand_path(keep_empty_path) unless keep_empty_path.to_s.empty?
349
581
  Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
350
- session_info(path)
582
+ info = session_info(path)
583
+ next unless info
584
+ next if delete_empty_unnamed_session_info(info, keep_empty_path: keep_empty_path)
585
+
586
+ info
351
587
  end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
352
588
  end
353
589
 
590
+ def delete_empty_unnamed_session_info(info, keep_empty_path: nil)
591
+ return false unless info.name.to_s.strip.empty? && info.message_count.to_i.zero?
592
+ return true if keep_empty_path && File.expand_path(info.path) == keep_empty_path
593
+
594
+ File.delete(info.path)
595
+ true
596
+ rescue StandardError
597
+ false
598
+ end
599
+
354
600
  def decorate_tree(sessions)
355
601
  by_parent = Hash.new { |hash, key| hash[key] = [] }
356
602
  ids = sessions.map(&:id).to_h { |id| [id, true] }