kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,466 @@
1
+ require "json"
2
+ require "time"
3
+ require_relative "../config_files"
4
+ require_relative "logger"
5
+
6
+ module Kward
7
+ class TelemetryStats
8
+ DEFAULT_RANGE = "1 week"
9
+ UNITS = %w[minute hour day week month year].freeze
10
+ USAGE = "Usage: /stats [N minutes|hours|days|weeks|months|years] (default: 1 week)".freeze
11
+ TOKEN_CSV_HEADER = %w[bucket_start bucket_end provider model events input_tokens output_tokens cache_read_tokens cache_write_tokens total_tokens].freeze
12
+ TOKEN_BUCKETS = %w[second minute hour day week month year].freeze
13
+
14
+ Result = Struct.new(:range, :log_dir, :enabled_categories, :record_count, :records_by_category, :records_by_event, :tokens, :performance, :tools, :errors, keyword_init: true) do
15
+ def to_h
16
+ {
17
+ range: {
18
+ input: range[:input],
19
+ count: range[:count],
20
+ unit: range[:unit],
21
+ startAt: range[:start_at].iso8601,
22
+ endAt: range[:end_at].iso8601
23
+ },
24
+ logDir: log_dir,
25
+ enabledCategories: enabled_categories,
26
+ recordCount: record_count,
27
+ recordsByCategory: records_by_category,
28
+ recordsByEvent: records_by_event,
29
+ usageStats: tokens,
30
+ performance: performance,
31
+ tools: tools,
32
+ errors: errors
33
+ }
34
+ end
35
+ end
36
+
37
+ def initialize(telemetry_logger: TelemetryLogger.new, clock: Time)
38
+ @telemetry_logger = telemetry_logger
39
+ @clock = clock
40
+ end
41
+
42
+ def enabled_categories
43
+ @telemetry_logger.enabled_categories
44
+ end
45
+
46
+ def log_dir
47
+ @telemetry_logger.log_directory
48
+ end
49
+
50
+ def collect(argument = "")
51
+ categories = enabled_categories
52
+ raise ArgumentError, "Telemetry logging is disabled. Enable logging and at least one category before using /stats." if categories.empty?
53
+
54
+ range = self.class.parse_range(argument, now: @clock.now.utc)
55
+ records = read_records(range[:start_at], range[:end_at], categories)
56
+ build_result(range, categories, records)
57
+ end
58
+
59
+ def token_usage_csv(argument = "", bucket: nil)
60
+ categories = enabled_categories
61
+ raise ArgumentError, "Token telemetry logging is disabled. Enable logging and token logging before exporting token CSV." unless categories.include?("tokens")
62
+
63
+ range = self.class.parse_range(argument, now: @clock.now.utc)
64
+ bucket = self.class.normalize_bucket(bucket || range[:unit])
65
+ buckets = token_usage_buckets(range, bucket)
66
+ lines = [csv_row(TOKEN_CSV_HEADER)]
67
+ buckets.each do |_key, values|
68
+ lines << csv_row(TOKEN_CSV_HEADER.map { |column| token_csv_value(values, column) })
69
+ end
70
+ lines.join("\n") + "\n"
71
+ end
72
+
73
+ def self.parse_range(argument, now: Time.now.utc)
74
+ text = argument.to_s.strip
75
+ text = DEFAULT_RANGE if text.empty?
76
+ match = text.match(/\A(\d+)\s+([A-Za-z]+)\z/)
77
+ raise ArgumentError, USAGE unless match
78
+
79
+ count = match[1].to_i
80
+ unit = normalize_unit(match[2])
81
+ raise ArgumentError, USAGE unless count.positive? && unit
82
+
83
+ {
84
+ input: text,
85
+ count: count,
86
+ unit: unit,
87
+ start_at: calendar_start(now.utc, count, unit),
88
+ end_at: now.utc
89
+ }
90
+ end
91
+
92
+ def self.format(result)
93
+ lines = []
94
+ range = result.range
95
+ lines << "Stats for #{range[:input]} (#{range[:start_at].iso8601} to #{range[:end_at].iso8601})"
96
+ lines << "Log directory: #{result.log_dir}"
97
+ lines << "Enabled categories: #{result.enabled_categories.join(", ")}"
98
+ lines << "Records: #{result.record_count}"
99
+ lines << ""
100
+ lines << "Records by category:"
101
+ lines.concat(format_counts(result.records_by_category))
102
+ lines << ""
103
+ lines << "Records by event:"
104
+ lines.concat(format_counts(result.records_by_event))
105
+ lines << ""
106
+ lines << "Tokens:"
107
+ lines.concat(format_tokens(result.tokens))
108
+ lines << ""
109
+ lines << "Performance:"
110
+ lines.concat(format_performance(result.performance))
111
+ lines << ""
112
+ lines << "Tools:"
113
+ lines.concat(format_tools(result.tools))
114
+ lines << ""
115
+ lines << "Errors:"
116
+ lines.concat(format_errors(result.errors))
117
+ lines.join("\n")
118
+ end
119
+
120
+ def self.normalize_unit(unit)
121
+ text = unit.to_s.downcase
122
+ text = text.delete_suffix("s")
123
+ UNITS.include?(text) ? text : nil
124
+ end
125
+
126
+ def self.normalize_bucket(bucket)
127
+ text = bucket.to_s.downcase.strip
128
+ text = text.delete_suffix("s")
129
+ raise ArgumentError, "Bucket must be one of: #{TOKEN_BUCKETS.join(", ")}" unless TOKEN_BUCKETS.include?(text)
130
+
131
+ text
132
+ end
133
+
134
+ def self.calendar_start(now, count, unit)
135
+ case unit
136
+ when "minute"
137
+ Time.utc(now.year, now.month, now.day, now.hour, now.min) - ((count - 1) * 60)
138
+ when "hour"
139
+ Time.utc(now.year, now.month, now.day, now.hour) - ((count - 1) * 60 * 60)
140
+ when "day"
141
+ Time.utc(now.year, now.month, now.day) - ((count - 1) * 24 * 60 * 60)
142
+ when "week"
143
+ start_of_week = Time.utc(now.year, now.month, now.day) - ((now.wday + 6) % 7 * 24 * 60 * 60)
144
+ start_of_week - ((count - 1) * 7 * 24 * 60 * 60)
145
+ when "month"
146
+ shift_month_start(now, -(count - 1))
147
+ when "year"
148
+ Time.utc(now.year - count + 1, 1, 1)
149
+ else
150
+ raise ArgumentError, USAGE
151
+ end
152
+ end
153
+
154
+ def self.shift_month_start(now, offset)
155
+ month_index = (now.year * 12) + (now.month - 1) + offset
156
+ year = month_index / 12
157
+ month = (month_index % 12) + 1
158
+ Time.utc(year, month, 1)
159
+ end
160
+
161
+ def self.format_counts(counts)
162
+ return [" none"] if counts.empty?
163
+
164
+ counts.sort_by { |key, value| [-value, key.to_s] }.map { |key, value| " #{key}: #{value}" }
165
+ end
166
+
167
+ def self.format_tokens(tokens)
168
+ lines = [" model usage events: #{tokens[:modelUsageEvents]}"]
169
+ if tokens[:totals].empty?
170
+ lines << " token totals: none"
171
+ else
172
+ tokens[:totals].sort.each { |key, value| lines << " #{key}: #{value}" }
173
+ end
174
+ lines
175
+ end
176
+
177
+ def self.format_performance(performance)
178
+ return [" none"] if performance[:events].empty?
179
+
180
+ lines = []
181
+ performance[:events].sort.each do |event, stats|
182
+ lines << " #{event}: count=#{stats[:count]}, min=#{stats[:durationMs][:min]}, avg=#{stats[:durationMs][:avg]}, max=#{stats[:durationMs][:max]} ms"
183
+ lines << " statuses: #{inline_counts(stats[:statuses])}" unless stats[:statuses].empty?
184
+ end
185
+ lines
186
+ end
187
+
188
+ def self.format_tools(tools)
189
+ lines = [" calls: #{tools[:calls]}", " result bytes: #{tools[:resultBytes]}"]
190
+ lines << " by tool: #{inline_counts(tools[:byName])}"
191
+ lines << " by status: #{inline_counts(tools[:byStatus])}"
192
+ lines
193
+ end
194
+
195
+ def self.format_errors(errors)
196
+ lines = [" events: #{errors[:count]}"]
197
+ lines << " by event: #{inline_counts(errors[:byEvent])}"
198
+ lines << " by class: #{inline_counts(errors[:byClass])}"
199
+ lines << " by provider: #{inline_counts(errors[:byProvider])}"
200
+ lines << " by code: #{inline_counts(errors[:byCode])}"
201
+ lines
202
+ end
203
+
204
+ def self.inline_counts(counts)
205
+ return "none" if counts.empty?
206
+
207
+ counts.sort_by { |key, value| [-value, key.to_s] }.map { |key, value| "#{key}=#{value}" }.join(", ")
208
+ end
209
+
210
+ private
211
+
212
+ def read_records(start_at, end_at, categories)
213
+ records = []
214
+ each_record(start_at, end_at, categories) { |record, _timestamp| records << record }
215
+ records
216
+ end
217
+
218
+ def each_record(start_at, end_at, categories, reverse: false, stop_before_start: false)
219
+ return enum_for(:each_record, start_at, end_at, categories, reverse: reverse, stop_before_start: stop_before_start) unless block_given?
220
+ return unless Dir.exist?(log_dir)
221
+
222
+ category_set = categories.each_with_object({}) { |category, result| result[category] = true }
223
+ paths = log_paths_for_range(start_at, end_at)
224
+ paths = paths.reverse if reverse
225
+ paths.each do |path|
226
+ stop_file = false
227
+ each_line = reverse ? method(:reverse_each_line) : method(:forward_each_line)
228
+ each_line.call(path) do |line|
229
+ record = JSON.parse(line)
230
+ timestamp = parse_timestamp(record["timestamp"])
231
+ next unless timestamp
232
+ if stop_before_start && timestamp < start_at
233
+ stop_file = true
234
+ break
235
+ end
236
+ next unless timestamp >= start_at && timestamp <= end_at
237
+ next unless category_set[record["category"].to_s]
238
+
239
+ yield record, timestamp
240
+ rescue JSON::ParserError
241
+ nil
242
+ end
243
+ next if stop_file
244
+ end
245
+ end
246
+
247
+ def forward_each_line(path, &block)
248
+ File.foreach(path, chomp: true, &block)
249
+ end
250
+
251
+ def reverse_each_line(path, chunk_size: 64 * 1024)
252
+ File.open(path, "rb") do |file|
253
+ position = file.size
254
+ buffer = +""
255
+ while position.positive?
256
+ read_size = [chunk_size, position].min
257
+ position -= read_size
258
+ file.seek(position)
259
+ buffer = file.read(read_size) + buffer
260
+ lines = buffer.split("\n", -1)
261
+ buffer = lines.shift
262
+ lines.pop if position + read_size == file.size && lines.last == ""
263
+ lines.reverse_each { |line| yield line.chomp }
264
+ end
265
+ yield buffer.chomp unless buffer.empty?
266
+ end
267
+ end
268
+
269
+ def log_paths_for_range(start_at, end_at)
270
+ return [] unless Dir.exist?(log_dir)
271
+
272
+ start_date = start_at.utc.strftime("%Y-%m-%d")
273
+ end_date = end_at.utc.strftime("%Y-%m-%d")
274
+ Dir[File.join(log_dir, "*.jsonl")].select do |path|
275
+ date = File.basename(path)[/\A\d{4}-\d{2}-\d{2}/]
276
+ date && date >= start_date && date <= end_date
277
+ end.sort
278
+ end
279
+
280
+ def parse_timestamp(value)
281
+ Time.parse(value.to_s).utc
282
+ rescue ArgumentError
283
+ nil
284
+ end
285
+
286
+ def token_usage_buckets(range, bucket)
287
+ buckets = {}
288
+ each_record(range[:start_at], range[:end_at], ["tokens"], reverse: true, stop_before_start: true) do |record, timestamp|
289
+ next unless record["event"] == "model_usage"
290
+
291
+ usage = record["usage"]
292
+ next unless usage.is_a?(Hash)
293
+
294
+ start_at = bucket_start(timestamp, bucket)
295
+ end_at = bucket_end(start_at, bucket)
296
+ key = [start_at, record["provider"].to_s, record["model"].to_s]
297
+ values = buckets[key] ||= token_csv_bucket(start_at, end_at, record)
298
+ values["events"] += 1
299
+ %w[input_tokens output_tokens cache_read_tokens cache_write_tokens total_tokens].each do |column|
300
+ values[column] += usage[column].to_i if usage[column].is_a?(Numeric)
301
+ end
302
+ end
303
+ buckets.sort_by { |(start_at, provider, model), _values| [start_at, provider, model] }.to_h
304
+ end
305
+
306
+ def token_csv_bucket(start_at, end_at, record)
307
+ TOKEN_CSV_HEADER.each_with_object({}) do |column, result|
308
+ result[column] = 0
309
+ end.merge(
310
+ "bucket_start" => start_at.iso8601,
311
+ "bucket_end" => end_at.iso8601,
312
+ "provider" => record["provider"].to_s,
313
+ "model" => record["model"].to_s
314
+ )
315
+ end
316
+
317
+ def token_csv_value(values, column)
318
+ return values[column] if %w[bucket_start bucket_end provider model].include?(column)
319
+
320
+ values[column] || 0
321
+ end
322
+
323
+ def csv_row(values)
324
+ values.map { |value| csv_escape(value) }.join(",")
325
+ end
326
+
327
+ def csv_escape(value)
328
+ text = value.to_s
329
+ return text unless text.match?(/[",\n\r]/)
330
+
331
+ "\"#{text.gsub("\"", "\"\"")}\""
332
+ end
333
+
334
+ def bucket_start(timestamp, bucket)
335
+ case bucket
336
+ when "second"
337
+ Time.utc(timestamp.year, timestamp.month, timestamp.day, timestamp.hour, timestamp.min, timestamp.sec)
338
+ when "minute"
339
+ Time.utc(timestamp.year, timestamp.month, timestamp.day, timestamp.hour, timestamp.min)
340
+ when "hour"
341
+ Time.utc(timestamp.year, timestamp.month, timestamp.day, timestamp.hour)
342
+ when "day"
343
+ Time.utc(timestamp.year, timestamp.month, timestamp.day)
344
+ when "week"
345
+ Time.utc(timestamp.year, timestamp.month, timestamp.day) - ((timestamp.wday + 6) % 7 * 24 * 60 * 60)
346
+ when "month"
347
+ Time.utc(timestamp.year, timestamp.month, 1)
348
+ when "year"
349
+ Time.utc(timestamp.year, 1, 1)
350
+ else
351
+ raise ArgumentError, "Bucket must be one of: #{TOKEN_BUCKETS.join(", ")}"
352
+ end
353
+ end
354
+
355
+ def bucket_end(start_at, bucket)
356
+ case bucket
357
+ when "second"
358
+ start_at + 1
359
+ when "minute"
360
+ start_at + 60
361
+ when "hour"
362
+ start_at + (60 * 60)
363
+ when "day"
364
+ start_at + (24 * 60 * 60)
365
+ when "week"
366
+ start_at + (7 * 24 * 60 * 60)
367
+ when "month"
368
+ self.class.shift_month_start(start_at, 1)
369
+ when "year"
370
+ Time.utc(start_at.year + 1, 1, 1)
371
+ end
372
+ end
373
+
374
+ def build_result(range, categories, records)
375
+ Result.new(
376
+ range: range,
377
+ log_dir: log_dir,
378
+ enabled_categories: categories,
379
+ record_count: records.length,
380
+ records_by_category: count_by(records) { |record| record["category"] },
381
+ records_by_event: count_by(records) { |record| record["event"] },
382
+ tokens: token_stats(records),
383
+ performance: performance_stats(records),
384
+ tools: tool_stats(records),
385
+ errors: error_stats(records)
386
+ )
387
+ end
388
+
389
+ def count_by(records)
390
+ records.each_with_object({}) do |record, counts|
391
+ key = yield(record).to_s
392
+ next if key.empty?
393
+
394
+ counts[key] = counts.fetch(key, 0) + 1
395
+ end
396
+ end
397
+
398
+ def token_stats(records)
399
+ usage_records = records.select { |record| record["category"] == "tokens" && record["event"] == "model_usage" }
400
+ totals = Hash.new(0)
401
+ usage_records.each do |record|
402
+ usage = record["usage"]
403
+ next unless usage.is_a?(Hash)
404
+
405
+ usage.each do |key, value|
406
+ totals[key.to_s] += value if value.is_a?(Numeric)
407
+ end
408
+ end
409
+ { modelUsageEvents: usage_records.length, totals: totals.sort.to_h }
410
+ end
411
+
412
+ def performance_stats(records)
413
+ events = {}
414
+ records.select { |record| record["category"] == "performance" }.each do |record|
415
+ event = record["event"].to_s
416
+ next if event.empty?
417
+
418
+ stats = events[event] ||= { count: 0, statuses: Hash.new(0), durations: [] }
419
+ stats[:count] += 1
420
+ status = record["status"].to_s
421
+ stats[:statuses][status] += 1 unless status.empty?
422
+ stats[:durations] << record["duration_ms"].to_f if record["duration_ms"].is_a?(Numeric)
423
+ end
424
+
425
+ normalized = events.each_with_object({}) do |(event, stats), result|
426
+ result[event] = {
427
+ count: stats[:count],
428
+ statuses: stats[:statuses].sort.to_h,
429
+ durationMs: duration_stats(stats[:durations])
430
+ }
431
+ end
432
+ { events: normalized }
433
+ end
434
+
435
+ def duration_stats(values)
436
+ return { min: nil, avg: nil, max: nil } if values.empty?
437
+
438
+ {
439
+ min: values.min.round(1),
440
+ avg: (values.sum / values.length).round(1),
441
+ max: values.max.round(1)
442
+ }
443
+ end
444
+
445
+ def tool_stats(records)
446
+ tool_records = records.select { |record| record["category"] == "tools" && record["event"] == "tool_call" }
447
+ {
448
+ calls: tool_records.length,
449
+ resultBytes: tool_records.sum { |record| record["result_bytes"].is_a?(Numeric) ? record["result_bytes"] : 0 },
450
+ byName: count_by(tool_records) { |record| record["tool_name"] },
451
+ byStatus: count_by(tool_records) { |record| record["status"] }
452
+ }
453
+ end
454
+
455
+ def error_stats(records)
456
+ error_records = records.select { |record| record["category"] == "errors" }
457
+ {
458
+ count: error_records.length,
459
+ byEvent: count_by(error_records) { |record| record["event"] },
460
+ byClass: count_by(error_records) { |record| record["error_class"] },
461
+ byProvider: count_by(error_records) { |record| record["provider"] },
462
+ byCode: count_by(error_records) { |record| record["error_code"] }
463
+ }
464
+ end
465
+ end
466
+ end
@@ -0,0 +1,107 @@
1
+ require_relative "base"
2
+
3
+ module Kward
4
+ module Tools
5
+ class AskUserQuestion < Base
6
+ def initialize(prompt:)
7
+ @prompt = prompt
8
+ super(
9
+ "ask_user_question",
10
+ "Ask the user one to four structured clarification questions in interactive mode. Supports single-select choices and custom typed answers.",
11
+ properties: {
12
+ questions: {
13
+ type: "array",
14
+ minItems: 1,
15
+ maxItems: 4,
16
+ items: {
17
+ type: "object",
18
+ properties: {
19
+ question: { type: "string", description: "The question to ask." },
20
+ header: { type: "string", description: "Short label shown in the overlay." },
21
+ options: {
22
+ type: "array",
23
+ minItems: 2,
24
+ maxItems: 4,
25
+ items: {
26
+ type: "object",
27
+ properties: {
28
+ label: { type: "string", description: "Choice label." },
29
+ description: { type: "string", description: "Choice explanation." }
30
+ },
31
+ required: ["label", "description"],
32
+ additionalProperties: false
33
+ }
34
+ }
35
+ },
36
+ required: ["question", "header", "options"],
37
+ additionalProperties: false
38
+ }
39
+ }
40
+ },
41
+ required: ["questions"]
42
+ )
43
+ end
44
+
45
+ def call(args, _conversation, cancellation: nil)
46
+ return "Error: ask_user_question requires interactive prompt support." unless @prompt.respond_to?(:ask_user_question)
47
+
48
+ questions = validated_questions(args)
49
+ return questions if questions.is_a?(String)
50
+
51
+ answers = prompt_ask_user_question(questions, cancellation: cancellation)
52
+ return "Cancelled." if answers.nil?
53
+
54
+ answers.map { |answer| "#{answer[:question]}: #{answer[:answer]}" }.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ def prompt_ask_user_question(questions, cancellation: nil)
60
+ method = @prompt.method(:ask_user_question)
61
+ supports_cancellation = method.parameters.any? do |type, name|
62
+ type == :keyrest || (type == :key && name == :cancellation) || (type == :keyreq && name == :cancellation)
63
+ end
64
+ return @prompt.ask_user_question(questions, cancellation: cancellation) if supports_cancellation
65
+
66
+ @prompt.ask_user_question(questions)
67
+ end
68
+
69
+ def validated_questions(args)
70
+ questions = argument(args, :questions)
71
+ return "Error: ask_user_question requires questions." unless questions.is_a?(Array)
72
+ return "Error: ask_user_question requires 1 to 4 questions." unless questions.length.between?(1, 4)
73
+
74
+ questions.map.with_index(1) do |question, index|
75
+ return "Error: question #{index} must be an object." unless question.respond_to?(:key?)
76
+ return "Error: question #{index} uses unsupported multiSelect." if question.key?("multiSelect") || question.key?(:multiSelect)
77
+
78
+ text = question_value(question, :question).to_s.strip
79
+ header = question_value(question, :header).to_s.strip
80
+ options = question_value(question, :options)
81
+ return "Error: question #{index} requires question and header." if text.empty? || header.empty?
82
+ return "Error: question #{index} requires 2 to 4 options." unless options.is_a?(Array) && options.length.between?(2, 4)
83
+
84
+ normalized_options = options.map.with_index(1) do |option, option_index|
85
+ return "Error: question #{index} option #{option_index} must be an object." unless option.respond_to?(:key?)
86
+ return "Error: question #{index} option #{option_index} uses unsupported preview." if option.key?("preview") || option.key?(:preview)
87
+
88
+ label = question_value(option, :label).to_s.strip
89
+ description = question_value(option, :description).to_s.strip
90
+ return "Error: question #{index} option #{option_index} requires label and description." if label.empty? || description.empty?
91
+
92
+ { label: label, description: description }
93
+ end
94
+
95
+ { question: text, header: header, options: normalized_options }
96
+ end
97
+ end
98
+
99
+ def question_value(object, key)
100
+ return object[key] if object.key?(key)
101
+ return object[key.to_s] if object.key?(key.to_s)
102
+
103
+ nil
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,45 @@
1
+ module Kward
2
+ module Tools
3
+ class Base
4
+ attr_reader :name
5
+
6
+ def initialize(name, description, properties: {}, required: [])
7
+ @name = name
8
+ @description = description
9
+ @properties = properties
10
+ @required = required
11
+ end
12
+
13
+ def schema
14
+ {
15
+ type: "function",
16
+ function: {
17
+ name: @name,
18
+ description: @description,
19
+ parameters: {
20
+ type: "object",
21
+ properties: @properties,
22
+ required: @required,
23
+ additionalProperties: false
24
+ }
25
+ }
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def argument(args, key, default = nil)
32
+ return args[key] if args.key?(key)
33
+ return args[key.to_s] if args.key?(key.to_s)
34
+
35
+ default
36
+ end
37
+
38
+ def agents_file_changed?(workspace, path, result)
39
+ result.to_s.start_with?("Wrote ", "Edited ") && File.basename(path.to_s) == "AGENTS.md" && workspace.resolved_path(path) == File.join(workspace.root.to_s, "AGENTS.md")
40
+ rescue StandardError
41
+ false
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,65 @@
1
+ require_relative "base"
2
+
3
+ module Kward
4
+ module Tools
5
+ class CodeSearch < Base
6
+ def initialize(code_search:)
7
+ @code_search = code_search
8
+ super(
9
+ "code_search",
10
+ "Find package repos, cache GitHub repos, and search/read bounded snippets.",
11
+ properties: {
12
+ action: {
13
+ type: "string",
14
+ enum: %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache],
15
+ description: "Operation."
16
+ },
17
+ ecosystem: {
18
+ type: "string",
19
+ enum: %w[rubygems npm pypi crates go],
20
+ description: "Package ecosystem."
21
+ },
22
+ package: {
23
+ type: "string",
24
+ description: "Package."
25
+ },
26
+ query: {
27
+ type: "string",
28
+ description: "Search query."
29
+ },
30
+ repo: {
31
+ type: "string",
32
+ description: "GitHub URL or owner/name."
33
+ },
34
+ path: {
35
+ type: "string",
36
+ description: "Repo-relative path."
37
+ },
38
+ start_line: {
39
+ type: "integer",
40
+ description: "1-indexed start line."
41
+ },
42
+ line_count: {
43
+ type: "integer",
44
+ description: "Line count; capped."
45
+ },
46
+ max_results: {
47
+ type: "integer",
48
+ description: "Max results; capped at 50."
49
+ },
50
+ context_lines: {
51
+ type: "integer",
52
+ description: "Context lines; capped at 5."
53
+ }
54
+ },
55
+ required: ["action"]
56
+ )
57
+ end
58
+
59
+ def call(args, _conversation, cancellation: nil)
60
+ cancellation&.raise_if_cancelled!
61
+ @code_search.call(args)
62
+ end
63
+ end
64
+ end
65
+ end