kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -0,0 +1,136 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Kward
6
+ module Telegram
7
+ # Small dependency-free client for the Telegram Bot API.
8
+ class BotApi
9
+ API_BASE_URL = "https://api.telegram.org".freeze
10
+ MAX_RESPONSE_BYTES = 5 * 1024 * 1024
11
+
12
+ class Error < StandardError
13
+ attr_reader :method, :error_code, :parameters
14
+
15
+ def initialize(method, description, error_code: nil, parameters: nil)
16
+ @method = method
17
+ @error_code = error_code
18
+ @parameters = parameters
19
+ super("Telegram #{method} failed: #{description.to_s}")
20
+ end
21
+ end
22
+
23
+ class RateLimitError < Error
24
+ attr_reader :retry_after
25
+
26
+ def initialize(method, description, retry_after:, error_code: nil, parameters: nil)
27
+ @retry_after = Integer(retry_after)
28
+ super(method, description, error_code: error_code, parameters: parameters)
29
+ end
30
+ end
31
+
32
+ def initialize(token:, requester: nil, base_url: API_BASE_URL)
33
+ @token = token.to_s
34
+ raise ArgumentError, "Telegram bot token is required" if @token.empty?
35
+
36
+ @requester = requester || method(:request)
37
+ @base_url = base_url.to_s.sub(%r{/\z}, "")
38
+ end
39
+
40
+ def get_me
41
+ call("getMe")
42
+ end
43
+
44
+ def delete_webhook(drop_pending_updates: false)
45
+ call("deleteWebhook", "drop_pending_updates" => !!drop_pending_updates)
46
+ end
47
+
48
+ def get_updates(offset: nil, timeout: 25)
49
+ timeout = Integer(timeout)
50
+ raise ArgumentError, "Telegram polling timeout must be between 0 and 50 seconds" unless (0..50).cover?(timeout)
51
+
52
+ params = {
53
+ "timeout" => timeout,
54
+ "allowed_updates" => JSON.generate(%w[message callback_query])
55
+ }
56
+ params["offset"] = Integer(offset) unless offset.nil?
57
+ Array(call("getUpdates", params))
58
+ end
59
+
60
+ def answer_callback_query(callback_query_id:, text: nil, show_alert: false)
61
+ params = {
62
+ "callback_query_id" => callback_query_id.to_s,
63
+ "show_alert" => !!show_alert
64
+ }
65
+ params["text"] = text.to_s unless text.nil?
66
+ call("answerCallbackQuery", params)
67
+ end
68
+
69
+ def send_message(chat_id:, text:, reply_to_message_id: nil, reply_markup: nil)
70
+ text = text.to_s
71
+ raise ArgumentError, "Telegram message text is required" if text.empty?
72
+
73
+ params = { "chat_id" => Integer(chat_id), "text" => text }
74
+ unless reply_to_message_id.nil?
75
+ params["reply_parameters"] = JSON.generate({ "message_id" => Integer(reply_to_message_id) })
76
+ end
77
+ params["reply_markup"] = JSON.generate(reply_markup) unless reply_markup.nil?
78
+ call("sendMessage", params)
79
+ end
80
+
81
+ private
82
+
83
+ def call(method, params = {})
84
+ response = @requester.call(method, params)
85
+ unless response.is_a?(Hash) && response["ok"]
86
+ raise_error(method, response || {})
87
+ end
88
+
89
+ response["result"]
90
+ end
91
+
92
+ def raise_error(method, response)
93
+ description = (response["description"] || "unknown API error").to_s.gsub(@token, "[REDACTED]")
94
+ parameters = response["parameters"] || {}
95
+ if response["error_code"].to_i == 429 && parameters["retry_after"]
96
+ raise RateLimitError.new(
97
+ method,
98
+ description,
99
+ retry_after: parameters.fetch("retry_after"),
100
+ error_code: response["error_code"],
101
+ parameters: parameters
102
+ )
103
+ end
104
+
105
+ raise Error.new(
106
+ method,
107
+ description,
108
+ error_code: response["error_code"],
109
+ parameters: parameters
110
+ )
111
+ end
112
+
113
+ def request(method, params)
114
+ uri = URI.parse("#{@base_url}/bot#{@token}/#{method}")
115
+ request = Net::HTTP::Post.new(uri)
116
+ request.set_form_data(params.transform_values(&:to_s))
117
+
118
+ response = Net::HTTP.start(
119
+ uri.host,
120
+ uri.port,
121
+ use_ssl: uri.scheme == "https",
122
+ open_timeout: 5,
123
+ read_timeout: params.fetch("timeout", 10).to_i + 10
124
+ ) { |http| http.request(request) }
125
+ body = response.body.to_s
126
+ raise Error.new(method, "response was too large") if body.bytesize > MAX_RESPONSE_BYTES
127
+
128
+ JSON.parse(body)
129
+ rescue JSON::ParserError
130
+ raise Error.new(method, "response was not valid JSON")
131
+ rescue URI::InvalidURIError
132
+ raise Error.new(method, "invalid Telegram API URL")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,304 @@
1
+ require "thread"
2
+ require_relative "telegram_api"
3
+
4
+ module Kward
5
+ module Telegram
6
+ # Long-polling Telegram adapter backed by normal Kward sessions.
7
+ class Transport
8
+ TELEGRAM_MESSAGE_LIMIT = 4096
9
+ DEFAULT_POLL_TIMEOUT = 25
10
+ MAX_RETRY_DELAY = 30
11
+
12
+ def initialize(host:, config:, api: nil, sleeper: nil)
13
+ @host = host
14
+ @config = config || {}
15
+ @token = host.secret("bot_token", env: "TELEGRAM_BOT_TOKEN")
16
+ @workspace = required_config("workspace")
17
+ @allowed_user_ids = required_ids("allowed_user_ids")
18
+ @allowed_chat_ids = required_ids("allowed_chat_ids")
19
+ @poll_timeout = Integer(@config.fetch("poll_timeout_seconds", DEFAULT_POLL_TIMEOUT))
20
+ raise ArgumentError, "poll_timeout_seconds must be between 0 and 50" unless (0..50).cover?(@poll_timeout)
21
+
22
+ @api = api || BotApi.new(token: @token)
23
+ @sleeper = sleeper || method(:sleep)
24
+ @state_mutex = Mutex.new
25
+ @stop = false
26
+ @thread = nil
27
+ @last_error = nil
28
+ end
29
+
30
+ def start
31
+ @api.delete_webhook
32
+ @api.get_me
33
+ @host.interactions.subscribe { |request| handle_interaction(request) }
34
+ @state_mutex.synchronize do
35
+ @stop = false
36
+ @last_error = nil
37
+ @thread = Thread.new { poll_loop }
38
+ end
39
+ nil
40
+ end
41
+
42
+ def stop
43
+ thread = @state_mutex.synchronize do
44
+ @stop = true
45
+ @thread
46
+ end
47
+ return nil unless thread
48
+
49
+ thread.join(@poll_timeout + 10)
50
+ thread.kill if thread.alive?
51
+ @state_mutex.synchronize { @thread = nil }
52
+ nil
53
+ end
54
+
55
+ def health
56
+ @state_mutex.synchronize do
57
+ {
58
+ state: @thread&.alive? ? "running" : "stopped",
59
+ error: @last_error
60
+ }
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def poll_loop
67
+ offset = Integer(@host.storage.get("telegram:offset") || 0)
68
+ until stopping?
69
+ begin
70
+ updates = @api.get_updates(offset: offset, timeout: @poll_timeout)
71
+ updates.each do |update|
72
+ update_id = Integer(update.fetch("update_id"))
73
+ handle_update(update)
74
+ offset = [offset, update_id + 1].max
75
+ @host.storage.put("telegram:offset", offset)
76
+ end
77
+ rescue BotApi::RateLimitError => error
78
+ record_error(error)
79
+ wait_for([error.retry_after, MAX_RETRY_DELAY].min)
80
+ rescue StandardError => error
81
+ record_error(error)
82
+ wait_for(2)
83
+ end
84
+ end
85
+ end
86
+
87
+ def handle_update(update)
88
+ if update["callback_query"]
89
+ handle_callback(update.fetch("callback_query"))
90
+ elsif update["message"]
91
+ handle_message(update.fetch("message"), update.fetch("update_id"))
92
+ end
93
+ rescue StandardError => error
94
+ record_error(error)
95
+ end
96
+
97
+ def handle_message(message, update_id)
98
+ text = message["text"].to_s
99
+ return if text.empty?
100
+
101
+ user_id = Integer(message.fetch("from").fetch("id"))
102
+ chat_id = Integer(message.fetch("chat").fetch("id"))
103
+ return unless allowed?(user_id, chat_id)
104
+ return unless @host.storage.claim("telegram:update:#{update_id}")
105
+
106
+ conversation = Kward::Transport.conversation_key(
107
+ transport_id: @host.transport_id,
108
+ external_id: "chat:#{chat_id}"
109
+ )
110
+ actor = Kward::Transport.actor(
111
+ id: "telegram:user:#{user_id}",
112
+ display_name: message.dig("from", "username") || user_id.to_s,
113
+ metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
114
+ )
115
+ @host.authorize!(:telegram_message, actor: actor, conversation: conversation)
116
+
117
+ session = @host.sessions.resolve(
118
+ conversation: conversation,
119
+ actor: actor,
120
+ workspace_root: @workspace,
121
+ name: "Telegram chat #{chat_id}"
122
+ )
123
+ remember_session(session, conversation, chat_id, actor)
124
+
125
+ turn = session.start_turn(text)
126
+ @host.storage.put("telegram:turn:#{turn.id}", {
127
+ "chat_id" => chat_id,
128
+ "session_id" => session.id,
129
+ "reply_to_message_id" => message["message_id"]
130
+ })
131
+ turn.subscribe { |event| handle_turn_event(event) }
132
+ end
133
+
134
+ def handle_turn_event(event)
135
+ return unless %w[answer assistantMessage assistant_message].include?(event.type.to_s)
136
+
137
+ turn = @host.storage.get("telegram:turn:#{event.turn_id}")
138
+ return unless turn
139
+ return if @host.storage.get("telegram:delivered:#{event.turn_id}")
140
+
141
+ content = event.payload[:content] || event.payload["content"]
142
+ if content.to_s.empty? && %w[assistantMessage assistant_message].include?(event.type.to_s)
143
+ message = event.payload[:message] || event.payload["message"]
144
+ content = message[:content] || message["content"] if message.is_a?(Hash)
145
+ end
146
+ return if content.to_s.empty?
147
+
148
+ split_message(content.to_s).each do |part|
149
+ @api.send_message(
150
+ chat_id: turn.fetch("chat_id"),
151
+ text: part,
152
+ reply_to_message_id: turn["reply_to_message_id"]
153
+ )
154
+ end
155
+ @host.storage.put("telegram:delivered:#{event.turn_id}", true)
156
+ rescue StandardError => error
157
+ record_error(error)
158
+ end
159
+
160
+ def handle_interaction(request)
161
+ session = @host.storage.get("telegram:session:#{request.session_id}")
162
+ return unless session
163
+
164
+ record = {
165
+ "chat_id" => session.fetch("chat_id"),
166
+ "session_id" => request.session_id,
167
+ "external_id" => session.fetch("external_id"),
168
+ "actor_id" => session.fetch("actor_id"),
169
+ "actor_name" => session["actor_name"],
170
+ "kind" => request.kind.to_s
171
+ }
172
+ buttons = case request.kind.to_s
173
+ when "tool_approval"
174
+ record["answers"] = { "approve" => true, "deny" => false }
175
+ [[
176
+ { "text" => "Approve", "callback_data" => callback_data(request.id, "approve") },
177
+ { "text" => "Deny", "callback_data" => callback_data(request.id, "deny") }
178
+ ]]
179
+ when "question"
180
+ question, options = question_options(request)
181
+ return if options.empty?
182
+
183
+ record["question"] = question
184
+ record["answers"] = options.each_with_index.to_h { |option, index| [index.to_s, option] }
185
+ [options.each_with_index.map do |option, index|
186
+ { "text" => option, "callback_data" => callback_data(request.id, index.to_s) }
187
+ end]
188
+ else
189
+ return
190
+ end
191
+
192
+ @host.storage.put("telegram:interaction:#{request.id}", record)
193
+ @api.send_message(
194
+ chat_id: record.fetch("chat_id"),
195
+ text: request.prompt,
196
+ reply_markup: { "inline_keyboard" => buttons }
197
+ )
198
+ rescue StandardError => error
199
+ record_error(error)
200
+ end
201
+
202
+ def handle_callback(callback)
203
+ data = callback["data"].to_s
204
+ match = /\Akward:([^:]+):([^:]+)\z/.match(data)
205
+ return unless match
206
+
207
+ request_id, choice = match.captures
208
+ chat_id = Integer(callback.fetch("message").fetch("chat").fetch("id"))
209
+ user_id = Integer(callback.fetch("from").fetch("id"))
210
+ return unless allowed?(user_id, chat_id)
211
+
212
+ record = @host.storage.get("telegram:interaction:#{request_id}")
213
+ return unless record && record.fetch("chat_id") == chat_id
214
+ return unless record.fetch("actor_id") == "telegram:user:#{user_id}"
215
+ return if @host.storage.get("telegram:answered:#{request_id}")
216
+
217
+ conversation = Kward::Transport.conversation_key(
218
+ transport_id: @host.transport_id,
219
+ external_id: record.fetch("external_id")
220
+ )
221
+ actor = Kward::Transport.actor(
222
+ id: record.fetch("actor_id"),
223
+ display_name: record["actor_name"],
224
+ metadata: { provider: "telegram", user_id: user_id, chat_id: chat_id }
225
+ )
226
+ session = @host.sessions.resolve(
227
+ conversation: conversation,
228
+ actor: actor,
229
+ workspace_root: @workspace
230
+ )
231
+ answer = record.fetch("answers").fetch(choice)
232
+ answer = [{ question: record.fetch("question"), answer: answer }] if record.fetch("kind") == "question"
233
+ session.answer_interaction(request_id: request_id, answer: answer)
234
+ @host.storage.put("telegram:answered:#{request_id}", true)
235
+ @api.answer_callback_query(callback_query_id: callback.fetch("id"))
236
+ rescue StandardError => error
237
+ record_error(error)
238
+ end
239
+
240
+ def remember_session(session, conversation, chat_id, actor)
241
+ @host.storage.put("telegram:session:#{session.id}", {
242
+ "chat_id" => chat_id,
243
+ "external_id" => conversation.external_id,
244
+ "actor_id" => actor.id,
245
+ "actor_name" => actor.display_name
246
+ })
247
+ end
248
+
249
+ def question_options(request)
250
+ question = request.choices.first || {}
251
+ options = Array(question[:options] || question["options"]).map do |option|
252
+ (option[:label] || option["label"]).to_s
253
+ end.reject(&:empty?)
254
+ [question[:question] || question["question"] || request.prompt, options]
255
+ end
256
+
257
+ def callback_data(request_id, choice)
258
+ "kward:#{request_id}:#{choice}"
259
+ end
260
+
261
+ def split_message(text)
262
+ text.each_char.each_slice(TELEGRAM_MESSAGE_LIMIT).map(&:join)
263
+ end
264
+
265
+ def allowed?(user_id, chat_id)
266
+ @allowed_user_ids.include?(user_id) && @allowed_chat_ids.include?(chat_id)
267
+ end
268
+
269
+ def stopping?
270
+ @state_mutex.synchronize { @stop }
271
+ end
272
+
273
+ def wait_for(seconds)
274
+ @sleeper.call(seconds) unless stopping?
275
+ end
276
+
277
+ def record_error(error)
278
+ message = error.message.to_s.gsub(@token.to_s, "[REDACTED]")
279
+ summary = "#{error.class}: #{message}"
280
+ @state_mutex.synchronize { @last_error = summary }
281
+ @host.logger.error("Telegram transport error: #{summary}")
282
+ rescue StandardError
283
+ nil
284
+ end
285
+
286
+ def required_config(key)
287
+ value = @config[key] || @config[key.to_sym]
288
+ raise ArgumentError, "Telegram transport requires #{key}" if value.to_s.empty?
289
+
290
+ value.to_s
291
+ end
292
+
293
+ def required_ids(key)
294
+ values = @config[key] || @config[key.to_sym]
295
+ values = Array(values).map { |value| Integer(value) }
296
+ raise ArgumentError, "Telegram transport requires non-empty #{key}" if values.empty?
297
+
298
+ values.uniq.freeze
299
+ rescue ArgumentError, TypeError
300
+ raise ArgumentError, "Telegram transport #{key} must contain numeric IDs"
301
+ end
302
+ end
303
+ end
304
+ end
data/lib/kward/agent.rb CHANGED
@@ -28,11 +28,12 @@ module Kward
28
28
  # lowest layer that owns the behavior, and use `Agent` only for cross-step turn
