kward 0.75.0 → 0.77.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 +48 -0
- data/Gemfile.lock +2 -2
- data/README.md +12 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/composer.md +219 -0
- data/doc/configuration.md +87 -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/plugins.md +1 -0
- data/doc/rpc.md +3 -22
- data/doc/security.md +180 -0
- data/doc/usage.md +15 -32
- data/lib/kward/agent.rb +5 -0
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +3 -17
- data/lib/kward/cli/plugins.rb +2 -7
- data/lib/kward/cli/prompt_interface.rb +10 -17
- data/lib/kward/cli/rendering.rb +7 -3
- data/lib/kward/cli/runtime_helpers.rb +0 -38
- 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 +14 -2
- data/lib/kward/cli.rb +1 -17
- data/lib/kward/cli_transcript_formatter.rb +21 -11
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +21 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/events.rb +1 -0
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +12 -9
- data/lib/kward/model/model_info.rb +14 -5
- data/lib/kward/model/stream_parser.rb +12 -12
- data/lib/kward/pan/index.html.erb +460 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +250 -23
- data/lib/kward/plugin_registry.rb +20 -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/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +1 -13
- data/lib/kward/prompt_interface.rb +18 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/server.rb +14 -42
- data/lib/kward/rpc/session_manager.rb +77 -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 +14 -10
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/registry.rb +11 -16
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/search/web.rb +1 -1
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +12 -7
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +5 -1
- data/templates/default/layout/html/layout.erb +0 -2
- metadata +5 -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)
|
|
@@ -1043,6 +1054,8 @@ module Kward
|
|
|
1043
1054
|
end
|
|
1044
1055
|
|
|
1045
1056
|
def close_rpc_session(rpc_session, delete_unused: true)
|
|
1057
|
+
turns = cancel_session_turns(rpc_session)
|
|
1058
|
+
turns.each { |turn| finish_turn(turn, "canceled") } if stop_worker(rpc_session, wait: true)
|
|
1046
1059
|
remove_live_session(rpc_session)
|
|
1047
1060
|
rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
|
|
1048
1061
|
end
|
|
@@ -1066,12 +1079,28 @@ module Kward
|
|
|
1066
1079
|
stop_footer_worker(rpc_session)
|
|
1067
1080
|
end
|
|
1068
1081
|
|
|
1069
|
-
def stop_worker(rpc_session)
|
|
1082
|
+
def stop_worker(rpc_session, wait: false)
|
|
1070
1083
|
worker = rpc_session.worker
|
|
1071
|
-
return unless worker&.alive?
|
|
1072
|
-
return if worker == Thread.current
|
|
1084
|
+
return false unless worker&.alive?
|
|
1085
|
+
return false if worker == Thread.current
|
|
1073
1086
|
|
|
1074
1087
|
rpc_session.queue << WORKER_STOP
|
|
1088
|
+
return false unless wait
|
|
1089
|
+
|
|
1090
|
+
worker.join(@worker_stop_timeout)
|
|
1091
|
+
return false unless worker.alive?
|
|
1092
|
+
|
|
1093
|
+
worker.kill
|
|
1094
|
+
worker.join
|
|
1095
|
+
true
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
def cancel_session_turns(rpc_session)
|
|
1099
|
+
turns = @mutex.synchronize do
|
|
1100
|
+
@turns.values.select { |turn| turn.session_id == rpc_session.id && ["queued", "running"].include?(turn.status) }
|
|
1101
|
+
end
|
|
1102
|
+
turns.each { |turn| cancel_turn(turn_id: turn.id) }
|
|
1103
|
+
turns
|
|
1075
1104
|
end
|
|
1076
1105
|
|
|
1077
1106
|
def start_footer_worker(rpc_session)
|
|
@@ -1128,11 +1157,18 @@ module Kward
|
|
|
1128
1157
|
def run_turn(rpc_session, turn)
|
|
1129
1158
|
previous_turn_id = Thread.current[:kward_rpc_turn_id]
|
|
1130
1159
|
Thread.current[:kward_rpc_turn_id] = turn.id
|
|
1160
|
+
started, event = turn.mutex.synchronize do
|
|
1161
|
+
next [false, nil] if turn.status == "canceled"
|
|
1162
|
+
|
|
1163
|
+
turn.status = "running"
|
|
1164
|
+
turn.started_at = now
|
|
1165
|
+
[true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
|
|
1166
|
+
end
|
|
1167
|
+
return unless started
|
|
1168
|
+
|
|
1169
|
+
@server.notify("turn/event", event)
|
|
1131
1170
|
rpc_session.running_turn_id = turn.id
|
|
1132
1171
|
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
1172
|
|
|
1137
1173
|
if turn.cancel_requested
|
|
1138
1174
|
finish_turn(turn, "canceled")
|
|
@@ -1256,6 +1292,8 @@ module Kward
|
|
|
1256
1292
|
case event
|
|
1257
1293
|
when Events::ReasoningDelta
|
|
1258
1294
|
emit_turn_event(turn, "reasoningDelta", { delta: event.delta })
|
|
1295
|
+
when Events::ReasoningBoundary
|
|
1296
|
+
emit_turn_event(turn, "reasoningBoundary", {})
|
|
1259
1297
|
when Events::AssistantDelta
|
|
1260
1298
|
emit_turn_event(turn, "assistantDelta", { delta: event.delta })
|
|
1261
1299
|
when Events::AssistantMessage
|
|
@@ -1290,11 +1328,16 @@ module Kward
|
|
|
1290
1328
|
end
|
|
1291
1329
|
|
|
1292
1330
|
def finish_turn(turn, status)
|
|
1293
|
-
|
|
1331
|
+
event = turn.mutex.synchronize do
|
|
1332
|
+
next nil if ["completed", "failed", "canceled"].include?(turn.status)
|
|
1333
|
+
|
|
1334
|
+
turn.status = status
|
|
1335
|
+
turn.finished_at = now
|
|
1336
|
+
append_turn_event_locked(turn, "turnFinished", { status: status, error: turn.error })
|
|
1337
|
+
end
|
|
1338
|
+
return unless event
|
|
1294
1339
|
|
|
1295
|
-
turn
|
|
1296
|
-
turn.finished_at = now
|
|
1297
|
-
emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
|
|
1340
|
+
@server.notify("turn/event", event)
|
|
1298
1341
|
rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
|
|
1299
1342
|
emit_footer_update(rpc_session) if rpc_session
|
|
1300
1343
|
end
|
|
@@ -1324,6 +1367,12 @@ module Kward
|
|
|
1324
1367
|
end
|
|
1325
1368
|
|
|
1326
1369
|
def emit_turn_event(turn, type, payload)
|
|
1370
|
+
event = turn.mutex.synchronize { append_turn_event_locked(turn, type, payload) }
|
|
1371
|
+
@server.notify("turn/event", event)
|
|
1372
|
+
event
|
|
1373
|
+
end
|
|
1374
|
+
|
|
1375
|
+
def append_turn_event_locked(turn, type, payload)
|
|
1327
1376
|
event = {
|
|
1328
1377
|
sequence: turn.next_sequence,
|
|
1329
1378
|
timestamp: now,
|
|
@@ -1335,7 +1384,6 @@ module Kward
|
|
|
1335
1384
|
turn.next_sequence += 1
|
|
1336
1385
|
turn.events << event
|
|
1337
1386
|
turn.events.shift while turn.events.length > RECENT_EVENT_LIMIT
|
|
1338
|
-
@server.notify("turn/event", event)
|
|
1339
1387
|
event
|
|
1340
1388
|
end
|
|
1341
1389
|
|
|
@@ -1349,6 +1397,10 @@ module Kward
|
|
|
1349
1397
|
end
|
|
1350
1398
|
|
|
1351
1399
|
def turn_payload(turn)
|
|
1400
|
+
turn.mutex.synchronize { turn_payload_unlocked(turn) }
|
|
1401
|
+
end
|
|
1402
|
+
|
|
1403
|
+
def turn_payload_unlocked(turn)
|
|
1352
1404
|
{
|
|
1353
1405
|
id: turn.id,
|
|
1354
1406
|
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
|
|
@@ -157,18 +157,19 @@ module Kward
|
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def response_item_content(message)
|
|
160
|
-
response_items(message).
|
|
161
|
-
next unless item.is_a?(Hash)
|
|
160
|
+
response_items(message).flat_map do |item|
|
|
161
|
+
next [] unless item.is_a?(Hash)
|
|
162
162
|
|
|
163
163
|
case ToolCall.value(item, :type).to_s
|
|
164
164
|
when "reasoning"
|
|
165
|
-
thinking
|
|
166
|
-
{ type: "thinking", thinking: thinking } unless thinking.empty?
|
|
165
|
+
reasoning_item_blocks(item).map { |thinking| { type: "thinking", thinking: thinking } }
|
|
167
166
|
when "message"
|
|
168
|
-
next if ToolCall.value(item, :phase).to_s == "commentary"
|
|
167
|
+
next [] if ToolCall.value(item, :phase).to_s == "commentary"
|
|
169
168
|
|
|
170
169
|
text = response_message_item_text(item)
|
|
171
|
-
{ type: "text", text: text }
|
|
170
|
+
text.empty? ? [] : [{ type: "text", text: text }]
|
|
171
|
+
else
|
|
172
|
+
[]
|
|
172
173
|
end
|
|
173
174
|
end
|
|
174
175
|
end
|
|
@@ -177,10 +178,9 @@ module Kward
|
|
|
177
178
|
MessageAccess.response_items(message)
|
|
178
179
|
end
|
|
179
180
|
|
|
180
|
-
def
|
|
181
|
-
summary = ToolCall.value(item, :summary)
|
|
182
|
-
|
|
183
|
-
response_text_parts(summary).empty? ? response_text_parts(content).join("\n\n") : response_text_parts(summary).join("\n\n")
|
|
181
|
+
def reasoning_item_blocks(item)
|
|
182
|
+
summary = response_text_parts(ToolCall.value(item, :summary))
|
|
183
|
+
summary.empty? ? response_text_parts(ToolCall.value(item, :content)) : summary
|
|
184
184
|
end
|
|
185
185
|
|
|
186
186
|
def response_message_item_text(item)
|
|
@@ -246,6 +246,8 @@ module Kward
|
|
|
246
246
|
|
|
247
247
|
diff = details[:diff] || details["diff"] || ToolMetadata.extract_unified_diff(text)
|
|
248
248
|
details[:diff] = diff if diff
|
|
249
|
+
first_changed_line = details[:firstChangedLine] || details["firstChangedLine"] || ToolMetadata.first_changed_line(diff)
|
|
250
|
+
details[:firstChangedLine] = first_changed_line if first_changed_line
|
|
249
251
|
|
|
250
252
|
changed_files = details[:changedFiles] || details["changedFiles"] || ToolMetadata.changed_files_from_result(text, matching_call)
|
|
251
253
|
details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
|
|
@@ -257,6 +259,8 @@ module Kward
|
|
|
257
259
|
allowed = {}
|
|
258
260
|
diff = ToolCall.value(details, :diff)
|
|
259
261
|
allowed[:diff] = diff if diff
|
|
262
|
+
first_changed_line = ToolCall.value(details, :firstChangedLine) || ToolCall.value(details, :first_changed_line)
|
|
263
|
+
allowed[:firstChangedLine] = first_changed_line if first_changed_line.is_a?(Integer) && first_changed_line.positive?
|
|
260
264
|
changed_files = ToolCall.value(details, :changedFiles) || ToolCall.value(details, :changed_files)
|
|
261
265
|
allowed[:changedFiles] = changed_files if changed_files.is_a?(Array)
|
|
262
266
|
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
|
data/lib/kward/tools/base.rb
CHANGED
|
@@ -3,11 +3,22 @@ module Kward
|
|
|
3
3
|
# Model-callable tool wrappers and their argument schemas.
|
|
4
4
|
module Tools
|
|
5
5
|
# Base class for model-callable tools and their JSON schemas.
|
|
6
|
+
#
|
|
7
|
+
# Concrete built-in tools initialize this class with their model-facing name,
|
|
8
|
+
# description, and strict argument schema, then implement `call` to perform
|
|
9
|
+
# the operation. Tool execution is coordinated by {Kward::ToolRegistry}.
|
|
10
|
+
#
|
|
11
|
+
# @api public
|
|
6
12
|
class Base
|
|
7
13
|
# @return [String] function name exposed to the model
|
|
8
14
|
attr_reader :name
|
|
9
15
|
|
|
10
16
|
# Creates a tool schema definition shared by all concrete tool wrappers.
|
|
17
|
+
#
|
|
18
|
+
# @param name [String] function name exposed to the model
|
|
19
|
+
# @param description [String] model-facing description of the operation
|
|
20
|
+
# @param properties [Hash] JSON Schema properties keyed by argument name
|
|
21
|
+
# @param required [Array<String, Symbol>] required argument names
|
|
11
22
|
def initialize(name, description, properties: {}, required: [])
|
|
12
23
|
@name = name
|
|
13
24
|
@description = description
|
|
@@ -16,6 +27,9 @@ module Kward
|
|
|
16
27
|
end
|
|
17
28
|
|
|
18
29
|
# Returns the strict JSON schema advertised to model providers.
|
|
30
|
+
#
|
|
31
|
+
# @return [Hash] function schema with additional properties disabled
|
|
32
|
+
# @api public
|
|
19
33
|
def schema
|
|
20
34
|
{
|
|
21
35
|
type: "function",
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -46,11 +46,12 @@ module Kward
|
|
|
46
46
|
# individual tools, and legacy-compatible shapes are accepted where already
|
|
47
47
|
# supported. Required fields and invalid required values should still return
|
|
48
48
|
# explicit tool errors.
|
|
49
|
+
# @api public
|
|
49
50
|
class ToolRegistry
|
|
50
51
|
# Tool schemas advertised to the model for the current frontend and config.
|
|
51
52
|
#
|
|
52
53
|
# @return [Array<Hash>] tool schemas currently advertised to the model
|
|
53
|
-
attr_reader :schemas
|
|
54
|
+
attr_reader :schemas
|
|
54
55
|
|
|
55
56
|
# Builds tool objects and the schema list for the current frontend/config.
|
|
56
57
|
#
|
|
@@ -63,7 +64,7 @@ module Kward
|
|
|
63
64
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
64
65
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
65
66
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
66
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil,
|
|
67
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
|
|
67
68
|
@workspace = workspace
|
|
68
69
|
@prompt = prompt
|
|
69
70
|
@web_search = web_search
|
|
@@ -73,8 +74,6 @@ module Kward
|
|
|
73
74
|
@web_search_enabled = web_search_enabled
|
|
74
75
|
@ask_user_question_enabled = ask_user_question_enabled
|
|
75
76
|
@allowed_tool_names = allowed_tool_names&.map(&:to_s)
|
|
76
|
-
@write_lock = write_lock
|
|
77
|
-
@writer_id = writer_id
|
|
78
77
|
@tool_output_compactor = tool_output_compactor
|
|
79
78
|
@telemetry_logger = telemetry_logger
|
|
80
79
|
@context_budget_meter = context_budget_meter
|
|
@@ -115,8 +114,6 @@ module Kward
|
|
|
115
114
|
hook_denied_content(before_tool, "tool call denied: #{name}")
|
|
116
115
|
elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
|
|
117
116
|
hook_denied_content(before_tool, "tool call approval denied: #{name}")
|
|
118
|
-
elsif mutation_tool?(name) && !write_lock_owned?
|
|
119
|
-
"Workspace write denied: another worker owns the write lock."
|
|
120
117
|
elsif tool_approval_denied?(tool_call, name, args, cancellation)
|
|
121
118
|
"Declined: tool execution denied by user: #{name}"
|
|
122
119
|
else
|
|
@@ -148,6 +145,14 @@ module Kward
|
|
|
148
145
|
model_content
|
|
149
146
|
end
|
|
150
147
|
|
|
148
|
+
# Returns frontend discovery metadata for a tool name.
|
|
149
|
+
#
|
|
150
|
+
# Unknown names return metadata with an unknown source rather than raising,
|
|
151
|
+
# which lets RPC clients render restored or unsupported tool calls safely.
|
|
152
|
+
#
|
|
153
|
+
# @param name [String, #to_s] exposed tool name
|
|
154
|
+
# @return [Hash] source, display name, and optional MCP identity
|
|
155
|
+
# @api public
|
|
151
156
|
def metadata_for(name)
|
|
152
157
|
tool = @tools[name.to_s]
|
|
153
158
|
return unknown_tool_metadata(name) unless tool
|
|
@@ -415,22 +420,12 @@ module Kward
|
|
|
415
420
|
(metadata[:source] || metadata["source"]).to_s == "mcp"
|
|
416
421
|
end
|
|
417
422
|
|
|
418
|
-
def mutation_tool?(name)
|
|
419
|
-
ToolCall.write_lock_required?(name)
|
|
420
|
-
end
|
|
421
|
-
|
|
422
423
|
def tool_approval_denied?(tool_call, name, args, cancellation)
|
|
423
424
|
return false unless @tool_approval
|
|
424
425
|
|
|
425
426
|
@tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
|
|
426
427
|
end
|
|
427
428
|
|
|
428
|
-
def write_lock_owned?
|
|
429
|
-
return true unless @write_lock
|
|
430
|
-
|
|
431
|
-
@write_lock.owned_by?(@writer_id)
|
|
432
|
-
end
|
|
433
|
-
|
|
434
429
|
def build_tools
|
|
435
430
|
tools = all_tools
|
|
436
431
|
tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
|
|
@@ -67,10 +67,6 @@ module Kward
|
|
|
67
67
|
TOOL_NAME_MAP[name.to_s]
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def write_lock_required?(name)
|
|
71
|
-
%w[edit_file write_file run_shell_command edit write bash].include?(name.to_s)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
70
|
def file_change_tool?(name)
|
|
75
71
|
%w[edit_file write_file edit write].include?(name.to_s)
|
|
76
72
|
end
|
data/lib/kward/update_check.rb
CHANGED
|
@@ -29,15 +29,11 @@ module Kward
|
|
|
29
29
|
true
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def notice
|
|
32
|
+
def notice(refresh: false)
|
|
33
33
|
return nil unless enabled?
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return nil if dismissed_version == latest_version
|
|
38
|
-
return nil unless newer_than_current?(latest_version)
|
|
39
|
-
|
|
40
|
-
Notice.new(latest_version: latest_version)
|
|
35
|
+
refresh_if_stale if refresh
|
|
36
|
+
notice_from_cache
|
|
41
37
|
end
|
|
42
38
|
|
|
43
39
|
def refresh_if_stale
|
|
@@ -69,6 +65,15 @@ module Kward
|
|
|
69
65
|
cache["dismissed_version"].to_s
|
|
70
66
|
end
|
|
71
67
|
|
|
68
|
+
def notice_from_cache
|
|
69
|
+
latest_version = cache["latest_version"].to_s
|
|
70
|
+
return nil if latest_version.empty?
|
|
71
|
+
return nil if dismissed_version == latest_version
|
|
72
|
+
return nil unless newer_than_current?(latest_version)
|
|
73
|
+
|
|
74
|
+
Notice.new(latest_version: latest_version)
|
|
75
|
+
end
|
|
76
|
+
|
|
72
77
|
def stale?
|
|
73
78
|
checked_at = Time.iso8601(cache["checked_at"].to_s)
|
|
74
79
|
(@now - checked_at) >= CHECK_INTERVAL_SECONDS
|
data/lib/kward/version.rb
CHANGED