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,265 @@
1
+ require "securerandom"
2
+ require "thread"
3
+ require_relative "../auth/github_oauth"
4
+ require_relative "../auth/openai_oauth"
5
+ require_relative "../model/client"
6
+ require_relative "config_manager"
7
+
8
+ module Kward
9
+ module RPC
10
+ class AuthManager
11
+ Login = Struct.new(:id, :oauth, :pkce, :state, :server, :redirect_uri, :status, :error, :thread, keyword_init: true)
12
+
13
+ def initialize(server:, oauth_factory: -> { OpenAIOAuth.new }, github_oauth_factory: -> { GithubOAuth.new }, config_manager: ConfigManager.new)
14
+ @server = server
15
+ @oauth_factory = oauth_factory
16
+ @github_oauth_factory = github_oauth_factory
17
+ @config_manager = config_manager
18
+ @logins = {}
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ def status
23
+ oauth = @oauth_factory.call
24
+ config = stored_config
25
+ {
26
+ openaiOAuth: oauth.logged_in?,
27
+ openaiAccountId: oauth.respond_to?(:account_id) ? oauth.account_id : nil,
28
+ openrouterApiKey: !ENV["OPENROUTER_API_KEY"].to_s.empty? || !config["openrouter_api_key"].to_s.empty?,
29
+ openaiAccessToken: !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?,
30
+ githubOAuth: @github_oauth_factory.call.logged_in?
31
+ }
32
+ rescue StandardError => e
33
+ { openaiOAuth: false, error: e.message }
34
+ end
35
+
36
+ def providers
37
+ { providers: [openai_provider, openrouter_provider, github_provider] }
38
+ end
39
+
40
+ def login_with_api_key(provider_id:, api_key:)
41
+ provider_id = provider_id.to_s
42
+ @config_manager.set_api_key(provider_id, api_key)
43
+ { providerId: provider_id, message: "Saved API key for #{provider_name(provider_id)}." }
44
+ end
45
+
46
+ def logout_provider(provider_id:)
47
+ provider_id = provider_id.to_s
48
+ case provider_id
49
+ when "openai"
50
+ logout_openai
51
+ { providerId: provider_id, message: "Logged out of OpenAI." }
52
+ when "openrouter"
53
+ @config_manager.delete_key("openrouter_api_key")
54
+ { providerId: provider_id, message: "Logged out of OpenRouter." }
55
+ else
56
+ raise "Unsupported auth provider: #{provider_id}"
57
+ end
58
+ end
59
+
60
+ def login_with_oauth(provider_id:, timeout_seconds: 120)
61
+ provider_id = provider_id.to_s
62
+ case provider_id
63
+ when "openai"
64
+ start_openai_login(timeout_seconds: timeout_seconds)
65
+ when "github"
66
+ raise "GitHub OAuth is supported in the CLI with `ruby lib/main.rb login github`, but RPC browser login is not implemented yet."
67
+ else
68
+ raise "Unsupported OAuth provider: #{provider_id}"
69
+ end
70
+ end
71
+
72
+ def start_openai_login(timeout_seconds: 120)
73
+ oauth = @oauth_factory.call
74
+ flow = oauth.start_login_flow
75
+ pkce = flow.fetch(:pkce)
76
+ state = flow.fetch(:state)
77
+ server = flow.fetch(:server)
78
+ redirect_uri = flow.fetch(:redirect_uri)
79
+ url = flow.fetch(:authorization_url)
80
+ login = Login.new(
81
+ id: SecureRandom.uuid,
82
+ oauth: oauth,
83
+ pkce: pkce,
84
+ state: state,
85
+ server: server,
86
+ redirect_uri: redirect_uri,
87
+ status: "pending"
88
+ )
89
+ @mutex.synchronize { @logins[login.id] = login }
90
+ login.thread = Thread.new { wait_for_callback(login, timeout_seconds: timeout_seconds.to_i <= 0 ? 120 : timeout_seconds.to_i) }
91
+ { providerId: "openai", loginId: login.id, authorizationUrl: url, redirectUri: redirect_uri, status: login.status }
92
+ end
93
+
94
+ def submit_openai_code(login_id:, code:)
95
+ login = fetch_login(login_id)
96
+ raise "Login is not pending" unless login.status == "pending"
97
+
98
+ code = login.oauth.authorization_code_from(code.to_s, expected_state: login.state)
99
+ complete_login(login, code)
100
+ login_payload(login)
101
+ end
102
+
103
+ def login_status(login_id:)
104
+ login_payload(fetch_login(login_id))
105
+ end
106
+
107
+ private
108
+
109
+ def fetch_login(login_id)
110
+ @mutex.synchronize { @logins[login_id.to_s] } || raise("Unknown login: #{login_id}")
111
+ end
112
+
113
+ def stored_config
114
+ @config_manager.read(redacted: false)
115
+ rescue StandardError
116
+ {}
117
+ end
118
+
119
+ def openai_provider
120
+ oauth = @oauth_factory.call
121
+ env_configured = !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?
122
+ stored_configured = oauth.logged_in?
123
+ provider = {
124
+ id: "openai",
125
+ name: "OpenAI",
126
+ authType: "oauth",
127
+ configured: env_configured || stored_configured,
128
+ storedCredentialType: "oauth",
129
+ canLogout: stored_configured,
130
+ usesCallbackServer: true
131
+ }
132
+ provider[:source] = env_configured ? "environment" : "stored" if provider[:configured]
133
+ provider[:label] = provider[:configured] ? "Signed in" : "Not signed in"
134
+ provider
135
+ rescue StandardError
136
+ {
137
+ id: "openai",
138
+ name: "OpenAI",
139
+ authType: "oauth",
140
+ configured: !ENV["OPENAI_ACCESS_TOKEN"].to_s.empty?,
141
+ source: (!ENV["OPENAI_ACCESS_TOKEN"].to_s.empty? ? "environment" : nil),
142
+ label: (!ENV["OPENAI_ACCESS_TOKEN"].to_s.empty? ? "Signed in" : "Not signed in"),
143
+ storedCredentialType: "oauth",
144
+ canLogout: false,
145
+ usesCallbackServer: true
146
+ }.compact
147
+ end
148
+
149
+ def openrouter_provider
150
+ config = stored_config
151
+ env_configured = !ENV["OPENROUTER_API_KEY"].to_s.empty?
152
+ stored_configured = !config["openrouter_api_key"].to_s.empty?
153
+ provider = {
154
+ id: "openrouter",
155
+ name: "OpenRouter",
156
+ authType: "api_key",
157
+ configured: env_configured || stored_configured,
158
+ canLogout: stored_configured
159
+ }
160
+ provider[:source] = env_configured ? "environment" : "stored" if provider[:configured]
161
+ provider[:storedCredentialType] = "api_key" if stored_configured
162
+ provider[:label] = provider[:configured] ? "API key configured" : "API key not configured"
163
+ provider
164
+ end
165
+
166
+ def github_provider
167
+ oauth = @github_oauth_factory.call
168
+ env_configured = !ENV["COPILOT_GITHUB_TOKEN"].to_s.empty?
169
+ stored_configured = oauth.logged_in? && !env_configured
170
+ provider = {
171
+ id: "github",
172
+ name: "GitHub",
173
+ authType: "oauth",
174
+ configured: env_configured || stored_configured,
175
+ storedCredentialType: "oauth",
176
+ canLogout: false,
177
+ usesCallbackServer: false,
178
+ supported: false,
179
+ reason: "GitHub OAuth is available in the CLI for Copilot scaffolding; RPC login is not implemented yet."
180
+ }
181
+ provider[:source] = env_configured ? "environment" : "stored" if provider[:configured]
182
+ provider[:label] = provider[:configured] ? "Signed in" : "Not signed in"
183
+ provider
184
+ rescue StandardError
185
+ {
186
+ id: "github",
187
+ name: "GitHub",
188
+ authType: "oauth",
189
+ configured: false,
190
+ label: "Not signed in",
191
+ storedCredentialType: "oauth",
192
+ canLogout: false,
193
+ usesCallbackServer: false,
194
+ supported: false,
195
+ reason: "GitHub OAuth status unavailable."
196
+ }
197
+ end
198
+
199
+ def provider_name(provider_id)
200
+ case provider_id
201
+ when "openrouter" then "OpenRouter"
202
+ when "openai" then "OpenAI"
203
+ when "github" then "GitHub"
204
+ else provider_id
205
+ end
206
+ end
207
+
208
+ def logout_openai
209
+ oauth = @oauth_factory.call
210
+ path = oauth.auth_path if oauth.respond_to?(:auth_path)
211
+ File.delete(path) if path && File.exist?(path)
212
+ end
213
+
214
+ def wait_for_callback(login, timeout_seconds:)
215
+ code = login.oauth.wait_for_login_callback(login.server, expected_state: login.state, timeout_seconds: timeout_seconds)
216
+ complete_login(login, code) unless code.to_s.empty?
217
+ rescue StandardError => e
218
+ login.status = "failed"
219
+ login.error = e.message
220
+ @server.notify("auth/loginFinished", login_payload(login))
221
+ ensure
222
+ login.server&.close unless login.server&.closed?
223
+ end
224
+
225
+ def complete_login(login, code)
226
+ raise "Missing authorization code" if code.to_s.empty?
227
+
228
+ login.oauth.complete_login_flow(code: code, redirect_uri: login.redirect_uri, code_verifier: login.pkce[:verifier])
229
+ login.status = "completed"
230
+ @server.notify("auth/loginFinished", login_payload(login))
231
+ rescue StandardError => e
232
+ login.status = "failed"
233
+ login.error = e.message
234
+ @server.notify("auth/loginFinished", login_payload(login))
235
+ raise
236
+ ensure
237
+ login.server&.close unless login.server&.closed?
238
+ end
239
+
240
+ def login_payload(login)
241
+ {
242
+ providerId: "openai",
243
+ loginId: login.id,
244
+ status: login.status,
245
+ redirectUri: login.redirect_uri,
246
+ message: login_status_message(login.status),
247
+ error: login.error
248
+ }.compact
249
+ end
250
+
251
+ def login_status_message(status)
252
+ case status
253
+ when "completed"
254
+ "Logged in to OpenAI."
255
+ when "failed"
256
+ "OpenAI login failed."
257
+ when "cancelled"
258
+ "OpenAI login cancelled."
259
+ else
260
+ "OpenAI login pending."
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,58 @@
1
+ require_relative "../auth/openai_oauth"
2
+ require_relative "../config_files"
3
+ require_relative "../model/model_info"
4
+ require_relative "redactor"
5
+
6
+ module Kward
7
+ module RPC
8
+ class ConfigManager
9
+ def initialize(config_path: OpenAIOAuth.default_config_path)
10
+ @config_path = File.expand_path(config_path)
11
+ end
12
+
13
+ attr_reader :config_path
14
+
15
+ def read(redacted: true)
16
+ config = load_config
17
+ redacted ? Redactor.redact(config) : config
18
+ end
19
+
20
+ def update(values)
21
+ Redactor.redact(ConfigFiles.update_config(values, @config_path))
22
+ end
23
+
24
+ def set_model(model, provider: nil)
25
+ model = model.to_s.strip
26
+ raise "Model must be a non-empty string" if model.empty?
27
+
28
+ update(ModelInfo.config_values_for_selection(provider, model))
29
+ end
30
+
31
+ def set_reasoning_effort(effort, provider: nil)
32
+ effort = effort.to_s.strip
33
+ raise "Reasoning effort must be a non-empty string" if effort.empty?
34
+
35
+ update(ModelInfo.reasoning_config_key_for_provider(provider) => effort)
36
+ end
37
+
38
+ def set_api_key(provider_id, api_key)
39
+ provider_id = provider_id.to_s
40
+ api_key = api_key.to_s.strip
41
+ raise "API key must be a non-empty string" if api_key.empty?
42
+ raise "Unsupported API key provider: #{provider_id}" unless provider_id == "openrouter"
43
+
44
+ update("openrouter_api_key" => api_key)
45
+ end
46
+
47
+ def delete_key(key)
48
+ ConfigFiles.delete_config_key(key, @config_path)
49
+ end
50
+
51
+ private
52
+
53
+ def load_config
54
+ ConfigFiles.read_config(@config_path)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,104 @@
1
+ require "securerandom"
2
+ require_relative "../message_access"
3
+
4
+ module Kward
5
+ module RPC
6
+ class PromptBridge
7
+ MIN_QUESTIONS = 1
8
+ MAX_QUESTIONS = 4
9
+ MIN_OPTIONS = 2
10
+ MAX_OPTIONS = 4
11
+
12
+ def initialize(server:, session_id:)
13
+ @server = server
14
+ @session_id = session_id
15
+ @mutex = Mutex.new
16
+ @condition = ConditionVariable.new
17
+ @answers = {}
18
+ @pending_requests = {}
19
+ end
20
+
21
+ def ask_user_question(questions, cancellation: nil)
22
+ questions = validate_questions(questions)
23
+ request_id = SecureRandom.uuid
24
+ @mutex.synchronize { @pending_requests[request_id] = true }
25
+ cancellation&.on_cancel { cancel_request(request_id) }
26
+ unless cancellation&.cancelled?
27
+ @server.notify("ui/question", {
28
+ sessionId: @session_id,
29
+ questionRequestId: request_id,
30
+ questions: questions
31
+ })
32
+ end
33
+
34
+ @mutex.synchronize do
35
+ @condition.wait(@mutex) until @answers.key?(request_id)
36
+ answer = @answers.delete(request_id)
37
+ @pending_requests.delete(request_id)
38
+ return nil if answer.nil?
39
+
40
+ answer
41
+ end
42
+ end
43
+
44
+ def answer(request_id, answers)
45
+ @mutex.synchronize do
46
+ request_id = request_id.to_s
47
+ return unless @pending_requests.key?(request_id)
48
+ return if @answers.key?(request_id)
49
+
50
+ @answers[request_id] = normalize_answers(answers)
51
+ @condition.broadcast
52
+ end
53
+ end
54
+
55
+ def cancel_request(request_id)
56
+ @mutex.synchronize do
57
+ request_id = request_id.to_s
58
+ return unless @pending_requests.key?(request_id)
59
+ return if @answers.key?(request_id)
60
+
61
+ @answers[request_id] = nil
62
+ @condition.broadcast
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def normalize_answers(answers)
69
+ return nil if answers.nil?
70
+ return answers unless answers.is_a?(Array)
71
+
72
+ answers.map do |answer|
73
+ next answer unless answer.is_a?(Hash)
74
+
75
+ { question: MessageAccess.value(answer, :question).to_s, answer: MessageAccess.value(answer, :answer).to_s }
76
+ end
77
+ end
78
+
79
+ def validate_questions(questions)
80
+ raise ArgumentError, "questions must be an array" unless questions.is_a?(Array)
81
+ unless questions.length.between?(MIN_QUESTIONS, MAX_QUESTIONS)
82
+ raise ArgumentError, "ui/question requires 1-4 questions"
83
+ end
84
+
85
+ questions.each_with_index do |question, index|
86
+ validate_question(question, index)
87
+ end
88
+ questions
89
+ end
90
+
91
+ def validate_question(question, index)
92
+ raise ArgumentError, "question #{index + 1} must be an object" unless question.is_a?(Hash)
93
+
94
+ options = MessageAccess.value(question, :options)
95
+ raise ArgumentError, "question #{index + 1} options must be an array" unless options.is_a?(Array)
96
+ unless options.length.between?(MIN_OPTIONS, MAX_OPTIONS)
97
+ raise ArgumentError, "question #{index + 1} requires 2-4 options"
98
+ end
99
+ raise ArgumentError, "question #{index + 1} multiSelect is unsupported" if MessageAccess.value(question, :multiSelect) == true
100
+ raise ArgumentError, "question #{index + 1} preview is unsupported" if options.any? { |option| option.is_a?(Hash) && MessageAccess.value(option, :preview) }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,47 @@
1
+ module Kward
2
+ module RPC
3
+ module Redactor
4
+ SECRET_KEYS = /(?:token|secret|api[_-]?key|authorization|password|credential)/i
5
+
6
+ module_function
7
+
8
+ def redact(value)
9
+ case value
10
+ when Hash
11
+ value.each_with_object({}) do |(key, item), result|
12
+ if token_count_key?(key.to_s) && item.is_a?(Numeric)
13
+ result[key] = item
14
+ elsif secret_key?(key)
15
+ result[key] = "[REDACTED]"
16
+ else
17
+ result[key] = redact(item)
18
+ end
19
+ end
20
+ when Array
21
+ value.map { |item| redact(item) }
22
+ when String
23
+ redact_string(value)
24
+ else
25
+ value
26
+ end
27
+ end
28
+
29
+ def redact_string(value)
30
+ value
31
+ .gsub(/Bearer\s+[^\s"']+/i, "Bearer [REDACTED]")
32
+ .gsub(/(sk-[A-Za-z0-9_-]{8,})/, "[REDACTED]")
33
+ end
34
+
35
+ def secret_key?(key)
36
+ text = key.to_s
37
+ return false if text == "apiKeyProviders"
38
+
39
+ text.match?(SECRET_KEYS)
40
+ end
41
+
42
+ def token_count_key?(key)
43
+ key.match?(/\A(?:input|output|cache_read|cache_write|total)_tokens\z/) || key == "estimated"
44
+ end
45
+ end
46
+ end
47
+ end