29
29
  # coordination.
30
30
  class Agent
31
- def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new, hook_manager: nil, hook_context: nil)
31
+ def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil)
32
32
  @client = client
33
33
  @tool_registry = tool_registry
34
34
  @conversation = conversation
35
- @telemetry_logger = telemetry_logger
35
+ @warning_sink = warning_sink
36
+ @telemetry_logger = telemetry_logger || TelemetryLogger.new(warning_sink: warning_sink)
36
37
  @hook_manager = hook_manager
37
38
  @hook_context = hook_context
38
39
  end
@@ -191,9 +192,9 @@ module Kward
191
192
 
192
193
  def auto_compact_if_needed
193
194
  context_window = @client.current_context_window if @client.respond_to?(:current_context_window)
194
- Compactor.new(conversation: @conversation, client: @client).auto_compact_if_needed(context_window: context_window)
195
+ Compactor.new(conversation: @conversation, client: @client, warning_sink: @warning_sink).auto_compact_if_needed(context_window: context_window)
195
196
  rescue StandardError => e
196
- warn "Auto-compaction failed: #{e.message}"
197
+ emit_warning "Auto-compaction failed: #{e.message}"
197
198
  nil
198
199
  end
199
200
 
@@ -201,14 +202,18 @@ module Kward
201
202
  settings = Compaction::Settings.from_config
