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
|
@@ -17,12 +17,14 @@ require_relative "../model/model_info"
|
|
|
17
17
|
require_relative "../plugin_registry"
|
|
18
18
|
require_relative "../prompts/commands"
|
|
19
19
|
require_relative "../session_store"
|
|
20
|
+
require_relative "../session_trash"
|
|
20
21
|
require_relative "../steering"
|
|
21
22
|
require_relative "../tools/tool_call"
|
|
22
23
|
require_relative "../tools/registry"
|
|
23
24
|
require_relative "../transcript_export"
|
|
24
25
|
require_relative "../workspace"
|
|
25
26
|
require_relative "prompt_bridge"
|
|
27
|
+
require_relative "runtime_payloads"
|
|
26
28
|
require_relative "tool_event_normalizer"
|
|
27
29
|
require_relative "transcript_normalizer"
|
|
28
30
|
|
|
@@ -33,24 +35,31 @@ module Kward
|
|
|
33
35
|
RPC_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
|
|
34
36
|
RPC_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
|
|
35
37
|
STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
|
|
38
|
+
FOOTER_REFRESH_INTERVAL = 1.0
|
|
36
39
|
WORKER_STOP = Object.new.freeze
|
|
37
40
|
|
|
38
|
-
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, keyword_init: true)
|
|
39
|
-
Turn = Struct.new(:id, :session_id, :input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, keyword_init: true)
|
|
41
|
+
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
42
|
+
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, keyword_init: true)
|
|
40
43
|
|
|
41
|
-
def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new)
|
|
44
|
+
def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new, session_trash: SessionTrash.new)
|
|
42
45
|
@server = server
|
|
43
46
|
@client = client
|
|
44
47
|
@config_dir = config_dir
|
|
45
48
|
@context_usage = context_usage
|
|
49
|
+
@session_trash = session_trash
|
|
46
50
|
@sessions = {}
|
|
47
51
|
@turns = {}
|
|
48
52
|
@mutex = Mutex.new
|
|
49
53
|
end
|
|
50
54
|
|
|
51
|
-
def create_session(workspace_root: Dir.pwd, name: nil)
|
|
55
|
+
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
|
|
52
56
|
workspace_root = validate_workspace_root(workspace_root)
|
|
53
57
|
store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
|
|
58
|
+
if resume_last && session_auto_resume_enabled? && name.to_s.strip.empty?
|
|
59
|
+
path = store.remembered_last_session_path
|
|
60
|
+
return resume_session(path: path, workspace_root: workspace_root, include_transcript: true) if path
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
conversation = new_conversation(workspace_root: workspace_root)
|
|
55
64
|
session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
56
65
|
session.rename(name) unless name.to_s.strip.empty?
|
|
@@ -58,10 +67,11 @@ module Kward
|
|
|
58
67
|
rpc_session = build_rpc_session(store, session, conversation, workspace_root)
|
|
59
68
|
remember_session(rpc_session)
|
|
60
69
|
cleanup_other_unused_sessions(rpc_session)
|
|
70
|
+
emit_footer_update(rpc_session)
|
|
61
71
|
session_payload(rpc_session)
|
|
62
72
|
end
|
|
63
73
|
|
|
64
|
-
def resume_session(path:, workspace_root: nil)
|
|
74
|
+
def resume_session(path:, workspace_root: nil, include_transcript: false)
|
|
65
75
|
root = validate_workspace_root(workspace_root || Dir.pwd)
|
|
66
76
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
67
77
|
location = store.session_location(path)
|
|
@@ -69,23 +79,26 @@ module Kward
|
|
|
69
79
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
70
80
|
session, conversation = store.load(
|
|
71
81
|
location[:path],
|
|
72
|
-
workspace:
|
|
82
|
+
workspace: configured_workspace(root),
|
|
73
83
|
model: current_model_id,
|
|
74
84
|
reasoning_effort: current_reasoning_effort
|
|
75
85
|
)
|
|
76
86
|
rpc_session = build_rpc_session(store, session, conversation, root)
|
|
77
87
|
remember_session(rpc_session)
|
|
78
88
|
cleanup_other_unused_sessions(rpc_session)
|
|
79
|
-
|
|
89
|
+
emit_footer_update(rpc_session)
|
|
90
|
+
payload = session_payload(rpc_session)
|
|
91
|
+
payload[:messages] = TranscriptNormalizer.new(rpc_session.conversation.messages).normalize if include_transcript
|
|
92
|
+
payload[:resumed] = true
|
|
93
|
+
payload
|
|
80
94
|
end
|
|
81
95
|
|
|
82
|
-
def list_sessions(workspace_root: Dir.pwd, limit:
|
|
96
|
+
def list_sessions(workspace_root: Dir.pwd, limit: nil)
|
|
83
97
|
root = validate_workspace_root(workspace_root)
|
|
84
98
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.first(limit)
|
|
99
|
+
requested_limit = limit.to_i if limit
|
|
100
|
+
requested_limit = nil unless requested_limit&.positive?
|
|
101
|
+
store.recent(limit: requested_limit)
|
|
89
102
|
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
90
103
|
end
|
|
91
104
|
|
|
@@ -101,6 +114,7 @@ module Kward
|
|
|
101
114
|
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
|
|
102
115
|
remember_session(rpc_session)
|
|
103
116
|
cleanup_other_unused_sessions(rpc_session)
|
|
117
|
+
emit_footer_update(rpc_session)
|
|
104
118
|
session_payload(rpc_session)
|
|
105
119
|
end
|
|
106
120
|
|
|
@@ -124,39 +138,85 @@ module Kward
|
|
|
124
138
|
def fork_messages(session_id:)
|
|
125
139
|
rpc_session = fetch_session(session_id)
|
|
126
140
|
{
|
|
127
|
-
messages:
|
|
128
|
-
|
|
141
|
+
messages: tree_entries(rpc_session).filter_map do |record|
|
|
142
|
+
message = record["message"]
|
|
143
|
+
next unless message.is_a?(Hash) && message_role(message) == "user"
|
|
129
144
|
|
|
130
|
-
{ entryId:
|
|
145
|
+
{ entryId: record["id"], text: display_message_text(message) }
|
|
131
146
|
end
|
|
132
147
|
}
|
|
133
148
|
end
|
|
134
149
|
|
|
135
150
|
def fork_session(session_id:, entry_id:)
|
|
136
151
|
source = fetch_session(session_id)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
entries = tree_entries(source)
|
|
153
|
+
resolved_entry_id = resolve_tree_entry_id(entries, entry_id)
|
|
154
|
+
selected_index = entries.index { |record| record["id"].to_s == resolved_entry_id.to_s }
|
|
155
|
+
selected = selected_index && entries[selected_index]
|
|
156
|
+
raise ArgumentError, "Unknown fork entryId: #{entry_id}" unless selected
|
|
157
|
+
|
|
158
|
+
message = selected["message"]
|
|
159
|
+
raise ArgumentError, "Entry is not forkable: #{entry_id}" unless message.is_a?(Hash) && message_role(message) == "user"
|
|
141
160
|
|
|
142
161
|
session, conversation = source.store.create_independent_from_messages(
|
|
143
|
-
|
|
162
|
+
entries[0...selected_index].filter_map { |record| record["message"] },
|
|
144
163
|
model: source.conversation.model,
|
|
145
164
|
reasoning_effort: source.conversation.reasoning_effort,
|
|
146
165
|
parent_session: source.session
|
|
147
166
|
)
|
|
148
167
|
|
|
149
|
-
# ensure forked sessions retain the original persona context
|
|
150
168
|
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
|
|
151
169
|
remember_session(rpc_session)
|
|
152
170
|
cleanup_other_unused_sessions(rpc_session)
|
|
153
171
|
{
|
|
154
172
|
session: session_payload(rpc_session),
|
|
155
|
-
text: full_message_text(
|
|
173
|
+
text: full_message_text(message),
|
|
156
174
|
cancelled: false
|
|
157
175
|
}
|
|
158
176
|
end
|
|
159
177
|
|
|
178
|
+
def session_tree(session_id:)
|
|
179
|
+
rpc_session = fetch_session(session_id)
|
|
180
|
+
{ items: flatten_session_tree(rpc_session) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def set_tree_label(session_id:, entry_id:, label: nil)
|
|
184
|
+
rpc_session = fetch_session(session_id)
|
|
185
|
+
rpc_session.session.append_label_change(entry_id, label)
|
|
186
|
+
{ ok: true }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def navigate_tree(session_id:, entry_id:, summarize: false, custom_instructions: nil)
|
|
190
|
+
rpc_session = fetch_session(session_id)
|
|
191
|
+
entries = tree_entries(rpc_session)
|
|
192
|
+
resolved_entry_id = resolve_tree_entry_id(entries, entry_id)
|
|
193
|
+
entry = rpc_session.store.session_entry(rpc_session.session.path, resolved_entry_id)
|
|
194
|
+
raise ArgumentError, "Unknown tree entryId: #{entry_id}" unless entry
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless selectable_tree_entry?(entry)
|
|
197
|
+
|
|
198
|
+
message = entry["message"]
|
|
199
|
+
user_entry = user_tree_entry?(entry)
|
|
200
|
+
target_leaf = user_entry ? entry["parentId"] : entry["id"]
|
|
201
|
+
editor_text = user_entry ? full_message_text(message) : nil
|
|
202
|
+
previous_leaf = rpc_session.session.leaf_id
|
|
203
|
+
|
|
204
|
+
if summarize
|
|
205
|
+
summary = summarize_branch(rpc_session, from_id: previous_leaf, to_id: target_leaf, custom_instructions: custom_instructions)
|
|
206
|
+
target_leaf = rpc_session.session.append_branch_summary(target_leaf, from_id: previous_leaf, summary: summary, details: {})
|
|
207
|
+
else
|
|
208
|
+
target_leaf ? rpc_session.session.branch(target_leaf) : rpc_session.session.reset_leaf
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
reload_rpc_session(rpc_session)
|
|
212
|
+
{
|
|
213
|
+
session: session_payload(rpc_session),
|
|
214
|
+
editorText: editor_text,
|
|
215
|
+
cancelled: false,
|
|
216
|
+
aborted: false
|
|
217
|
+
}.compact
|
|
218
|
+
end
|
|
219
|
+
|
|
160
220
|
def export_session(session_id:, path: nil, format: nil)
|
|
161
221
|
rpc_session = fetch_session(session_id)
|
|
162
222
|
format = export_format(format)
|
|
@@ -169,9 +229,9 @@ module Kward
|
|
|
169
229
|
def delete_session(session_id:)
|
|
170
230
|
rpc_session = fetch_session(session_id)
|
|
171
231
|
path = rpc_session.session.path
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
{ deleted:
|
|
232
|
+
close_rpc_session(rpc_session, delete_unused: false)
|
|
233
|
+
deleted = @session_trash.delete(path)
|
|
234
|
+
{ deleted: deleted, path: path }
|
|
175
235
|
end
|
|
176
236
|
|
|
177
237
|
def close_session(session_id:)
|
|
@@ -199,6 +259,7 @@ module Kward
|
|
|
199
259
|
rpc_session = fetch_session(session_id)
|
|
200
260
|
normalized_attachments = normalize_attachments(attachments)
|
|
201
261
|
plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
|
|
262
|
+
display_input = input.to_s if input.is_a?(String)
|
|
202
263
|
content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
|
|
203
264
|
streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
|
|
204
265
|
if streaming_behavior == "steer"
|
|
@@ -211,6 +272,7 @@ module Kward
|
|
|
211
272
|
id: SecureRandom.uuid,
|
|
212
273
|
session_id: rpc_session.id,
|
|
213
274
|
input: content,
|
|
275
|
+
display_input: display_input,
|
|
214
276
|
status: "queued",
|
|
215
277
|
cancel_requested: false,
|
|
216
278
|
cancellation: Cancellation.new,
|
|
@@ -260,7 +322,6 @@ module Kward
|
|
|
260
322
|
|
|
261
323
|
def run_command(session_id:, command:, arguments: "")
|
|
262
324
|
name = command.to_s.delete_prefix("/")
|
|
263
|
-
return { ok: false, error: "unsupported", reason: "notImplemented" } if name == "crew"
|
|
264
325
|
return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
|
|
265
326
|
|
|
266
327
|
run_plugin_command(session_id: session_id, command: name, arguments: arguments)
|
|
@@ -295,8 +356,8 @@ module Kward
|
|
|
295
356
|
{ autoSummary: false }
|
|
296
357
|
end
|
|
297
358
|
|
|
298
|
-
def memory_list(include_inactive: false)
|
|
299
|
-
memory_manager.
|
|
359
|
+
def memory_list(include_inactive: false, workspace_root: Dir.pwd)
|
|
360
|
+
memory_manager.hierarchy(include_inactive: include_inactive, workspace_root: workspace_root)
|
|
300
361
|
end
|
|
301
362
|
|
|
302
363
|
def memory_add(text:, scope: nil, tags: [])
|
|
@@ -312,7 +373,11 @@ module Kward
|
|
|
312
373
|
end
|
|
313
374
|
|
|
314
375
|
def memory_promote(id:)
|
|
315
|
-
{ memory: memory_manager.
|
|
376
|
+
{ memory: memory_manager.promote_memory(id) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def memory_relax(id:, workspace_root: Dir.pwd)
|
|
380
|
+
{ memory: memory_manager.relax_core(id, workspace_root: workspace_root) }
|
|
316
381
|
end
|
|
317
382
|
|
|
318
383
|
def memory_inspect
|
|
@@ -387,35 +452,18 @@ module Kward
|
|
|
387
452
|
compaction_settings: compaction_settings
|
|
388
453
|
)
|
|
389
454
|
session = session_payload(rpc_session)
|
|
390
|
-
|
|
391
|
-
|
|
455
|
+
RuntimePayloads.state(
|
|
456
|
+
session: session,
|
|
392
457
|
model: model,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
sessionName: session[:name],
|
|
403
|
-
autoCompactionEnabled: compaction_settings.enabled,
|
|
404
|
-
autoCompactionReserveTokens: auto_compaction_reserve_tokens,
|
|
405
|
-
autoRetryEnabled: false,
|
|
406
|
-
defaultProvider: model[:provider],
|
|
407
|
-
defaultModel: default_model_label(model),
|
|
408
|
-
defaultThinkingLevel: model[:reasoningEffort],
|
|
409
|
-
hideThinkingBlock: false,
|
|
410
|
-
quietStartup: false,
|
|
411
|
-
transport: "kward-rpc",
|
|
412
|
-
imageAutoResize: false,
|
|
413
|
-
blockImages: false,
|
|
414
|
-
enabledModels: [],
|
|
415
|
-
enableSkillCommands: true,
|
|
416
|
-
messageCount: message_count(rpc_session.conversation),
|
|
417
|
-
pendingMessageCount: pending_count
|
|
418
|
-
}.compact
|
|
458
|
+
streaming: streaming?(rpc_session),
|
|
459
|
+
steering_supported: supports_in_flight_steer?,
|
|
460
|
+
auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
|
|
461
|
+
active_persona_label: active_persona_label(rpc_session),
|
|
462
|
+
message_count: message_count(rpc_session.conversation),
|
|
463
|
+
pending_count: pending_turn_count(rpc_session.id),
|
|
464
|
+
compaction_enabled: compaction_settings.enabled,
|
|
465
|
+
workspace_guardrails_enabled: workspace_guardrails_enabled?
|
|
466
|
+
)
|
|
419
467
|
end
|
|
420
468
|
|
|
421
469
|
def runtime_stats(session_id:)
|
|
@@ -428,22 +476,14 @@ module Kward
|
|
|
428
476
|
context_window: model[:contextWindow],
|
|
429
477
|
compaction_settings: compaction_settings
|
|
430
478
|
)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
toolCalls: counts[:toolCalls],
|
|
440
|
-
toolResults: counts[:toolResults],
|
|
441
|
-
totalMessages: counts[:totalMessages],
|
|
442
|
-
usingSubscription: model[:provider] == "Codex",
|
|
443
|
-
autoCompactionEnabled: compaction_settings.enabled,
|
|
444
|
-
autoCompactionReserveTokens: auto_compaction_reserve_tokens,
|
|
445
|
-
contextUsage: context_usage(rpc_session, model)
|
|
446
|
-
}.compact
|
|
479
|
+
RuntimePayloads.stats(
|
|
480
|
+
session: session,
|
|
481
|
+
counts: counts,
|
|
482
|
+
model: model,
|
|
483
|
+
auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
|
|
484
|
+
context_usage: context_usage(rpc_session, model),
|
|
485
|
+
compaction_enabled: compaction_settings.enabled
|
|
486
|
+
)
|
|
447
487
|
end
|
|
448
488
|
|
|
449
489
|
def refresh_client_config
|
|
@@ -452,19 +492,25 @@ module Kward
|
|
|
452
492
|
refresh_session_tool_registries
|
|
453
493
|
end
|
|
454
494
|
|
|
495
|
+
def reload_plugins
|
|
496
|
+
registry = PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
497
|
+
sessions = @mutex.synchronize do
|
|
498
|
+
@plugin_registry = registry
|
|
499
|
+
@sessions.values
|
|
500
|
+
end
|
|
501
|
+
sessions.each do |rpc_session|
|
|
502
|
+
rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
|
|
503
|
+
rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
|
|
504
|
+
emit_footer_update(rpc_session)
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
455
508
|
def session_payload(rpc_session)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
cwd: rpc_session.session.cwd.to_s.empty? ? rpc_session.workspace_root : rpc_session.session.cwd,
|
|
462
|
-
name: rpc_session.session.name,
|
|
463
|
-
createdAt: rpc_session.session.created_at&.utc&.iso8601(3),
|
|
464
|
-
modifiedAt: session_modified_at(rpc_session.session)&.utc&.iso8601(3),
|
|
465
|
-
parentId: rpc_session.session.parent_id,
|
|
466
|
-
parentPath: rpc_session.session.parent_path
|
|
467
|
-
}
|
|
509
|
+
RuntimePayloads.session(
|
|
510
|
+
rpc_session,
|
|
511
|
+
modified_at: session_modified_at(rpc_session.session),
|
|
512
|
+
active_persona_label: active_persona_label(rpc_session)
|
|
513
|
+
)
|
|
468
514
|
end
|
|
469
515
|
|
|
470
516
|
def session_modified_at(session)
|
|
@@ -515,8 +561,7 @@ module Kward
|
|
|
515
561
|
end
|
|
516
562
|
|
|
517
563
|
def compaction_settings
|
|
518
|
-
|
|
519
|
-
Kward::Compaction::Settings.from_config(ConfigFiles.read_config(path))
|
|
564
|
+
Kward::Compaction::Settings.from_config(ConfigFiles.read_config(config_path))
|
|
520
565
|
rescue StandardError
|
|
521
566
|
Kward::Compaction::Settings.new
|
|
522
567
|
end
|
|
@@ -548,10 +593,11 @@ module Kward
|
|
|
548
593
|
)
|
|
549
594
|
end
|
|
550
595
|
|
|
551
|
-
def
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
596
|
+
def active_persona_label(rpc_session)
|
|
597
|
+
ConfigFiles.active_persona_label(
|
|
598
|
+
workspace_root: rpc_session.workspace_root,
|
|
599
|
+
model: rpc_session.conversation.model
|
|
600
|
+
) || "Assistant"
|
|
555
601
|
end
|
|
556
602
|
|
|
557
603
|
def streaming?(rpc_session)
|
|
@@ -575,17 +621,6 @@ module Kward
|
|
|
575
621
|
@mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
|
|
576
622
|
end
|
|
577
623
|
|
|
578
|
-
def active_empty_unnamed_session_info?(info, workspace_root)
|
|
579
|
-
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
580
|
-
rpc_sessions.any? do |rpc_session|
|
|
581
|
-
rpc_session.workspace_root == workspace_root &&
|
|
582
|
-
File.expand_path(rpc_session.session.path) == File.expand_path(info.path) &&
|
|
583
|
-
session_idle?(rpc_session) &&
|
|
584
|
-
info.name.to_s.strip.empty? &&
|
|
585
|
-
info.message_count.to_i.zero?
|
|
586
|
-
end
|
|
587
|
-
end
|
|
588
|
-
|
|
589
624
|
def message_count(conversation)
|
|
590
625
|
conversation.messages.count { |message| message_role(message) != "system" }
|
|
591
626
|
end
|
|
@@ -634,23 +669,293 @@ module Kward
|
|
|
634
669
|
MessageAccess.content(message)
|
|
635
670
|
end
|
|
636
671
|
|
|
637
|
-
def
|
|
638
|
-
|
|
672
|
+
def tree_entries(rpc_session)
|
|
673
|
+
rpc_session.store.session_entries(rpc_session.session.path)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def resolve_tree_entry_id(entries, entry_id)
|
|
677
|
+
id = entry_id.to_s
|
|
678
|
+
return id if entries.any? { |record| record["id"].to_s == id }
|
|
679
|
+
|
|
680
|
+
match = id.match(/\Amessage:(\d+)\z/)
|
|
681
|
+
return entries[match[1].to_i]&.dig("id") if match
|
|
682
|
+
|
|
683
|
+
id
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def reload_rpc_session(rpc_session)
|
|
687
|
+
session, conversation = rpc_session.store.load(
|
|
688
|
+
rpc_session.session.path,
|
|
689
|
+
workspace: configured_workspace(rpc_session.workspace_root),
|
|
690
|
+
model: current_model_id,
|
|
691
|
+
reasoning_effort: current_reasoning_effort
|
|
692
|
+
)
|
|
693
|
+
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
694
|
+
rpc_session.session = session
|
|
695
|
+
rpc_session.conversation = conversation
|
|
696
|
+
rebuild_session_tools(rpc_session)
|
|
697
|
+
emit_footer_update(rpc_session)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def flatten_session_tree(rpc_session)
|
|
701
|
+
roots = rpc_session.store.session_tree(rpc_session.session.path)
|
|
702
|
+
current_leaf = rpc_session.session.leaf_id || rpc_session.store.current_leaf(rpc_session.session.path)
|
|
703
|
+
active_path = tree_active_path(roots, current_leaf)
|
|
704
|
+
tool_calls_by_id = tree_tool_calls(roots)
|
|
705
|
+
visible_roots = roots.flat_map { |root| visible_tree_nodes(root, current_leaf) }
|
|
706
|
+
multiple_roots = visible_roots.length > 1
|
|
707
|
+
result = []
|
|
708
|
+
|
|
709
|
+
walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
|
|
710
|
+
entry = node[:source]["entry"] || {}
|
|
711
|
+
entry_id = entry["id"].to_s
|
|
712
|
+
formatted = tree_entry_display(entry, tool_calls_by_id)
|
|
713
|
+
display_indent = multiple_roots ? [indent - 1, 0].max : indent
|
|
714
|
+
result << {
|
|
715
|
+
entryId: entry_id,
|
|
716
|
+
parentId: entry["parentId"],
|
|
717
|
+
role: formatted[:role],
|
|
718
|
+
text: formatted[:text],
|
|
719
|
+
current: !current_leaf.to_s.empty? && entry_id == current_leaf.to_s,
|
|
720
|
+
depth: display_indent,
|
|
721
|
+
isLast: is_last,
|
|
722
|
+
ancestorContinues: gutters.map { |gutter| gutter[:show] },
|
|
723
|
+
activePath: active_path.include?(entry_id),
|
|
724
|
+
selectable: selectable_tree_entry?(entry),
|
|
725
|
+
label: node[:source]["label"] || entry["resolvedLabel"],
|
|
726
|
+
labelTimestamp: node[:source]["labelTimestamp"],
|
|
727
|
+
prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
|
|
728
|
+
}.compact
|
|
729
|
+
|
|
730
|
+
children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
|
|
731
|
+
multiple_children = children.length > 1
|
|
732
|
+
child_indent = if multiple_children
|
|
733
|
+
indent + 1
|
|
734
|
+
elsif just_branched && indent.positive?
|
|
735
|
+
indent + 1
|
|
736
|
+
else
|
|
737
|
+
indent
|
|
738
|
+
end
|
|
739
|
+
connector_position = [display_indent - 1, 0].max
|
|
740
|
+
child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
|
|
741
|
+
children.each_with_index do |child, index|
|
|
742
|
+
walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
|
|
747
|
+
walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
|
|
748
|
+
end
|
|
749
|
+
result
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def user_tree_entry?(entry)
|
|
753
|
+
message = entry["message"]
|
|
754
|
+
message.is_a?(Hash) && message_role(message) == "user"
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def selectable_tree_entry?(entry)
|
|
758
|
+
!entry["id"].to_s.empty? && ["message", "compaction", "branch_summary"].include?(entry["type"])
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def nearest_visible_parent_by_id(user_entries, entries)
|
|
762
|
+
user_ids = user_entries.map { |entry| entry["id"].to_s }.to_h { |id| [id, true] }
|
|
763
|
+
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
764
|
+
user_entries.each_with_object({}) do |entry, parents|
|
|
765
|
+
parent_id = entry["parentId"]
|
|
766
|
+
while parent_id && by_id[parent_id.to_s] && !user_ids[parent_id.to_s]
|
|
767
|
+
parent_id = by_id[parent_id.to_s]["parentId"]
|
|
768
|
+
end
|
|
769
|
+
parents[entry["id"].to_s] = user_ids[parent_id.to_s] ? parent_id.to_s : nil
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def active_path_ids(entries, leaf_id)
|
|
774
|
+
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
775
|
+
ids = []
|
|
776
|
+
current = by_id[leaf_id.to_s]
|
|
777
|
+
while current
|
|
778
|
+
ids << current["id"].to_s
|
|
779
|
+
current = by_id[current["parentId"].to_s]
|
|
780
|
+
end
|
|
781
|
+
ids
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def tree_active_path(roots, leaf_id)
|
|
785
|
+
by_id = tree_entries_by_id(roots)
|
|
786
|
+
ids = []
|
|
787
|
+
current = by_id[leaf_id.to_s]
|
|
788
|
+
while current
|
|
789
|
+
ids << current["id"].to_s
|
|
790
|
+
current = by_id[current["parentId"].to_s]
|
|
791
|
+
end
|
|
792
|
+
ids
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def tree_entries_by_id(roots)
|
|
796
|
+
roots.each_with_object({}) do |root, map|
|
|
797
|
+
stack = [root]
|
|
798
|
+
until stack.empty?
|
|
799
|
+
node = stack.pop
|
|
800
|
+
entry = node["entry"] || {}
|
|
801
|
+
map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
|
|
802
|
+
stack.concat(Array(node["children"]))
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def visible_tree_nodes(node, current_leaf)
|
|
808
|
+
children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child, current_leaf) }
|
|
809
|
+
return children if hidden_tree_entry?(node["entry"] || {}, current_leaf)
|
|
810
|
+
|
|
811
|
+
[{ source: node, children: children }]
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def hidden_tree_entry?(entry, current_leaf)
|
|
815
|
+
return false if current_leaf && entry["id"].to_s == current_leaf.to_s
|
|
816
|
+
return false unless entry["type"] == "message"
|
|
817
|
+
|
|
818
|
+
message = entry["message"]
|
|
819
|
+
return false unless message.is_a?(Hash) && message_role(message) == "assistant"
|
|
820
|
+
|
|
821
|
+
content = message_content(message)
|
|
822
|
+
content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
|
|
823
|
+
(content_tool_calls && !tree_text_content?(content)) || (!tool_calls(message).empty? && full_message_text(message).empty?)
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def tree_text_content?(content)
|
|
827
|
+
Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
|
|
639
828
|
end
|
|
640
829
|
|
|
641
|
-
def
|
|
642
|
-
"
|
|
830
|
+
def tree_contains_active_path?(node, active_path)
|
|
831
|
+
entry_id = (node[:source]["entry"] || {})["id"].to_s
|
|
832
|
+
active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
def tree_tool_calls(roots)
|
|
836
|
+
roots.each_with_object({}) do |root, tool_calls_by_id|
|
|
837
|
+
stack = [root]
|
|
838
|
+
until stack.empty?
|
|
839
|
+
node = stack.pop
|
|
840
|
+
entry = node["entry"] || {}
|
|
841
|
+
message = entry["message"]
|
|
842
|
+
if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
|
|
843
|
+
tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
|
|
844
|
+
end
|
|
845
|
+
stack.concat(Array(node["children"]))
|
|
846
|
+
end
|
|
847
|
+
end
|
|
643
848
|
end
|
|
644
849
|
|
|
645
|
-
def
|
|
646
|
-
|
|
647
|
-
|
|
850
|
+
def tree_entry_display(entry, tool_calls_by_id = {})
|
|
851
|
+
case entry["type"]
|
|
852
|
+
when "message"
|
|
853
|
+
message = entry["message"] || {}
|
|
854
|
+
role = message_role(message).to_s
|
|
855
|
+
return { role: "tool", text: format_tool_result(message, tool_calls_by_id) } if ["tool", "toolResult"].include?(role)
|
|
856
|
+
return { role: role.empty? ? "message" : role, text: display_message_text(message) }
|
|
857
|
+
when "compaction"
|
|
858
|
+
return { role: "compaction", text: display_message_text(entry["message"] || {}) }
|
|
859
|
+
when "branch_summary"
|
|
860
|
+
return { role: "summary", text: truncate_tree_text(entry["summary"]) }
|
|
861
|
+
end
|
|
648
862
|
|
|
649
|
-
|
|
863
|
+
{ role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
|
|
867
|
+
return "" if display_indent.to_i <= 0
|
|
868
|
+
|
|
869
|
+
connector_position = show_connector ? display_indent - 1 : -1
|
|
870
|
+
(0...(display_indent * 3)).map do |index|
|
|
871
|
+
level = index / 3
|
|
872
|
+
position = index % 3
|
|
873
|
+
gutter = gutters.find { |candidate| candidate[:position] == level }
|
|
874
|
+
|
|
875
|
+
if gutter
|
|
876
|
+
position.zero? && gutter[:show] ? "│" : " "
|
|
877
|
+
elsif show_connector && level == connector_position
|
|
878
|
+
if position.zero?
|
|
879
|
+
is_last ? "└" : "├"
|
|
880
|
+
elsif position == 1
|
|
881
|
+
foldable ? "⊟" : "─"
|
|
882
|
+
else
|
|
883
|
+
" "
|
|
884
|
+
end
|
|
885
|
+
else
|
|
886
|
+
" "
|
|
887
|
+
end
|
|
888
|
+
end.join
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def format_tool_result(message, tool_calls_by_id)
|
|
892
|
+
tool_call = tool_calls_by_id[tree_message_tool_call_id(message).to_s]
|
|
893
|
+
return format_tool_call(tool_call) if tool_call
|
|
894
|
+
|
|
895
|
+
name = tree_message_tool_name(message).to_s
|
|
896
|
+
name = "tool" if name.empty?
|
|
897
|
+
"[#{name}]"
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def tree_message_tool_call_id(message)
|
|
901
|
+
MessageAccess.tool_call_id(message) || ToolCall.value(message, :toolCallId)
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def tree_message_tool_name(message)
|
|
905
|
+
MessageAccess.name(message) || ToolCall.value(message, :toolName)
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def format_tool_call(tool_call)
|
|
909
|
+
name = ToolCall.display_name(tool_call)
|
|
910
|
+
args = ToolCall.arguments(tool_call)
|
|
911
|
+
case name
|
|
912
|
+
when "read"
|
|
913
|
+
path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
|
|
914
|
+
offset = args["offset"] || args[:offset]
|
|
915
|
+
limit = args["limit"] || args[:limit]
|
|
916
|
+
display = path.to_s
|
|
917
|
+
if offset || limit
|
|
918
|
+
start_line = offset || 1
|
|
919
|
+
end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
|
|
920
|
+
display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
|
|
921
|
+
end
|
|
922
|
+
"[read: #{display}]"
|
|
923
|
+
when "write", "edit"
|
|
924
|
+
path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
|
|
925
|
+
"[#{name}: #{path}]"
|
|
926
|
+
when "bash"
|
|
927
|
+
command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
|
|
928
|
+
"[bash: #{command.length > 50 ? "#{command.slice(0, 50)}..." : command}]"
|
|
929
|
+
else
|
|
930
|
+
serialized = JSON.dump(args)
|
|
931
|
+
"[#{name}: #{serialized.length > 40 ? "#{serialized.slice(0, 40)}..." : serialized}]"
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def summarize_branch(rpc_session, from_id:, to_id:, custom_instructions: nil)
|
|
936
|
+
entries = tree_entries(rpc_session)
|
|
937
|
+
active = active_path_ids(entries, from_id)
|
|
938
|
+
target = active_path_ids(entries, to_id)
|
|
939
|
+
target_lookup = target.to_h { |id| [id, true] }
|
|
940
|
+
abandoned = active.reject { |id| target_lookup[id] }
|
|
941
|
+
messages = entries.select { |entry| abandoned.include?(entry["id"].to_s) }.filter_map { |entry| entry["message"] }
|
|
942
|
+
source_text = messages.map { |message| "#{message_role(message)}: #{full_message_text(message)}" }.join("\n\n")
|
|
943
|
+
prompt = [
|
|
944
|
+
{ role: "system", content: "Summarize the abandoned conversation branch concisely for future context." },
|
|
945
|
+
{ role: "user", content: [custom_instructions.to_s.strip, source_text].reject(&:empty?).join("\n\n") }
|
|
946
|
+
]
|
|
947
|
+
response = @client.chat(prompt, tools: [])
|
|
948
|
+
text = full_message_text(response)
|
|
949
|
+
text.empty? ? "Branch summary unavailable." : text
|
|
650
950
|
end
|
|
651
951
|
|
|
652
952
|
def display_message_text(message)
|
|
653
|
-
full_message_text(message)
|
|
953
|
+
truncate_tree_text(full_message_text(message))
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def truncate_tree_text(text)
|
|
957
|
+
normalized = text.to_s.gsub(/\s+/, " ").strip
|
|
958
|
+
normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
|
|
654
959
|
end
|
|
655
960
|
|
|
656
961
|
def full_message_text(message)
|
|
@@ -716,23 +1021,23 @@ module Kward
|
|
|
716
1021
|
def normalize_attachment(attachment)
|
|
717
1022
|
raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
|
|
718
1023
|
|
|
719
|
-
type = value(attachment, :type).to_s
|
|
1024
|
+
type = ToolCall.value(attachment, :type).to_s
|
|
720
1025
|
raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
|
|
721
1026
|
|
|
722
|
-
mime_type = normalize_attachment_mime_type(value(attachment, :mimeType) || value(attachment, :mime_type) || value(attachment, :media_type))
|
|
1027
|
+
mime_type = normalize_attachment_mime_type(ToolCall.value(attachment, :mimeType) || ToolCall.value(attachment, :mime_type) || ToolCall.value(attachment, :media_type))
|
|
723
1028
|
raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
|
|
724
1029
|
|
|
725
|
-
data = value(attachment, :data).to_s
|
|
1030
|
+
data = ToolCall.value(attachment, :data).to_s
|
|
726
1031
|
raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
|
|
727
1032
|
raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
|
|
728
|
-
declared_size = value(attachment, :sizeBytes) || value(attachment, :size_bytes)
|
|
1033
|
+
declared_size = ToolCall.value(attachment, :sizeBytes) || ToolCall.value(attachment, :size_bytes)
|
|
729
1034
|
raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
|
|
730
1035
|
|
|
731
1036
|
decoded_size = Base64.strict_decode64(data).bytesize
|
|
732
1037
|
raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
|
|
733
1038
|
|
|
734
1039
|
result = { type: "image", data: data, mimeType: mime_type }
|
|
735
|
-
name = value(attachment, :name)
|
|
1040
|
+
name = ToolCall.value(attachment, :name)
|
|
736
1041
|
result[:alt] = name.to_s unless name.to_s.empty?
|
|
737
1042
|
result
|
|
738
1043
|
rescue ArgumentError => e
|
|
@@ -745,14 +1050,6 @@ module Kward
|
|
|
745
1050
|
mime_type.to_s.downcase
|
|
746
1051
|
end
|
|
747
1052
|
|
|
748
|
-
def value(object, key)
|
|
749
|
-
return nil unless object.respond_to?(:key?)
|
|
750
|
-
return object[key] if object.key?(key)
|
|
751
|
-
return object[key.to_s] if object.key?(key.to_s)
|
|
752
|
-
|
|
753
|
-
nil
|
|
754
|
-
end
|
|
755
|
-
|
|
756
1053
|
def plugin_registry
|
|
757
1054
|
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
758
1055
|
end
|
|
@@ -788,11 +1085,29 @@ module Kward
|
|
|
788
1085
|
end
|
|
789
1086
|
|
|
790
1087
|
def build_tool_registry(workspace_root, prompt)
|
|
791
|
-
ToolRegistry.new(workspace:
|
|
1088
|
+
ToolRegistry.new(workspace: configured_workspace(workspace_root), prompt: prompt)
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
def configured_workspace(root)
|
|
1092
|
+
Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def workspace_guardrails_enabled?
|
|
1096
|
+
ConfigFiles.workspace_guardrails_enabled?(ConfigFiles.read_config(config_path))
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def session_auto_resume_enabled?
|
|
1100
|
+
ConfigFiles.session_auto_resume_enabled?(ConfigFiles.read_config(config_path))
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def config_path
|
|
1104
|
+
File.join(@config_dir, "config.json")
|
|
792
1105
|
end
|
|
793
1106
|
|
|
794
1107
|
def remember_session(rpc_session)
|
|
795
1108
|
@mutex.synchronize { @sessions[rpc_session.id] = rpc_session }
|
|
1109
|
+
rpc_session.store.remember_last_session(rpc_session.session) if rpc_session.store.respond_to?(:remember_last_session)
|
|
1110
|
+
start_footer_worker(rpc_session)
|
|
796
1111
|
end
|
|
797
1112
|
|
|
798
1113
|
def fetch_session(session_id)
|
|
@@ -809,10 +1124,11 @@ module Kward
|
|
|
809
1124
|
rpc_session.worker = Thread.new { worker_loop(rpc_session) }
|
|
810
1125
|
end
|
|
811
1126
|
|
|
812
|
-
def close_rpc_session(rpc_session)
|
|
1127
|
+
def close_rpc_session(rpc_session, delete_unused: true)
|
|
813
1128
|
@mutex.synchronize { @sessions.delete(rpc_session.id) }
|
|
814
1129
|
stop_worker(rpc_session)
|
|
815
|
-
rpc_session
|
|
1130
|
+
stop_footer_worker(rpc_session)
|
|
1131
|
+
rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
|
|
816
1132
|
end
|
|
817
1133
|
|
|
818
1134
|
def cleanup_other_unused_sessions(current_session)
|
|
@@ -826,6 +1142,7 @@ module Kward
|
|
|
826
1142
|
|
|
827
1143
|
@mutex.synchronize { @sessions.delete(rpc_session.id) }
|
|
828
1144
|
stop_worker(rpc_session)
|
|
1145
|
+
stop_footer_worker(rpc_session)
|
|
829
1146
|
end
|
|
830
1147
|
end
|
|
831
1148
|
|
|
@@ -837,6 +1154,34 @@ module Kward
|
|
|
837
1154
|
rpc_session.queue << WORKER_STOP
|
|
838
1155
|
end
|
|
839
1156
|
|
|
1157
|
+
def start_footer_worker(rpc_session)
|
|
1158
|
+
return unless plugin_registry.footer_renderer
|
|
1159
|
+
return if rpc_session.footer_worker&.alive?
|
|
1160
|
+
|
|
1161
|
+
rpc_session.footer_worker = Thread.new do
|
|
1162
|
+
loop do
|
|
1163
|
+
sleep FOOTER_REFRESH_INTERVAL
|
|
1164
|
+
break unless @mutex.synchronize { @sessions[rpc_session.id] == rpc_session }
|
|
1165
|
+
|
|
1166
|
+
emit_footer_update(rpc_session)
|
|
1167
|
+
end
|
|
1168
|
+
rescue StandardError => e
|
|
1169
|
+
@server.log_error(e)
|
|
1170
|
+
ensure
|
|
1171
|
+
rpc_session.footer_worker = nil if rpc_session.footer_worker == Thread.current
|
|
1172
|
+
end
|
|
1173
|
+
end
|
|
1174
|
+
|
|
1175
|
+
def stop_footer_worker(rpc_session)
|
|
1176
|
+
worker = rpc_session.footer_worker
|
|
1177
|
+
rpc_session.footer_worker = nil
|
|
1178
|
+
return unless worker&.alive?
|
|
1179
|
+
return if worker == Thread.current
|
|
1180
|
+
|
|
1181
|
+
worker.kill
|
|
1182
|
+
worker.join(0.1)
|
|
1183
|
+
end
|
|
1184
|
+
|
|
840
1185
|
def worker_loop(rpc_session)
|
|
841
1186
|
loop do
|
|
842
1187
|
turn_id = rpc_session.queue.pop
|
|
@@ -870,8 +1215,9 @@ module Kward
|
|
|
870
1215
|
if turn.plugin_command_name
|
|
871
1216
|
run_plugin_turn(rpc_session, turn)
|
|
872
1217
|
else
|
|
1218
|
+
auto_name_session(rpc_session, turn.display_input || turn.input)
|
|
873
1219
|
prepare_memory_context(rpc_session.conversation, turn.input)
|
|
874
|
-
rpc_session.agent.ask(turn.input, cancellation: turn.cancellation, steering: turn.steering) do |event|
|
|
1220
|
+
rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering) do |event|
|
|
875
1221
|
next if turn.cancel_requested
|
|
876
1222
|
|
|
877
1223
|
notify_plugin_transcript_event(rpc_session, event)
|
|
@@ -895,6 +1241,24 @@ module Kward
|
|
|
895
1241
|
Steering.new
|
|
896
1242
|
end
|
|
897
1243
|
|
|
1244
|
+
def auto_name_session(rpc_session, input)
|
|
1245
|
+
return unless rpc_session.session.name.to_s.strip.empty?
|
|
1246
|
+
|
|
1247
|
+
name = default_session_name(input)
|
|
1248
|
+
rpc_session.session.rename(name) unless name.empty?
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
def default_session_name(input)
|
|
1252
|
+
input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def turn_display_input(turn)
|
|
1256
|
+
return nil if turn.display_input.nil?
|
|
1257
|
+
return nil if turn.display_input == turn.input
|
|
1258
|
+
|
|
1259
|
+
turn.display_input
|
|
1260
|
+
end
|
|
1261
|
+
|
|
898
1262
|
def prepare_memory_context(conversation, input)
|
|
899
1263
|
manager = memory_manager
|
|
900
1264
|
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
@@ -994,6 +1358,30 @@ module Kward
|
|
|
994
1358
|
turn.status = status
|
|
995
1359
|
turn.finished_at = now
|
|
996
1360
|
emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
|
|
1361
|
+
rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
|
|
1362
|
+
emit_footer_update(rpc_session) if rpc_session
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
def emit_footer_update(rpc_session)
|
|
1366
|
+
renderer = plugin_registry.footer_renderer
|
|
1367
|
+
return unless renderer
|
|
1368
|
+
|
|
1369
|
+
text = begin
|
|
1370
|
+
context = PluginRegistry::Context.new(
|
|
1371
|
+
conversation: rpc_session.conversation,
|
|
1372
|
+
session: rpc_session.session,
|
|
1373
|
+
workspace_root: rpc_session.workspace_root,
|
|
1374
|
+
say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
|
|
1375
|
+
)
|
|
1376
|
+
renderer.call(context).to_s.gsub(/\s+/, " ").strip
|
|
1377
|
+
rescue StandardError => e
|
|
1378
|
+
warn "Warning: Kward plugin footer error: #{e.message}"
|
|
1379
|
+
""
|
|
1380
|
+
end
|
|
1381
|
+
return if rpc_session.last_footer_text == text
|
|
1382
|
+
|
|
1383
|
+
rpc_session.last_footer_text = text
|
|
1384
|
+
@server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
|
|
997
1385
|
end
|
|
998
1386
|
|
|
999
1387
|
def emit_turn_event(turn, type, payload)
|
|
@@ -1069,11 +1457,11 @@ module Kward
|
|
|
1069
1457
|
end
|
|
1070
1458
|
|
|
1071
1459
|
def normalized_tool_event_payload(tool_call)
|
|
1072
|
-
ToolEventNormalizer.new(tool_call).call_payload
|
|
1460
|
+
ToolEventNormalizer.new(tool_call).call_payload
|
|
1073
1461
|
end
|
|
1074
1462
|
|
|
1075
1463
|
def normalized_tool_result_event_payload(tool_call, content)
|
|
1076
|
-
ToolEventNormalizer.new(tool_call, content: content).result_payload
|
|
1464
|
+
ToolEventNormalizer.new(tool_call, content: content).result_payload
|
|
1077
1465
|
end
|
|
1078
1466
|
|
|
1079
1467
|
def turn_error_payload(error)
|
|
@@ -1084,35 +1472,6 @@ module Kward
|
|
|
1084
1472
|
}
|
|
1085
1473
|
end
|
|
1086
1474
|
|
|
1087
|
-
def tool_metadata(tool_call)
|
|
1088
|
-
name = ToolCall.name(tool_call)
|
|
1089
|
-
args = ToolCall.arguments(tool_call)
|
|
1090
|
-
|
|
1091
|
-
case name
|
|
1092
|
-
when "edit_file"
|
|
1093
|
-
edits = Array(args["edits"] || args[:edits]).map do |edit|
|
|
1094
|
-
{
|
|
1095
|
-
oldText: edit["old_text"] || edit[:old_text],
|
|
1096
|
-
newText: edit["new_text"] || edit[:new_text]
|
|
1097
|
-
}.compact
|
|
1098
|
-
end
|
|
1099
|
-
first_edit = edits.first || {}
|
|
1100
|
-
{
|
|
1101
|
-
kind: "edit",
|
|
1102
|
-
path: args["path"] || args[:path],
|
|
1103
|
-
edits: edits,
|
|
1104
|
-
oldText: first_edit[:oldText],
|
|
1105
|
-
newText: first_edit[:newText]
|
|
1106
|
-
}.compact
|
|
1107
|
-
when "write_file"
|
|
1108
|
-
{ kind: "write", path: args["path"] || args[:path] }.compact
|
|
1109
|
-
when "run_shell_command"
|
|
1110
|
-
{ kind: "shell", command: args["command"] || args[:command] }.compact
|
|
1111
|
-
else
|
|
1112
|
-
nil
|
|
1113
|
-
end
|
|
1114
|
-
end
|
|
1115
|
-
|
|
1116
1475
|
|
|
1117
1476
|
def now
|
|
1118
1477
|
Time.now.utc.iso8601(3)
|