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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -3
- data/Gemfile.lock +2 -2
- data/README.md +5 -1
- data/doc/configuration.md +43 -1
- data/doc/memory.md +31 -9
- data/doc/rpc.md +41 -21
- data/doc/troubleshooting.md +55 -0
- data/doc/usage.md +41 -6
- data/lib/kward/cli.rb +1155 -195
- data/lib/kward/cli_transcript_formatter.rb +124 -0
- data/lib/kward/compaction/file_operation_tracker.rb +46 -0
- data/lib/kward/compactor.rb +3 -68
- data/lib/kward/config_files.rb +45 -69
- data/lib/kward/memory/manager.rb +66 -7
- data/lib/kward/model/client.rb +2 -195
- data/lib/kward/model/model_info.rb +9 -10
- data/lib/kward/model/payloads.rb +203 -0
- data/lib/kward/prompt_interface/banner.rb +77 -0
- data/lib/kward/prompt_interface.rb +220 -191
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/rpc/runtime_payloads.rb +79 -0
- data/lib/kward/rpc/server.rb +33 -34
- data/lib/kward/rpc/session_manager.rb +518 -159
- data/lib/kward/rpc/tool_event_normalizer.rb +12 -9
- data/lib/kward/rpc/transcript_normalizer.rb +31 -53
- data/lib/kward/session_store.rb +269 -23
- data/lib/kward/session_trash.rb +96 -0
- data/lib/kward/session_tree_renderer.rb +264 -0
- data/lib/kward/tools/registry.rb +3 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +10 -5
- metadata +9 -1
|
@@ -10,19 +10,16 @@ module Kward
|
|
|
10
10
|
@fields = ToolMetadata.normalized_tool_fields(@tool_call)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def call_payload
|
|
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
|
|
25
|
-
call_payload
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
256
|
+
ToolMetadata.normalize_tool_args(name, args)
|
|
257
257
|
when "run_shell_command", "bash"
|
|
258
258
|
normalize_bash_args(args)
|
|
259
259
|
else
|
|
260
|
-
|
|
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 =
|
|
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
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -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 =
|
|
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.
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
200
|
-
|
|
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] }
|