202
203
  return nil unless settings.enabled
203
204
 
204
- Compactor.new(conversation: @conversation, client: @client, settings: settings).compact(
205
+ Compactor.new(conversation: @conversation, client: @client, settings: settings, warning_sink: @warning_sink).compact(
205
206
  custom_instructions: "The previous model request exceeded the context window. Preserve the current task state and critical details needed to retry."
206
207
  )
207
208
  rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted, StandardError => compaction_error
208
- warn "Context overflow recovery failed: #{compaction_error.message}; original error: #{error.message}"
209
+ emit_warning "Context overflow recovery failed: #{compaction_error.message}; original error: #{error.message}"
209
210
  nil
210
211
  end
211
212
 
213
+ def emit_warning(message)
214
+ @warning_sink ? @warning_sink.call(message) : warn(message)
215
+ end
216
+
212
217
  def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
213
218
  reasoning_delta = lambda do |delta|
214
219
  cancellation&.raise_if_cancelled!
data/lib/kward/ansi.rb CHANGED
@@ -22,6 +22,7 @@ module Kward
22
22
  cyan: 36,
23
23
  gray: 90,
24
24
  grey: 90,
25
+ white: 97,
25
26
  primary_green: "38;2;138;160;106",
26
27
  bright_accent_green: "38;2;155;255;0",
27
28
  augen: "38;2;155;255;0",
@@ -0,0 +1,99 @@
1
+ require "json"
2
+ require_relative "../config_files"
3
+ require_relative "../private_file"
4
+ require_relative "../model/provider_catalog"
5
+
6
+ # Namespace for Kward credential storage.
7
+ module Kward
8
+ # Stores API keys outside the non-secret Kward configuration file.
9
+ class APIKeyStore
10
+ def initialize(path: APIKeyStore.default_path, config_path: ConfigFiles.config_path, env: ENV)
11
+ @path = File.expand_path(path)
12
+ @config_path = File.expand_path(config_path)
13
+ @env = env
14
+ end
15
+
16
+ attr_reader :path
17
+
18
+ def self.default_path
19
+ File.join(ConfigFiles.config_dir, "api_keys.json")
20
+ end
21
+
22
+ def fetch(provider_id)
23
+ provider = ProviderCatalog.fetch(provider_id)
24
+ environment_key(provider) || stored_key(provider.id)
25
+ end
26
+
27
+ def configured?(provider_id)
28
+ !fetch(provider_id).to_s.empty?
29
+ end
30
+
31
+ def store(provider_id, api_key)
32
+ provider = ProviderCatalog.fetch(provider_id)
33
+ raise ArgumentError, "#{provider.name} does not accept an API key" unless provider.api_key?
34
+
35
+ api_key = api_key.to_s.strip
36
+ raise ArgumentError, "API key must be a non-empty string" if api_key.empty?
37
+
38
+ keys = stored_keys
39
+ keys[provider.id] = api_key
40
+ write_keys(keys)
41
+ path
42
+ end
43
+
44
+ def delete(provider_id)
45
+ provider = ProviderCatalog.fetch(provider_id)
46
+ keys = stored_keys
47
+ return false unless keys.delete(provider.id)
48
+
49
+ write_keys(keys)
50
+ true
51
+ end
52
+
53
+ def stored?(provider_id)
54
+ !stored_key(provider_id).to_s.empty?
55
+ end
56
+
57
+ def stored_value(provider_id)
58
+ ProviderCatalog.fetch(provider_id)
59
+ stored_key(provider_id)
60
+ end
61
+
62
+ def migrate_openrouter_config_key!
63
+ return false if stored?("openrouter")
64
+
65
+ config = ConfigFiles.read_config(@config_path)
66
+ key = config["openrouter_api_key"].to_s.strip
67
+ return false if key.empty?
68
+
69
+ store("openrouter", key)
70
+ ConfigFiles.delete_config_key("openrouter_api_key", @config_path)
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ def environment_key(provider)
77
+ provider.api_key_env.map { |name| @env[name].to_s.strip }.find { |key| !key.empty? }
78
+ end
79
+
80
+ def stored_key(provider_id)
81
+ stored_keys[provider_id.to_s]
82
+ end
83
+
84
+ def stored_keys
85
+ return {} unless File.exist?(path)
86
+
87
+ data = JSON.parse(File.read(path))
88
+ raise "Invalid Kward API key file: #{path}" unless data.is_a?(Hash)
89
+
90
+ data.transform_keys(&:to_s).transform_values(&:to_s)
91
+ rescue JSON::ParserError => e
92
+ raise "Invalid Kward API key file: #{path}: #{e.message}"
93
+ end
94
+
95
+ def write_keys(keys)
96
+ PrivateFile.write_json(path, keys)
97
+ end
98
+ end
99
+ end