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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +48 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +87 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/plugins.md +1 -0
  17. data/doc/rpc.md +3 -22
  18. data/doc/security.md +180 -0
  19. data/doc/usage.md +15 -32
  20. data/lib/kward/agent.rb +5 -0
  21. data/lib/kward/ansi.rb +2 -2
  22. data/lib/kward/cancellation.rb +2 -2
  23. data/lib/kward/cli/commands.rb +1 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -17
  25. data/lib/kward/cli/plugins.rb +2 -7
  26. data/lib/kward/cli/prompt_interface.rb +10 -17
  27. data/lib/kward/cli/rendering.rb +7 -3
  28. data/lib/kward/cli/runtime_helpers.rb +0 -38
  29. data/lib/kward/cli/sessions.rb +14 -5
  30. data/lib/kward/cli/settings.rb +22 -0
  31. data/lib/kward/cli/slash_commands.rb +1 -561
  32. data/lib/kward/cli/tabs.rb +14 -2
  33. data/lib/kward/cli.rb +1 -17
  34. data/lib/kward/cli_transcript_formatter.rb +21 -11
  35. data/lib/kward/compactor.rb +3 -3
  36. data/lib/kward/config_files.rb +21 -0
  37. data/lib/kward/ekwsh.rb +0 -4
  38. data/lib/kward/events.rb +1 -0
  39. data/lib/kward/hooks/catalog.rb +0 -5
  40. data/lib/kward/image_attachments.rb +1 -1
  41. data/lib/kward/model/client.rb +12 -9
  42. data/lib/kward/model/model_info.rb +14 -5
  43. data/lib/kward/model/stream_parser.rb +12 -12
  44. data/lib/kward/pan/index.html.erb +460 -64
  45. data/lib/kward/pan/kward_logo.png +0 -0
  46. data/lib/kward/pan/server.rb +250 -23
  47. data/lib/kward/plugin_registry.rb +20 -0
  48. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  50. data/lib/kward/prompt_interface/layout.rb +0 -4
  51. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  52. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  53. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  54. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -13
  55. data/lib/kward/prompt_interface.rb +18 -1
  56. data/lib/kward/prompts/commands.rb +1 -3
  57. data/lib/kward/prompts.rb +20 -0
  58. data/lib/kward/rpc/server.rb +14 -42
  59. data/lib/kward/rpc/session_manager.rb +77 -25
  60. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  61. data/lib/kward/rpc/tool_metadata.rb +23 -0
  62. data/lib/kward/rpc/transcript_normalizer.rb +14 -10
  63. data/lib/kward/session_store.rb +55 -16
  64. data/lib/kward/skills/registry.rb +21 -1
  65. data/lib/kward/tools/base.rb +14 -0
  66. data/lib/kward/tools/registry.rb +11 -16
  67. data/lib/kward/tools/search/code.rb +1 -1
  68. data/lib/kward/tools/search/web.rb +1 -1
  69. data/lib/kward/tools/tool_call.rb +0 -4
  70. data/lib/kward/transcript_export.rb +1 -1
  71. data/lib/kward/update_check.rb +12 -7
  72. data/lib/kward/version.rb +1 -1
  73. data/templates/default/kward_navigation.rb +5 -1
  74. data/templates/default/layout/html/layout.erb +0 -2
  75. metadata +5 -12
  76. data/lib/kward/workers/git_guard.rb +0 -93
  77. data/lib/kward/workers/job.rb +0 -99
  78. data/lib/kward/workers/live_view.rb +0 -49
  79. data/lib/kward/workers/manager.rb +0 -328
  80. data/lib/kward/workers/queue_runner.rb +0 -166
  81. data/lib/kward/workers/queue_store.rb +0 -112
  82. data/lib/kward/workers/store.rb +0 -72
  83. data/lib/kward/workers/tool_policy.rb +0 -23
  84. data/lib/kward/workers/worker.rb +0 -82
  85. data/lib/kward/workers/write_lock.rb +0 -38
  86. 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
- store.recent(limit: requested_limit, keep_empty_path: current_session_path)
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) if session_idle?(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.cancel_requested = true
383
- turn.cancellation&.cancel!
384
- emit_turn_event(turn, "turnCancelRequested", {})
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
- turn: turn_payload(turn),
400
- events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
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
- return if ["completed", "failed", "canceled"].include?(turn.status)
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.status = status
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).filter_map do |item|
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 = reasoning_item_text(item)
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 } unless text.empty?
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 reasoning_item_text(item)
181
- summary = ToolCall.value(item, :summary)
182
- content = ToolCall.value(item, :content)
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
@@ -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 keep visible
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
- sessions = recent_sessions(keep_empty_path: keep_empty_path)
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
- sessions = recent_sessions(keep_empty_path: keep_empty_path)
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
- keep_empty_path = File.expand_path(keep_empty_path) unless keep_empty_path.to_s.empty?
775
- Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
776
- info = session_info(path)
777
- next unless info
778
- next if delete_empty_unnamed_session_info(info, keep_empty_path: keep_empty_path)
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
- info
781
- end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
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, keep_empty_path: nil)
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 keep_empty_path && File.expand_path(info.path) == keep_empty_path
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
- # Parsed skill metadata and instruction path.
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
@@ -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",
@@ -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, :writer_id
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, write_lock: nil, writer_id: 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
+ 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
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require "fileutils"
3
3
  require "json"
4
4
  require "net/http"
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require "json"
3
3
  require "net/http"
4
4
  require "nokogiri"
@@ -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
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require_relative "markdown_transcript"
3
3
 
4
4
  # Namespace for the Kward CLI agent runtime.
@@ -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
- latest_version = cache["latest_version"].to_s
36
- return nil if latest_version.empty?
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
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.75.0"
4
+ VERSION = "0.77.0"
5
5
  end