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,56 @@
1
+ require "thread"
2
+ require "time"
3
+
4
+ module Kward
5
+ class Steering
6
+ Event = Struct.new(:input, :created_at, keyword_init: true)
7
+
8
+ def initialize(on_submit: nil)
9
+ @listeners = []
10
+ @listeners << on_submit if on_submit
11
+ @events = []
12
+ @mutex = Mutex.new
13
+ @condition = ConditionVariable.new
14
+ end
15
+
16
+ def on_submit(&block)
17
+ return unless block
18
+
19
+ @mutex.synchronize { @listeners << block }
20
+ lambda do
21
+ @mutex.synchronize { @listeners.delete(block) }
22
+ end
23
+ end
24
+
25
+ def submit(input)
26
+ event = Event.new(input: input, created_at: Time.now.utc.iso8601(3))
27
+ listeners = nil
28
+ @mutex.synchronize do
29
+ @events << event
30
+ listeners = @listeners.dup
31
+ @condition.broadcast
32
+ end
33
+ listeners.each { |listener| listener.call(event) }
34
+ event
35
+ end
36
+
37
+ def wait(after: 0, timeout: nil)
38
+ deadline = timeout ? Time.now + timeout.to_f : nil
39
+ @mutex.synchronize do
40
+ loop do
41
+ event = @events[after]
42
+ return event if event
43
+
44
+ if deadline
45
+ remaining = deadline - Time.now
46
+ return nil if remaining <= 0
47
+
48
+ @condition.wait(@mutex, remaining)
49
+ else
50
+ @condition.wait(@mutex)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,195 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+ require_relative "../config_files"
5
+ require_relative "../rpc/redactor"
6
+
7
+ module Kward
8
+ class TelemetryLogger
9
+ CATEGORIES = %w[tokens performance tools errors].freeze
10
+ ENV_KEYS = {
11
+ "enabled" => "KWARD_LOGGING",
12
+ "tokens" => "KWARD_LOGGING_TOKENS",
13
+ "performance" => "KWARD_LOGGING_PERFORMANCE",
14
+ "tools" => "KWARD_LOGGING_TOOLS",
15
+ "errors" => "KWARD_LOGGING_ERRORS"
16
+ }.freeze
17
+ DEFAULT_MAX_BYTES = 10 * 1024 * 1024
18
+
19
+ def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
20
+ @config_path = config_path
21
+ @log_dir = log_dir
22
+ @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
23
+ @clock = clock
24
+ @monotonic_clock = monotonic_clock
25
+ @error_output = error_output
26
+ @mutex = Mutex.new
27
+ @warned = false
28
+ end
29
+
30
+ def enabled?(category)
31
+ settings = current_settings
32
+ settings["enabled"] && settings[category.to_s]
33
+ end
34
+
35
+ def enabled_categories
36
+ settings = current_settings
37
+ return [] unless settings["enabled"]
38
+
39
+ CATEGORIES.select { |category| settings[category] }
40
+ end
41
+
42
+ def log_directory
43
+ log_dir
44
+ end
45
+
46
+ def log(category, event, payload = {})
47
+ category = category.to_s
48
+ return false unless enabled?(category)
49
+
50
+ record = sanitize_record(payload).merge(
51
+ "timestamp" => @clock.now.utc.iso8601(3),
52
+ "category" => category,
53
+ "event" => event.to_s
54
+ )
55
+ write_record(record)
56
+ true
57
+ rescue StandardError => e
58
+ warn_once(e)
59
+ false
60
+ end
61
+
62
+ def duration_ms(started_at)
63
+ ((monotonic_now - started_at.to_f) * 1000).round(1)
64
+ end
65
+
66
+ def monotonic_now
67
+ @monotonic_clock.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ end
69
+
70
+ def self.error_payload(error)
71
+ payload = { "error_class" => error.class.name }
72
+ if error.respond_to?(:provider) && error.respond_to?(:code)
73
+ payload["provider"] = error.provider
74
+ payload["error_code"] = error.code
75
+ payload["error_message"] = "#{error.provider} request failed: #{error.code}"
76
+ else
77
+ payload["error_message"] = RPC::Redactor.redact_string(error.message.to_s)[0, 500]
78
+ end
79
+ payload
80
+ end
81
+
82
+ private
83
+
84
+ def current_settings
85
+ values = config_settings
86
+ ENV_KEYS.each do |key, env_key|
87
+ env_value = parse_bool(ENV[env_key])
88
+ values[key] = env_value unless env_value.nil?
89
+ end
90
+ values
91
+ end
92
+
93
+ def config_settings
94
+ logging = ConfigFiles.read_config(@config_path)["logging"]
95
+ logging = {} unless logging.is_a?(Hash)
96
+ {
97
+ "enabled" => truthy?(logging["enabled"]),
98
+ "tokens" => truthy?(logging["tokens"]),
99
+ "performance" => truthy?(logging["performance"]),
100
+ "tools" => truthy?(logging["tools"]),
101
+ "errors" => truthy?(logging["errors"])
102
+ }
103
+ rescue StandardError
104
+ CATEGORIES.each_with_object({ "enabled" => false }) { |category, result| result[category] = false }
105
+ end
106
+
107
+ def truthy?(value)
108
+ value == true
109
+ end
110
+
111
+ def parse_bool(value)
112
+ return nil if value.nil?
113
+
114
+ text = value.to_s.strip.downcase
115
+ return true if %w[1 true yes on].include?(text)
116
+ return false if %w[0 false no off].include?(text)
117
+
118
+ nil
119
+ end
120
+
121
+ def sanitize_record(payload)
122
+ sanitized = redact(payload || {})
123
+ sanitized.is_a?(Hash) ? sanitized : { "value" => sanitized }
124
+ end
125
+
126
+ def redact(value)
127
+ case value
128
+ when Hash
129
+ value.each_with_object({}) do |(key, item), result|
130
+ result[key] = secret_key?(key) ? "[REDACTED]" : redact(item)
131
+ end
132
+ when Array
133
+ value.map { |item| redact(item) }
134
+ when String
135
+ RPC::Redactor.redact_string(value)
136
+ else
137
+ value
138
+ end
139
+ end
140
+
141
+ def secret_key?(key)
142
+ text = key.to_s
143
+ return false if token_count_key?(text)
144
+
145
+ RPC::Redactor.secret_key?(text)
146
+ end
147
+
148
+ def token_count_key?(key)
149
+ key.match?(/\A(?:input|output|cache_read|cache_write|total)_tokens\z/) || key == "estimated"
150
+ end
151
+
152
+ def write_record(record)
153
+ @mutex.synchronize do
154
+ dir = log_dir
155
+ FileUtils.mkdir_p(dir, mode: 0o700)
156
+ path = current_log_path(dir)
157
+ File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
158
+ file.write(JSON.generate(record))
159
+ file.write("\n")
160
+ end
161
+ File.chmod(0o600, path)
162
+ end
163
+ end
164
+
165
+ def log_dir
166
+ @log_dir || File.join(File.dirname(File.expand_path(@config_path)), "logs")
167
+ end
168
+
169
+ def current_log_path(dir)
170
+ base = File.join(dir, "#{@clock.now.utc.strftime("%Y-%m-%d")}.jsonl")
171
+ return base if writable_log_path?(base)
172
+
173
+ index = 1
174
+ loop do
175
+ path = base.sub(/\.jsonl\z/, "-#{index}.jsonl")
176
+ return path if writable_log_path?(path)
177
+
178
+ index += 1
179
+ end
180
+ end
181
+
182
+ def writable_log_path?(path)
183
+ !File.exist?(path) || File.size(path) < @max_bytes
184
+ end
185
+
186
+ def warn_once(error)
187
+ return if @warned
188
+
189
+ @warned = true
190
+ @error_output&.puts("Warning: telemetry logging failed: #{error.message}")
191
+ rescue StandardError
192
+ nil
193
+ end
194
+ end
195
+ end