kward 0.76.0 → 0.78.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/.github/workflows/ci.yml +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +13 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/code-search.md +9 -6
- data/doc/composer.md +219 -0
- data/doc/configuration.md +114 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -22
- data/doc/security.md +184 -0
- data/doc/tabs.md +4 -3
- data/doc/usage.md +15 -32
- data/doc/web-search.md +4 -2
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +24 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +11 -35
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +154 -36
- data/lib/kward/cli.rb +30 -26
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +28 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +71 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +20 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +48 -41
- data/lib/kward/rpc/session_manager.rb +76 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +18 -5
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +45 -17
- data/lib/kward/tools/search/code.rb +47 -13
- data/lib/kward/tools/search/web.rb +61 -18
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +11 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
|
@@ -62,10 +62,11 @@ module Kward
|
|
|
62
62
|
RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
|
|
63
63
|
STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
|
|
64
64
|
FOOTER_REFRESH_INTERVAL = 1.0
|
|
65
|
+
WORKER_STOP_TIMEOUT = 2.0
|
|
65
66
|
WORKER_STOP = Object.new.freeze
|
|
66
67
|
|
|
67
68
|
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)
|
|
68
|
-
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, :options, :tool_registry, keyword_init: true)
|
|
69
|
+
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, :options, :tool_registry, :mutex, keyword_init: true)
|
|
69
70
|
|
|
70
71
|
# Creates an object for RPC session lifecycle and turn coordination.
|
|
71
72
|
def initialize(
|
|
@@ -74,7 +75,8 @@ module Kward
|
|
|
74
75
|
config_dir: ConfigFiles.config_dir,
|
|
75
76
|
config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
|
|
76
77
|
context_usage: ContextUsage.new,
|
|
77
|
-
session_trash: SessionTrash.new
|
|
78
|
+
session_trash: SessionTrash.new,
|
|
79
|
+
worker_stop_timeout: WORKER_STOP_TIMEOUT
|
|
78
80
|
)
|
|
79
81
|
@server = server
|
|
80
82
|
@client = client
|
|
@@ -83,6 +85,7 @@ module Kward
|
|
|
83
85
|
@context_usage = context_usage
|
|
84
86
|
@session_metrics = SessionMetrics.new(context_usage: context_usage)
|
|
85
87
|
@session_trash = session_trash
|
|
88
|
+
@worker_stop_timeout = worker_stop_timeout
|
|
86
89
|
@sessions = {}
|
|
87
90
|
@turns = {}
|
|
88
91
|
@mutex = Mutex.new
|
|
@@ -140,7 +143,11 @@ module Kward
|
|
|
140
143
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
141
144
|
requested_limit = limit.to_i if limit
|
|
142
145
|
requested_limit = nil unless requested_limit&.positive?
|
|
143
|
-
|
|
146
|
+
live_paths = @mutex.synchronize do
|
|
147
|
+
@sessions.values.filter_map { |rpc_session| rpc_session.session.path if rpc_session.workspace_root == root }
|
|
148
|
+
end
|
|
149
|
+
live_paths << current_session_path unless current_session_path.to_s.empty?
|
|
150
|
+
store.recent(limit: requested_limit, keep_empty_path: live_paths)
|
|
144
151
|
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
145
152
|
end
|
|
146
153
|
|
|
@@ -325,7 +332,7 @@ module Kward
|
|
|
325
332
|
# Stops all live RPC session workers during server shutdown.
|
|
326
333
|
def shutdown_sessions
|
|
327
334
|
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
328
|
-
rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session)
|
|
335
|
+
rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) }
|
|
329
336
|
{ closed: true }
|
|
330
337
|
end
|
|
331
338
|
|
|
@@ -368,7 +375,8 @@ module Kward
|
|
|
368
375
|
plugin_command_name: plugin_command&.name,
|
|
369
376
|
plugin_arguments: plugin_arguments,
|
|
370
377
|
options: normalized_options,
|
|
371
|
-
tool_registry: scoped_tool_registry(rpc_session, normalized_options)
|
|
378
|
+
tool_registry: scoped_tool_registry(rpc_session, normalized_options),
|
|
379
|
+
mutex: Mutex.new
|
|
372
380
|
)
|
|
373
381
|
@mutex.synchronize { @turns[turn.id] = turn }
|
|
374
382
|
rpc_session.queue << turn.id
|
|
@@ -379,12 +387,13 @@ module Kward
|
|
|
379
387
|
|
|
380
388
|
def cancel_turn(turn_id:)
|
|
381
389
|
turn = fetch_turn(turn_id)
|
|
382
|
-
turn.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if turn.status == "queued"
|
|
386
|
-
finish_turn(turn, "canceled")
|
|
390
|
+
queued, event = turn.mutex.synchronize do
|
|
391
|
+
turn.cancel_requested = true
|
|
392
|
+
[turn.status == "queued", append_turn_event_locked(turn, "turnCancelRequested", {})]
|
|
387
393
|
end
|
|
394
|
+
@server.notify("turn/event", event)
|
|
395
|
+
turn.cancellation&.cancel!
|
|
396
|
+
finish_turn(turn, "canceled") if queued
|
|
388
397
|
turn_payload(turn)
|
|
389
398
|
end
|
|
390
399
|
|
|
@@ -395,10 +404,12 @@ module Kward
|
|
|
395
404
|
def turn_events(turn_id:, after_sequence: 0)
|
|
396
405
|
turn = fetch_turn(turn_id)
|
|
397
406
|
after_sequence = after_sequence.to_i
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
407
|
+
turn.mutex.synchronize do
|
|
408
|
+
{
|
|
409
|
+
turn: turn_payload_unlocked(turn),
|
|
410
|
+
events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
|
|
411
|
+
}
|
|
412
|
+
end
|
|
402
413
|
end
|
|
403
414
|
|
|
404
415
|
def list_turns(session_id: nil, active: false)
|
|
@@ -957,6 +968,7 @@ module Kward
|
|
|
957
968
|
prompt: prompt,
|
|
958
969
|
allowed_tool_names: allowed_tool_names,
|
|
959
970
|
tool_approval: tool_approval,
|
|
971
|
+
approval_for_allowed_tools: !tool_approval.nil?,
|
|
960
972
|
hook_manager: hook_manager,
|
|
961
973
|
hook_context: hook_context
|
|
962
974
|
)
|
|
@@ -1043,6 +1055,8 @@ module Kward
|
|
|
1043
1055
|
end
|
|
1044
1056
|
|
|
1045
1057
|
def close_rpc_session(rpc_session, delete_unused: true)
|
|
1058
|
+
turns = cancel_session_turns(rpc_session)
|
|
1059
|
+
turns.each { |turn| finish_turn(turn, "canceled") } if stop_worker(rpc_session, wait: true)
|
|
1046
1060
|
remove_live_session(rpc_session)
|
|
1047
1061
|
rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
|
|
1048
1062
|
end
|
|
@@ -1066,12 +1080,28 @@ module Kward
|
|
|
1066
1080
|
stop_footer_worker(rpc_session)
|
|
1067
1081
|
end
|
|
1068
1082
|
|
|
1069
|
-
def stop_worker(rpc_session)
|
|
1083
|
+
def stop_worker(rpc_session, wait: false)
|
|
1070
1084
|
worker = rpc_session.worker
|
|
1071
|
-
return unless worker&.alive?
|
|
1072
|
-
return if worker == Thread.current
|
|
1085
|
+
return false unless worker&.alive?
|
|
1086
|
+
return false if worker == Thread.current
|
|
1073
1087
|
|
|
1074
1088
|
rpc_session.queue << WORKER_STOP
|
|
1089
|
+
return false unless wait
|
|
1090
|
+
|
|
1091
|
+
worker.join(@worker_stop_timeout)
|
|
1092
|
+
return false unless worker.alive?
|
|
1093
|
+
|
|
1094
|
+
worker.kill
|
|
1095
|
+
worker.join
|
|
1096
|
+
true
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def cancel_session_turns(rpc_session)
|
|
1100
|
+
turns = @mutex.synchronize do
|
|
1101
|
+
@turns.values.select { |turn| turn.session_id == rpc_session.id && ["queued", "running"].include?(turn.status) }
|
|
1102
|
+
end
|
|
1103
|
+
turns.each { |turn| cancel_turn(turn_id: turn.id) }
|
|
1104
|
+
turns
|
|
1075
1105
|
end
|
|
1076
1106
|
|
|
1077
1107
|
def start_footer_worker(rpc_session)
|
|
@@ -1128,11 +1158,18 @@ module Kward
|
|
|
1128
1158
|
def run_turn(rpc_session, turn)
|
|
1129
1159
|
previous_turn_id = Thread.current[:kward_rpc_turn_id]
|
|
1130
1160
|
Thread.current[:kward_rpc_turn_id] = turn.id
|
|
1161
|
+
started, event = turn.mutex.synchronize do
|
|
1162
|
+
next [false, nil] if turn.status == "canceled"
|
|
1163
|
+
|
|
1164
|
+
turn.status = "running"
|
|
1165
|
+
turn.started_at = now
|
|
1166
|
+
[true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
|
|
1167
|
+
end
|
|
1168
|
+
return unless started
|
|
1169
|
+
|
|
1170
|
+
@server.notify("turn/event", event)
|
|
1131
1171
|
rpc_session.running_turn_id = turn.id
|
|
1132
1172
|
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
1133
|
-
turn.status = "running"
|
|
1134
|
-
turn.started_at = now
|
|
1135
|
-
emit_turn_event(turn, "turnStarted", { status: "running" })
|
|
1136
1173
|
|
|
1137
1174
|
if turn.cancel_requested
|
|
1138
1175
|
finish_turn(turn, "canceled")
|
|
@@ -1292,11 +1329,16 @@ module Kward
|
|
|
1292
1329
|
end
|
|
1293
1330
|
|
|
1294
1331
|
def finish_turn(turn, status)
|
|
1295
|
-
|
|
1332
|
+
event = turn.mutex.synchronize do
|
|
1333
|
+
next nil if ["completed", "failed", "canceled"].include?(turn.status)
|
|
1334
|
+
|
|
1335
|
+
turn.status = status
|
|
1336
|
+
turn.finished_at = now
|
|
1337
|
+
append_turn_event_locked(turn, "turnFinished", { status: status, error: turn.error })
|
|
1338
|
+
end
|
|
1339
|
+
return unless event
|
|
1296
1340
|
|
|
1297
|
-
turn
|
|
1298
|
-
turn.finished_at = now
|
|
1299
|
-
emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
|
|
1341
|
+
@server.notify("turn/event", event)
|
|
1300
1342
|
rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
|
|
1301
1343
|
emit_footer_update(rpc_session) if rpc_session
|
|
1302
1344
|
end
|
|
@@ -1326,6 +1368,12 @@ module Kward
|
|
|
1326
1368
|
end
|
|
1327
1369
|
|
|
1328
1370
|
def emit_turn_event(turn, type, payload)
|
|
1371
|
+
event = turn.mutex.synchronize { append_turn_event_locked(turn, type, payload) }
|
|
1372
|
+
@server.notify("turn/event", event)
|
|
1373
|
+
event
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
def append_turn_event_locked(turn, type, payload)
|
|
1329
1377
|
event = {
|
|
1330
1378
|
sequence: turn.next_sequence,
|
|
1331
1379
|
timestamp: now,
|
|
@@ -1337,7 +1385,6 @@ module Kward
|
|
|
1337
1385
|
turn.next_sequence += 1
|
|
1338
1386
|
turn.events << event
|
|
1339
1387
|
turn.events.shift while turn.events.length > RECENT_EVENT_LIMIT
|
|
1340
|
-
@server.notify("turn/event", event)
|
|
1341
1388
|
event
|
|
1342
1389
|
end
|
|
1343
1390
|
|
|
@@ -1351,6 +1398,10 @@ module Kward
|
|
|
1351
1398
|
end
|
|
1352
1399
|
|
|
1353
1400
|
def turn_payload(turn)
|
|
1401
|
+
turn.mutex.synchronize { turn_payload_unlocked(turn) }
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
def turn_payload_unlocked(turn)
|
|
1354
1405
|
{
|
|
1355
1406
|
id: turn.id,
|
|
1356
1407
|
sessionId: turn.session_id,
|
|
@@ -65,6 +65,8 @@ module Kward
|
|
|
65
65
|
if mutation_tool?
|
|
66
66
|
diff = ToolMetadata.extract_unified_diff(text)
|
|
67
67
|
result[:diff] = diff if diff
|
|
68
|
+
first_changed_line = ToolMetadata.first_changed_line(diff)
|
|
69
|
+
result[:firstChangedLine] = first_changed_line if first_changed_line
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
files = changed_files
|
|
@@ -75,6 +75,29 @@ module Kward
|
|
|
75
75
|
index ? text.to_s[index..] : nil
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Returns the first changed line in the post-edit file from a unified diff.
|
|
79
|
+
# A deletion-only hunk uses the next surviving new-file line as its anchor.
|
|
80
|
+
def first_changed_line(diff)
|
|
81
|
+
new_line = nil
|
|
82
|
+
|
|
83
|
+
diff.to_s.each_line do |line|
|
|
84
|
+
if (match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/))
|
|
85
|
+
new_line = match[1].to_i
|
|
86
|
+
elsif new_line
|
|
87
|
+
case line
|
|
88
|
+
when /^\+[^+]/
|
|
89
|
+
return new_line
|
|
90
|
+
when /^-[^-]/
|
|
91
|
+
return new_line
|
|
92
|
+
when /^ /
|
|
93
|
+
new_line += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
78
101
|
def changed_files_from_result(text, matching_call = nil)
|
|
79
102
|
path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
|
|
80
103
|
return [path] if path
|
|
@@ -54,10 +54,13 @@ module Kward
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def normalize_user_message(message)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
with_timestamp(
|
|
58
|
+
{
|
|
59
|
+
role: "user",
|
|
60
|
+
content: normalize_content(MessageAccess.content(message))
|
|
61
|
+
},
|
|
62
|
+
message
|
|
63
|
+
)
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def normalize_assistant_message(message)
|
|
@@ -76,7 +79,7 @@ module Kward
|
|
|
76
79
|
result = { role: "assistant", content: content }
|
|
77
80
|
error_message = ToolCall.value(message, :errorMessage) || ToolCall.value(message, :error_message)
|
|
78
81
|
result[:errorMessage] = error_message unless error_message.to_s.empty?
|
|
79
|
-
result
|
|
82
|
+
with_timestamp(result, message)
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
def normalize_tool_result_message(message)
|
|
@@ -96,6 +99,12 @@ module Kward
|
|
|
96
99
|
|
|
97
100
|
details = tool_result_details(message, matching_call, content)
|
|
98
101
|
result[:details] = details unless details.empty?
|
|
102
|
+
with_timestamp(result, message)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def with_timestamp(result, message)
|
|
106
|
+
timestamp = ToolCall.value(message, :timestamp)
|
|
107
|
+
result[:timestamp] = timestamp if timestamp
|
|
99
108
|
result
|
|
100
109
|
end
|
|
101
110
|
|
|
@@ -246,6 +255,8 @@ module Kward
|
|
|
246
255
|
|
|
247
256
|
diff = details[:diff] || details["diff"] || ToolMetadata.extract_unified_diff(text)
|
|
248
257
|
details[:diff] = diff if diff
|
|
258
|
+
first_changed_line = details[:firstChangedLine] || details["firstChangedLine"] || ToolMetadata.first_changed_line(diff)
|
|
259
|
+
details[:firstChangedLine] = first_changed_line if first_changed_line
|
|
249
260
|
|
|
250
261
|
changed_files = details[:changedFiles] || details["changedFiles"] || ToolMetadata.changed_files_from_result(text, matching_call)
|
|
251
262
|
details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
|
|
@@ -257,6 +268,8 @@ module Kward
|
|
|
257
268
|
allowed = {}
|
|
258
269
|
diff = ToolCall.value(details, :diff)
|
|
259
270
|
allowed[:diff] = diff if diff
|
|
271
|
+
first_changed_line = ToolCall.value(details, :firstChangedLine) || ToolCall.value(details, :first_changed_line)
|
|
272
|
+
allowed[:firstChangedLine] = first_changed_line if first_changed_line.is_a?(Integer) && first_changed_line.positive?
|
|
260
273
|
changed_files = ToolCall.value(details, :changedFiles) || ToolCall.value(details, :changed_files)
|
|
261
274
|
allowed[:changedFiles] = changed_files if changed_files.is_a?(Array)
|
|
262
275
|
allowed
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -309,11 +309,10 @@ module Kward
|
|
|
309
309
|
# Lists recent non-empty sessions for this workspace.
|
|
310
310
|
#
|
|
311
311
|
# @param limit [Integer, nil] maximum number of sessions, or nil for all
|
|
312
|
-
# @param keep_empty_path [String, nil] empty session path to
|
|
312
|
+
# @param keep_empty_path [String, Array<String>, nil] live empty session path(s) to preserve while omitting them from results
|
|
313
313
|
# @return [Array<SessionInfo>] newest sessions first
|
|
314
314
|
def recent(limit: 20, keep_empty_path: nil)
|
|
315
|
-
|
|
316
|
-
limit ? sessions.first(limit) : sessions
|
|
315
|
+
recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
|
|
317
316
|
end
|
|
318
317
|
|
|
319
318
|
# Persists the last active session pointer for workspace auto-resume.
|
|
@@ -344,9 +343,7 @@ module Kward
|
|
|
344
343
|
#
|
|
345
344
|
# @return [Array<SessionInfo>] recent sessions with tree depth fields
|
|
346
345
|
def recent_tree(limit: 20, keep_empty_path: nil)
|
|
347
|
-
|
|
348
|
-
sessions = sessions.first(limit) if limit
|
|
349
|
-
decorate_tree(sessions)
|
|
346
|
+
decorate_tree(recent_sessions(limit: limit, keep_empty_path: keep_empty_path))
|
|
350
347
|
end
|
|
351
348
|
|
|
352
349
|
# Deletes an empty unnamed session file.
|
|
@@ -770,20 +767,30 @@ module Kward
|
|
|
770
767
|
nil
|
|
771
768
|
end
|
|
772
769
|
|
|
773
|
-
def recent_sessions(keep_empty_path: nil)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
770
|
+
def recent_sessions(limit: nil, keep_empty_path: nil)
|
|
771
|
+
keep_empty_paths = Array(keep_empty_path).filter_map do |path|
|
|
772
|
+
File.expand_path(path) unless path.to_s.empty?
|
|
773
|
+
end
|
|
774
|
+
paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path| session_file_mtime(path) }.reverse
|
|
775
|
+
sessions = []
|
|
776
|
+
|
|
777
|
+
paths.each do |path|
|
|
778
|
+
if limit.nil? || sessions.length < limit
|
|
779
|
+
info = session_info(path)
|
|
780
|
+
next unless info
|
|
781
|
+
next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
|
|
779
782
|
|
|
780
|
-
|
|
781
|
-
|
|
783
|
+
sessions << info
|
|
784
|
+
else
|
|
785
|
+
delete_empty_unnamed_session_path(path, keep_empty_paths: keep_empty_paths)
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
sessions
|
|
782
789
|
end
|
|
783
790
|
|
|
784
|
-
def delete_empty_unnamed_session_info(info,
|
|
791
|
+
def delete_empty_unnamed_session_info(info, keep_empty_paths: [])
|
|
785
792
|
return false unless info.name.to_s.strip.empty? && info.message_count.to_i.zero?
|
|
786
|
-
return true if
|
|
793
|
+
return true if keep_empty_paths.include?(File.expand_path(info.path))
|
|
787
794
|
|
|
788
795
|
File.delete(info.path)
|
|
789
796
|
true
|
|
@@ -791,6 +798,38 @@ module Kward
|
|
|
791
798
|
false
|
|
792
799
|
end
|
|
793
800
|
|
|
801
|
+
def session_file_mtime(path)
|
|
802
|
+
File.mtime(path)
|
|
803
|
+
rescue StandardError
|
|
804
|
+
Time.at(0)
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def delete_empty_unnamed_session_path(path, keep_empty_paths: [])
|
|
808
|
+
return false unless empty_unnamed_session_path?(path)
|
|
809
|
+
return true if keep_empty_paths.include?(File.expand_path(path))
|
|
810
|
+
|
|
811
|
+
File.delete(path)
|
|
812
|
+
true
|
|
813
|
+
rescue StandardError
|
|
814
|
+
false
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def empty_unnamed_session_path?(path)
|
|
818
|
+
header = false
|
|
819
|
+
name = nil
|
|
820
|
+
File.foreach(path) do |line|
|
|
821
|
+
record = JSON.parse(line)
|
|
822
|
+
header = true if record["type"] == "session" && !record["id"].to_s.empty?
|
|
823
|
+
name = record["name"] if record["type"] == "session_info" && record.key?("name")
|
|
824
|
+
next unless record["type"] == "message"
|
|
825
|
+
|
|
826
|
+
return false if ["user", "assistant", "tool"].include?(message_role(record["message"] || {}))
|
|
827
|
+
end
|
|
828
|
+
header && name.to_s.strip.empty?
|
|
829
|
+
rescue JSON::ParserError
|
|
830
|
+
false
|
|
831
|
+
end
|
|
832
|
+
|
|
794
833
|
def decorate_tree(sessions)
|
|
795
834
|
by_parent = Hash.new { |hash, key| hash[key] = [] }
|
|
796
835
|
ids = sessions.map(&:id).to_h { |id| [id, true] }
|
|
@@ -4,7 +4,13 @@ require "pathname"
|
|
|
4
4
|
module Kward
|
|
5
5
|
# Skill discovery and metadata parsing from configured skill folders.
|
|
6
6
|
module Skills
|
|
7
|
-
#
|
|
7
|
+
# Discovers Agent Skills and reads their bounded instruction resources.
|
|
8
|
+
#
|
|
9
|
+
# Project sources are included only when the caller marks them trusted. If
|
|
10
|
+
# duplicate skill names exist, the first source in documented precedence
|
|
11
|
+
# order wins.
|
|
12
|
+
#
|
|
13
|
+
# @api public
|
|
8
14
|
class Registry
|
|
9
15
|
SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
|
|
10
16
|
|
|
@@ -18,6 +24,10 @@ module Kward
|
|
|
18
24
|
@inside_directory = inside_directory
|
|
19
25
|
end
|
|
20
26
|
|
|
27
|
+
# Returns discovered, validated skills in precedence order.
|
|
28
|
+
#
|
|
29
|
+
# @return [Array<ConfigFiles::Skill>]
|
|
30
|
+
# @api public
|
|
21
31
|
def skills
|
|
22
32
|
seen = {}
|
|
23
33
|
skill_sources.flat_map do |source|
|
|
@@ -39,6 +49,16 @@ module Kward
|
|
|
39
49
|
[]
|
|
40
50
|
end
|
|
41
51
|
|
|
52
|
+
# Reads a skill's main instructions or a bounded relative resource.
|
|
53
|
+
#
|
|
54
|
+
# Absolute paths, paths escaping the skill folder, missing files, and files
|
|
55
|
+
# above the configured size limit return user-facing error strings.
|
|
56
|
+
#
|
|
57
|
+
# @param name [String, #to_s] discovered skill name
|
|
58
|
+
# @param relative_path [String, nil] file relative to the skill directory;
|
|
59
|
+
# defaults to `SKILL.md`
|
|
60
|
+
# @return [String] skill content or an error message
|
|
61
|
+
# @api public
|
|
42
62
|
def read_skill_file(name, relative_path = nil)
|
|
43
63
|
skill = skills.find { |candidate| candidate.name == name.to_s }
|
|
44
64
|
return "Error: unknown skill: #{name}" unless skill
|
|
@@ -6,6 +6,7 @@ require "tmpdir"
|
|
|
6
6
|
require "uri"
|
|
7
7
|
require "zlib"
|
|
8
8
|
require_relative "config_files"
|
|
9
|
+
require_relative "http"
|
|
9
10
|
|
|
10
11
|
# Namespace for the Kward CLI agent runtime.
|
|
11
12
|
module Kward
|
|
@@ -56,8 +57,9 @@ module Kward
|
|
|
56
57
|
|
|
57
58
|
def download(url)
|
|
58
59
|
uri = URI(url)
|
|
60
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
|
|
59
61
|
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
|
|
60
|
-
http.
|
|
62
|
+
http.request(request)
|
|
61
63
|
end
|
|
62
64
|
raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
63
65
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
4
|
+
# drivers implement the same small surface without becoming Kward sessions.
|
|
5
|
+
class SessionTabDriver
|
|
6
|
+
attr_reader :session, :agent
|
|
7
|
+
|
|
8
|
+
def initialize(session:, agent:)
|
|
9
|
+
@session = session
|
|
10
|
+
@agent = agent
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def messages
|
|
14
|
+
agent.conversation.messages
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def conversation
|
|
18
|
+
agent.conversation
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def submit(input, display_input:, cancellation:, steering: nil, &block)
|
|
22
|
+
options = { cancellation: cancellation }
|
|
23
|
+
options[:display_input] = display_input unless display_input.nil?
|
|
24
|
+
options[:steering] = steering if steering
|
|
25
|
+
agent.ask(input, **options, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def descriptor
|
|
29
|
+
{ "kind" => "session", "session_path" => session.path }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def session?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def supports_steering?
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def assistant_label
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Represents a persisted plugin tab whose provider plugin is unavailable.
|
|
46
|
+
# Its descriptor is retained so reinstalling the plugin restores the tab.
|
|
47
|
+
class UnavailableTabDriver
|
|
48
|
+
attr_reader :descriptor
|
|
49
|
+
|
|
50
|
+
def initialize(descriptor:, message:)
|
|
51
|
+
@descriptor = descriptor
|
|
52
|
+
@message = message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def messages
|
|
56
|
+
[{ role: "assistant", content: @message }]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def submit(*)
|
|
60
|
+
raise @message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def session?
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def supports_steering?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assistant_label
|
|
72
|
+
"Plugin"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Dependencies made available to a plugin tab factory. The host deliberately
|
|
77
|
+
# exposes provider transport and frontend-neutral facts, not CLI internals or
|
|
78
|
+
# workspace session state.
|
|
79
|
+
class PluginTabHost
|
|
80
|
+
attr_reader :client, :workspace_root
|
|
81
|
+
|
|
82
|
+
def initialize(client:, workspace_root:)
|
|
83
|
+
@client = client
|
|
84
|
+
@workspace_root = workspace_root
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/kward/tab_store.rb
CHANGED
|
@@ -5,32 +5,45 @@ require_relative "private_file"
|
|
|
5
5
|
|
|
6
6
|
# Namespace for the Kward CLI agent runtime.
|
|
7
7
|
module Kward
|
|
8
|
-
# Persists the terminal UI's open
|
|
8
|
+
# Persists the terminal UI's open tabs per workspace.
|
|
9
9
|
class TabStore
|
|
10
|
+
VERSION = 2
|
|
11
|
+
|
|
10
12
|
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
|
|
11
13
|
@config_dir = config_dir
|
|
12
14
|
@cwd = File.expand_path(cwd)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def load
|
|
16
|
-
return
|
|
18
|
+
return empty_state unless File.file?(path)
|
|
17
19
|
|
|
18
20
|
data = JSON.parse(File.read(path))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{ "session_paths" => paths, "labels" => labels, "active_index" => active_index }
|
|
21
|
+
return typed_state(data) if data["tabs"].is_a?(Array)
|
|
22
|
+
|
|
23
|
+
legacy_state(data)
|
|
23
24
|
rescue JSON::ParserError
|
|
24
|
-
|
|
25
|
+
empty_state
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
# `session_paths` remains supported for callers and layouts written before
|
|
29
|
+
# typed tab descriptors were introduced.
|
|
30
|
+
def save(session_paths: nil, tabs: nil, active_index:, labels: [])
|
|
31
|
+
tabs ||= Array(session_paths).filter_map.with_index do |session_path, index|
|
|
32
|
+
path = session_path.to_s
|
|
33
|
+
next if path.empty?
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"kind" => "session",
|
|
37
|
+
"session_path" => path,
|
|
38
|
+
"label" => Array(labels)[index].to_s
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
tabs = normalize_tabs(tabs)
|
|
29
42
|
PrivateFile.write_json(path, {
|
|
43
|
+
"version" => VERSION,
|
|
30
44
|
"cwd" => @cwd,
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
|
|
45
|
+
"tabs" => tabs,
|
|
46
|
+
"active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
|
|
34
47
|
})
|
|
35
48
|
end
|
|
36
49
|
|
|
@@ -40,6 +53,55 @@ module Kward
|
|
|
40
53
|
|
|
41
54
|
private
|
|
42
55
|
|
|
56
|
+
def empty_state
|
|
57
|
+
{ "tabs" => [], "session_paths" => [], "labels" => [], "active_index" => 0 }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def typed_state(data)
|
|
61
|
+
tabs = normalize_tabs(data["tabs"])
|
|
62
|
+
state_for(tabs, data["active_index"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def legacy_state(data)
|
|
66
|
+
paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
|
|
67
|
+
labels = Array(data["labels"]).map(&:to_s)
|
|
68
|
+
tabs = paths.each_with_index.map do |session_path, index|
|
|
69
|
+
{ "kind" => "session", "session_path" => session_path, "label" => labels[index].to_s }
|
|
70
|
+
end
|
|
71
|
+
state_for(tabs, data["active_index"])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def state_for(tabs, active_index)
|
|
75
|
+
{
|
|
76
|
+
"tabs" => tabs,
|
|
77
|
+
"session_paths" => tabs.filter_map { |tab| tab["session_path"] if tab["kind"] == "session" },
|
|
78
|
+
"labels" => tabs.map { |tab| tab["label"].to_s },
|
|
79
|
+
"active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_tabs(tabs)
|
|
84
|
+
session_paths = {}
|
|
85
|
+
|
|
86
|
+
Array(tabs).filter_map do |tab|
|
|
87
|
+
next unless tab.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
normalized = tab.transform_keys(&:to_s)
|
|
90
|
+
kind = normalized["kind"].to_s
|
|
91
|
+
next if kind.empty?
|
|
92
|
+
next if kind == "session" && normalized["session_path"].to_s.empty?
|
|
93
|
+
|
|
94
|
+
if kind == "session"
|
|
95
|
+
session_path = File.expand_path(normalized["session_path"].to_s, @cwd)
|
|
96
|
+
next if session_paths[session_path]
|
|
97
|
+
|
|
98
|
+
session_paths[session_path] = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
normalized
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
43
105
|
def workspace_key
|
|
44
106
|
Digest::SHA256.hexdigest(@cwd)[0, 24]
|
|
45
107
|
end
|