kward 0.66.0 → 0.67.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 +44 -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 +262 -20
- 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,42 @@ 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
218
|
def recent(limit: 20)
|
|
196
|
-
recent_sessions.first(limit)
|
|
219
|
+
limit ? recent_sessions.first(limit) : recent_sessions
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def remember_last_session(session)
|
|
223
|
+
return unless session&.path
|
|
224
|
+
|
|
225
|
+
FileUtils.mkdir_p(session_dir, mode: 0o700)
|
|
226
|
+
PrivateFile.write_json(last_session_path, { "path" => File.expand_path(session.path), "timestamp" => Time.now.utc.iso8601(3) })
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def last_session_path
|
|
230
|
+
File.join(session_dir, LAST_SESSION_FILENAME)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def remembered_last_session_path
|
|
234
|
+
return nil unless File.file?(last_session_path)
|
|
235
|
+
|
|
236
|
+
path = JSON.parse(File.read(last_session_path))["path"].to_s
|
|
237
|
+
return nil if path.empty? || !File.file?(path)
|
|
238
|
+
|
|
239
|
+
path
|
|
240
|
+
rescue JSON::ParserError
|
|
241
|
+
nil
|
|
197
242
|
end
|
|
198
243
|
|
|
199
244
|
def recent_tree(limit: 20)
|
|
200
|
-
|
|
245
|
+
sessions = limit ? recent_sessions.first(limit) : recent_sessions
|
|
246
|
+
decorate_tree(sessions)
|
|
201
247
|
end
|
|
202
248
|
|
|
203
249
|
def delete_unused_session(session)
|
|
@@ -215,6 +261,63 @@ module Kward
|
|
|
215
261
|
File.join(@config_dir, "sessions", self.class.safe_cwd(@cwd))
|
|
216
262
|
end
|
|
217
263
|
|
|
264
|
+
|
|
265
|
+
def build_tree_record(path, type, parent_id, fields = {})
|
|
266
|
+
message = fields[:message]
|
|
267
|
+
id = message_entry_id(message) || next_entry_id(path)
|
|
268
|
+
assign_message_entry_id(message, id) if message.is_a?(Hash)
|
|
269
|
+
{
|
|
270
|
+
type: type,
|
|
271
|
+
id: id,
|
|
272
|
+
parentId: parent_id,
|
|
273
|
+
timestamp: Time.now.utc.iso8601(3)
|
|
274
|
+
}.merge(fields).delete_if { |_key, value| value.nil? }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def append_leaf_change(path, leaf_id)
|
|
278
|
+
append_record(path, {
|
|
279
|
+
type: "leaf",
|
|
280
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
281
|
+
targetId: leaf_id
|
|
282
|
+
})
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def append_label_change(path, entry_id, label)
|
|
286
|
+
append_record(path, {
|
|
287
|
+
type: "label",
|
|
288
|
+
id: next_entry_id(path),
|
|
289
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
290
|
+
targetId: entry_id.to_s,
|
|
291
|
+
label: label.to_s.strip.empty? ? nil : label.to_s.strip
|
|
292
|
+
})
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def session_tree(path)
|
|
296
|
+
records = records_from_file(resolve_session_path(path))
|
|
297
|
+
build_session_tree(records)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def session_entries(path)
|
|
301
|
+
records = records_from_file(resolve_session_path(path))
|
|
302
|
+
labels = labels_by_target(records)
|
|
303
|
+
timestamps = label_timestamps_by_target(records)
|
|
304
|
+
records.select { |record| tree_entry_record?(record) }.map do |record|
|
|
305
|
+
id = record["id"].to_s
|
|
306
|
+
record.dup.tap do |copy|
|
|
307
|
+
copy["resolvedLabel"] = labels[id] if labels.key?(id)
|
|
308
|
+
copy["labelTimestamp"] = timestamps[id] if timestamps.key?(id)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def session_entry(path, entry_id)
|
|
314
|
+
session_entries(path).find { |record| record["id"].to_s == entry_id.to_s }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def current_leaf(path)
|
|
318
|
+
current_leaf_id(records_from_file(resolve_session_path(path)))
|
|
319
|
+
end
|
|
320
|
+
|
|
218
321
|
def append_record(path, record)
|
|
219
322
|
File.open(path, "a", 0o600) do |file|
|
|
220
323
|
file.write(JSON.generate(record))
|
|
@@ -237,11 +340,27 @@ module Kward
|
|
|
237
340
|
end
|
|
238
341
|
|
|
239
342
|
def records_from_file(path)
|
|
240
|
-
File.readlines(path, chomp: true).filter_map do |line|
|
|
343
|
+
records = File.readlines(path, chomp: true).filter_map do |line|
|
|
241
344
|
JSON.parse(line)
|
|
242
345
|
rescue JSON::ParserError
|
|
243
346
|
nil
|
|
244
347
|
end
|
|
348
|
+
normalize_tree_records(records)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def normalize_tree_records(records)
|
|
352
|
+
parent_id = nil
|
|
353
|
+
entry_index = 0
|
|
354
|
+
records.each do |record|
|
|
355
|
+
next unless tree_entry_record?(record)
|
|
356
|
+
|
|
357
|
+
record["id"] = "message:#{entry_index}" if record["id"].to_s.empty?
|
|
358
|
+
record["parentId"] = parent_id unless record.key?("parentId")
|
|
359
|
+
assign_message_entry_id(record["message"], record["id"]) if record["message"].is_a?(Hash) && message_entry_id(record["message"]).to_s.empty?
|
|
360
|
+
parent_id = record["id"]
|
|
361
|
+
entry_index += 1
|
|
362
|
+
end
|
|
363
|
+
records
|
|
245
364
|
end
|
|
246
365
|
|
|
247
366
|
def session_header(records, path)
|
|
@@ -306,17 +425,127 @@ module Kward
|
|
|
306
425
|
end
|
|
307
426
|
|
|
308
427
|
def restored_messages(records)
|
|
309
|
-
records.each_with_object([]) do |record, messages|
|
|
428
|
+
branch_records(records).each_with_object([]) do |record, messages|
|
|
310
429
|
message = record["message"]
|
|
311
430
|
case record["type"]
|
|
312
431
|
when "message"
|
|
313
432
|
messages << message if message.is_a?(Hash)
|
|
314
433
|
when "compaction"
|
|
315
434
|
messages.replace(rebuilt_compacted_messages(message, messages)) if message.is_a?(Hash)
|
|
435
|
+
when "branch_summary"
|
|
436
|
+
messages << { "role" => "branchSummary", "content" => record["summary"].to_s, "id" => record["id"] }
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def build_session_tree(records)
|
|
443
|
+
entries = records.select { |record| tree_entry_record?(record) }
|
|
444
|
+
labels = labels_by_target(records)
|
|
445
|
+
label_timestamps = label_timestamps_by_target(records)
|
|
446
|
+
nodes = entries.each_with_object({}) do |entry, map|
|
|
447
|
+
id = entry["id"].to_s
|
|
448
|
+
next if id.empty?
|
|
449
|
+
|
|
450
|
+
node = { "entry" => decorate_tree_entry(entry), "children" => [] }
|
|
451
|
+
node["label"] = labels[id] if labels.key?(id)
|
|
452
|
+
node["labelTimestamp"] = label_timestamps[id] if label_timestamps.key?(id)
|
|
453
|
+
map[id] = node
|
|
454
|
+
end
|
|
455
|
+
roots = []
|
|
456
|
+
entries.each do |entry|
|
|
457
|
+
id = entry["id"].to_s
|
|
458
|
+
node = nodes[id]
|
|
459
|
+
next unless node
|
|
460
|
+
|
|
461
|
+
parent = nodes[entry["parentId"].to_s]
|
|
462
|
+
parent ? parent["children"] << node : roots << node
|
|
463
|
+
end
|
|
464
|
+
roots
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def decorate_tree_entry(entry)
|
|
468
|
+
entry.dup
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def branch_records(records)
|
|
472
|
+
return legacy_branch_records(records) unless records.any? { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
|
|
473
|
+
|
|
474
|
+
entries = records.select { |record| tree_entry_record?(record) }
|
|
475
|
+
by_id = entries.to_h { |record| [record["id"].to_s, record] }
|
|
476
|
+
leaf_id = current_leaf_id(records)
|
|
477
|
+
return [] if leaf_id.nil?
|
|
478
|
+
|
|
479
|
+
branch = []
|
|
480
|
+
seen = {}
|
|
481
|
+
current = by_id[leaf_id.to_s]
|
|
482
|
+
while current && !seen[current["id"].to_s]
|
|
483
|
+
seen[current["id"].to_s] = true
|
|
484
|
+
branch << current
|
|
485
|
+
parent_id = current["parentId"]
|
|
486
|
+
current = parent_id ? by_id[parent_id.to_s] : nil
|
|
487
|
+
end
|
|
488
|
+
branch.reverse
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def legacy_branch_records(records)
|
|
492
|
+
records.select { |record| ["message", "compaction"].include?(record["type"]) }
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def current_leaf_id(records)
|
|
496
|
+
latest = records.reverse.find { |record| record["type"] == "leaf" || (tree_entry_record?(record) && !record["id"].to_s.empty?) }
|
|
497
|
+
return nil unless latest
|
|
498
|
+
return latest["targetId"] if latest["type"] == "leaf"
|
|
499
|
+
|
|
500
|
+
latest["id"]
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def tree_entry_record?(record)
|
|
504
|
+
["message", "compaction", "branch_summary"].include?(record["type"])
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def labels_by_target(records)
|
|
508
|
+
records.each_with_object({}) do |record, labels|
|
|
509
|
+
next unless record["type"] == "label"
|
|
510
|
+
|
|
511
|
+
target = record["targetId"].to_s
|
|
512
|
+
next if target.empty?
|
|
513
|
+
|
|
514
|
+
label = record["label"].to_s.strip
|
|
515
|
+
label.empty? ? labels.delete(target) : labels[target] = label
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def label_timestamps_by_target(records)
|
|
520
|
+
records.each_with_object({}) do |record, timestamps|
|
|
521
|
+
next unless record["type"] == "label"
|
|
522
|
+
|
|
523
|
+
target = record["targetId"].to_s
|
|
524
|
+
next if target.empty?
|
|
525
|
+
|
|
526
|
+
if record["label"].to_s.strip.empty?
|
|
527
|
+
timestamps.delete(target)
|
|
528
|
+
else
|
|
529
|
+
timestamps[target] = record["timestamp"]
|
|
316
530
|
end
|
|
317
531
|
end
|
|
318
532
|
end
|
|
319
533
|
|
|
534
|
+
def next_entry_id(_path)
|
|
535
|
+
SecureRandom.hex(4)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def message_entry_id(message)
|
|
539
|
+
return nil unless message.respond_to?(:key?)
|
|
540
|
+
|
|
541
|
+
message["id"] || message[:id]
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def assign_message_entry_id(message, id)
|
|
545
|
+
message["id"] = id
|
|
546
|
+
message.delete(:id) if message.key?(:id)
|
|
547
|
+
end
|
|
548
|
+
|
|
320
549
|
def rebuilt_compacted_messages(compaction_message, previous_messages)
|
|
321
550
|
first_kept_entry_id = compaction_message["first_kept_entry_id"] || compaction_message["firstKeptEntryId"]
|
|
322
551
|
return [compaction_message] if first_kept_entry_id.to_s.empty?
|
|
@@ -347,10 +576,23 @@ module Kward
|
|
|
347
576
|
|
|
348
577
|
def recent_sessions
|
|
349
578
|
Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
|
|
350
|
-
session_info(path)
|
|
579
|
+
info = session_info(path)
|
|
580
|
+
next unless info
|
|
581
|
+
next if delete_empty_unnamed_session_info(info)
|
|
582
|
+
|
|
583
|
+
info
|
|
351
584
|
end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
|
|
352
585
|
end
|
|
353
586
|
|
|
587
|
+
def delete_empty_unnamed_session_info(info)
|
|
588
|
+
return false unless info.name.to_s.strip.empty? && info.message_count.to_i.zero?
|
|
589
|
+
|
|
590
|
+
File.delete(info.path)
|
|
591
|
+
true
|
|
592
|
+
rescue StandardError
|
|
593
|
+
false
|
|
594
|
+
end
|
|
595
|
+
|
|
354
596
|
def decorate_tree(sessions)
|
|
355
597
|
by_parent = Hash.new { |hash, key| hash[key] = [] }
|
|
356
598
|
ids = sessions.map(&:id).to_h { |id| [id, true] }
|