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,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
|