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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- 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
|