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,323 @@
1
+ require "base64"
2
+ require "digest"
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "socket"
7
+ require "time"
8
+ require "uri"
9
+ require_relative "file"
10
+
11
+ module Kward
12
+ class OpenAIOAuth
13
+ ISSUER = "https://auth.openai.com"
14
+ TOKEN_URL = URI("#{ISSUER}/oauth/token")
15
+ DEFAULT_PORT = 1455
16
+ CALLBACK_PATH = "/auth/callback"
17
+ SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke"
18
+ DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
19
+
20
+ attr_reader :auth_path
21
+
22
+ def initialize(auth_path: OpenAIOAuth.default_auth_path, client_id: nil, config_path: OpenAIOAuth.default_config_path, issuer: ISSUER)
23
+ @auth_path = File.expand_path(auth_path)
24
+ @client_id = client_id
25
+ @config_path = File.expand_path(config_path)
26
+ @issuer = issuer.delete_suffix("/")
27
+ end
28
+
29
+ def self.default_auth_path
30
+ File.expand_path(ENV["KWARD_AUTH_PATH"] || "~/.kward/auth.json")
31
+ end
32
+
33
+ def self.default_config_path
34
+ File.expand_path(ENV["KWARD_CONFIG_PATH"] || "~/.kward/config.json")
35
+ end
36
+
37
+ def access_token
38
+ auth = current_auth
39
+ auth&.fetch("tokens", {})&.fetch("access_token", nil)
40
+ end
41
+
42
+ def account_id
43
+ auth = current_auth
44
+ auth&.fetch("account_id", nil) || auth&.fetch("tokens", {})&.fetch("account_id", nil)
45
+ end
46
+
47
+ def logged_in?
48
+ !access_token.to_s.empty?
49
+ end
50
+
51
+ def login(prompt:, open_browser: true, timeout_seconds: 120)
52
+ flow = start_login_flow
53
+ pkce = flow[:pkce]
54
+ state = flow[:state]
55
+ server = flow[:server]
56
+ redirect_uri = flow[:redirect_uri]
57
+ url = flow[:authorization_url]
58
+
59
+ prompt.say("OpenAI login URL:\n#{url}\n")
60
+ prompt.say("Waiting for browser login. If it does not complete, paste the callback URL when prompted.")
61
+ browser_opened = open_browser && open_url(url)
62
+
63
+ code = wait_for_callback(server, expected_state: state, timeout_seconds: browser_opened ? timeout_seconds : 5)
64
+ unless code
65
+ input = prompt.ask("Paste callback URL or authorization code:")
66
+ code = authorization_code_from(input.to_s, expected_state: state)
67
+ end
68
+ raise "Missing authorization code" if code.to_s.empty?
69
+
70
+ complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier])
71
+ auth_path
72
+ ensure
73
+ server&.close unless server&.closed?
74
+ end
75
+
76
+ def authorization_url(redirect_uri:, code_challenge:, state:)
77
+ query = URI.encode_www_form(
78
+ response_type: "code",
79
+ client_id: client_id,
80
+ redirect_uri: redirect_uri,
81
+ scope: SCOPE,
82
+ code_challenge: code_challenge,
83
+ code_challenge_method: "S256",
84
+ id_token_add_organizations: "true",
85
+ codex_cli_simplified_flow: "true",
86
+ state: state,
87
+ originator: "kward"
88
+ )
89
+ "#{@issuer}/oauth/authorize?#{query}"
90
+ end
91
+
92
+ def start_login_flow
93
+ pkce = generate_pkce
94
+ state = random_urlsafe(32)
95
+ server = start_callback_server
96
+ redirect_uri = "http://localhost:#{server.addr[1]}#{CALLBACK_PATH}"
97
+ {
98
+ pkce: pkce,
99
+ state: state,
100
+ server: server,
101
+ redirect_uri: redirect_uri,
102
+ authorization_url: authorization_url(redirect_uri: redirect_uri, code_challenge: pkce[:challenge], state: state)
103
+ }
104
+ end
105
+
106
+ def wait_for_login_callback(server, expected_state:, timeout_seconds:)
107
+ wait_for_callback(server, expected_state: expected_state, timeout_seconds: timeout_seconds)
108
+ end
109
+
110
+ def complete_login_flow(code:, redirect_uri:, code_verifier:)
111
+ tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
112
+ save_auth(tokens: tokens)
113
+ tokens
114
+ end
115
+
116
+ def authorization_code_from(input, expected_state: nil)
117
+ value = input.strip
118
+ return "" if value.empty?
119
+
120
+ uri = URI.parse(value)
121
+ params = URI.decode_www_form(uri.query.to_s).to_h
122
+ if params.key?("code")
123
+ raise "OAuth state mismatch" if expected_state && params["state"] != expected_state
124
+
125
+ return params["code"]
126
+ end
127
+
128
+ value
129
+ rescue URI::InvalidURIError
130
+ value
131
+ end
132
+
133
+ def save_auth(tokens: {})
134
+ account_id = extract_account_id(tokens)
135
+ data = {
136
+ "auth_mode" => "openai_oauth",
137
+ "tokens" => account_id ? tokens.merge("account_id" => account_id) : tokens,
138
+ "account_id" => account_id,
139
+ "saved_at" => Time.now.utc.iso8601,
140
+ "expires_at" => expires_at_for(tokens)
141
+ }.compact
142
+
143
+ AuthFile.write_json(@auth_path, data)
144
+ end
145
+
146
+ def refresh!
147
+ auth = load_auth || raise("OpenAI OAuth login not found")
148
+ refresh_token = auth.fetch("tokens", {}).fetch("refresh_token", nil)
149
+ raise "OpenAI OAuth refresh token not found" if refresh_token.to_s.empty?
150
+
151
+ response = post_json(TOKEN_URL,
152
+ client_id: client_id,
153
+ grant_type: "refresh_token",
154
+ refresh_token: refresh_token)
155
+ refreshed = parse_successful_json(response, "OpenAI OAuth token refresh")
156
+ save_auth(tokens: (auth.fetch("tokens", {}) || {}).merge(refreshed))
157
+ load_auth
158
+ end
159
+
160
+ private
161
+
162
+ def current_auth
163
+ auth = load_auth
164
+ tokens = auth&.fetch("tokens", {}) || {}
165
+ return nil if tokens.empty?
166
+
167
+ if token_expired?(auth) && !tokens["refresh_token"].to_s.empty?
168
+ auth = refresh!
169
+ end
170
+
171
+ auth
172
+ end
173
+
174
+ def load_auth
175
+ return nil unless File.exist?(@auth_path)
176
+
177
+ JSON.parse(File.read(@auth_path))
178
+ rescue JSON::ParserError
179
+ nil
180
+ end
181
+
182
+ def client_id
183
+ return @client_id unless @client_id.to_s.strip.empty?
184
+
185
+ value = load_config.fetch("openai_oauth_client_id", "").to_s.strip
186
+ return value unless value.empty?
187
+
188
+ DEFAULT_CLIENT_ID
189
+ end
190
+
191
+ def load_config
192
+ return {} unless File.exist?(@config_path)
193
+
194
+ JSON.parse(File.read(@config_path))
195
+ rescue JSON::ParserError
196
+ raise "Invalid Kward config JSON: #{@config_path}"
197
+ end
198
+
199
+ def generate_pkce
200
+ verifier = random_urlsafe(64)
201
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
202
+ { verifier: verifier, challenge: challenge }
203
+ end
204
+
205
+ def random_urlsafe(bytes)
206
+ Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
207
+ end
208
+
209
+ def start_callback_server
210
+ TCPServer.new("localhost", Integer(ENV.fetch("KWARD_OAUTH_PORT", DEFAULT_PORT)))
211
+ rescue Errno::EADDRINUSE
212
+ TCPServer.new("localhost", 0)
213
+ end
214
+
215
+ def wait_for_callback(server, expected_state:, timeout_seconds:)
216
+ ready = IO.select([server], nil, nil, timeout_seconds)
217
+ return nil unless ready
218
+
219
+ socket = server.accept
220
+ request_line = socket.gets.to_s
221
+ path = request_line.split[1].to_s
222
+ params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
223
+
224
+ code = nil
225
+ status = "200 OK"
226
+ body = "Login complete. You can close this window."
227
+ if params["error"]
228
+ body = "Login failed. Return to the terminal."
229
+ elsif params["state"] != expected_state
230
+ status = "400 Bad Request"
231
+ body = "Invalid OAuth state. Return to the terminal."
232
+ else
233
+ code = params["code"]
234
+ end
235
+
236
+ socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
237
+ code
238
+ rescue URI::InvalidURIError
239
+ nil
240
+ ensure
241
+ socket&.close
242
+ end
243
+
244
+ def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
245
+ response = post_form(TOKEN_URL,
246
+ grant_type: "authorization_code",
247
+ code: code,
248
+ redirect_uri: redirect_uri,
249
+ client_id: client_id,
250
+ code_verifier: code_verifier)
251
+ parse_successful_json(response, "OpenAI OAuth token exchange")
252
+ end
253
+
254
+ def post_form(uri, params)
255
+ request = Net::HTTP::Post.new(uri)
256
+ request["Content-Type"] = "application/x-www-form-urlencoded"
257
+ request.body = URI.encode_www_form(params)
258
+
259
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
260
+ end
261
+
262
+ def post_json(uri, params)
263
+ request = Net::HTTP::Post.new(uri)
264
+ request["Content-Type"] = "application/json"
265
+ request.body = JSON.dump(params)
266
+
267
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
268
+ end
269
+
270
+ def token_expired?(auth)
271
+ expires_at = auth&.fetch("expires_at", nil)
272
+ return false unless expires_at
273
+
274
+ Time.parse(expires_at) <= Time.now.utc + 60
275
+ rescue ArgumentError
276
+ false
277
+ end
278
+
279
+ def expires_at_for(tokens)
280
+ expires_in = tokens["expires_in"] || tokens[:expires_in]
281
+ return tokens["expires_at"] || tokens[:expires_at] unless expires_in
282
+
283
+ (Time.now.utc + expires_in.to_i).iso8601
284
+ end
285
+
286
+ def extract_account_id(tokens)
287
+ [tokens["id_token"], tokens[:id_token], tokens["access_token"], tokens[:access_token]].each do |token|
288
+ claims = jwt_claims(token.to_s)
289
+ account_id = claims["chatgpt_account_id"] || claims.dig("https://api.openai.com/auth", "chatgpt_account_id") || claims.dig("organizations", 0, "id")
290
+ return account_id if account_id
291
+ end
292
+ nil
293
+ end
294
+
295
+ def jwt_claims(token)
296
+ _header, payload, _signature = token.split(".")
297
+ return {} unless payload
298
+
299
+ JSON.parse(Base64.urlsafe_decode64(payload + "=" * ((4 - payload.length % 4) % 4)))
300
+ rescue JSON::ParserError, ArgumentError
301
+ {}
302
+ end
303
+
304
+ def parse_successful_json(response, label)
305
+ raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
306
+
307
+ JSON.parse(response.body)
308
+ rescue JSON::ParserError
309
+ raise "#{label} returned invalid JSON"
310
+ end
311
+
312
+ def open_url(url)
313
+ command = if RUBY_PLATFORM.match?(/darwin/)
314
+ "open"
315
+ elsif RUBY_PLATFORM.match?(/linux/)
316
+ "xdg-open"
317
+ end
318
+ return false unless command
319
+
320
+ system(command, url, out: File::NULL, err: File::NULL)
321
+ end
322
+ end
323
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "../config_files"
2
+ require_relative "openai_oauth"
3
+
4
+ module Kward
5
+ class OpenRouterAPIKey
6
+ attr_reader :config_path
7
+
8
+ def initialize(config_path: OpenAIOAuth.default_config_path)
9
+ @config_path = File.expand_path(config_path)
10
+ end
11
+
12
+ def api_key
13
+ ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(config, "openrouter_api_key") : ENV["OPENROUTER_API_KEY"]
14
+ end
15
+
16
+ def configured?
17
+ !api_key.to_s.empty?
18
+ end
19
+
20
+ def login(prompt:)
21
+ api_key = prompt.ask("OpenRouter API key:").to_s.strip
22
+ raise "OpenRouter API key must be a non-empty string" if api_key.empty?
23
+
24
+ ConfigFiles.update_config({ "openrouter_api_key" => api_key }, @config_path)
25
+ @config_path
26
+ end
27
+
28
+ def logout
29
+ ConfigFiles.delete_config_key("openrouter_api_key", @config_path)
30
+ end
31
+
32
+ private
33
+
34
+ def config
35
+ ConfigFiles.read_config(@config_path)
36
+ rescue StandardError
37
+ {}
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,54 @@
1
+ require "thread"
2
+
3
+ module Kward
4
+ class Cancellation
5
+ class CancelledError < StandardError; end
6
+
7
+ def initialize
8
+ @cancelled = false
9
+ @callbacks = []
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def cancel!
14
+ callbacks = @mutex.synchronize do
15
+ return if @cancelled
16
+
17
+ @cancelled = true
18
+ pending = @callbacks
19
+ @callbacks = []
20
+ pending
21
+ end
22
+
23
+ callbacks.each do |callback|
24
+ callback.call
25
+ rescue StandardError
26
+ nil
27
+ end
28
+ end
29
+
30
+ def cancelled?
31
+ @mutex.synchronize { @cancelled }
32
+ end
33
+
34
+ alias canceled? cancelled?
35
+
36
+ def raise_if_cancelled!
37
+ raise CancelledError, "cancelled" if cancelled?
38
+ end
39
+
40
+ def on_cancel(&block)
41
+ run_now = false
42
+ @mutex.synchronize do
43
+ if @cancelled
44
+ run_now = true
45
+ else
46
+ @callbacks << block
47
+ end
48
+ end
49
+
50
+ block.call if run_now
51
+ nil
52
+ end
53
+ end
54
+ end