kward 0.78.0 → 0.79.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 +4 -4
- data/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +24 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/doc/agent-tools.md +1 -1
- data/doc/configuration.md +68 -2
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +3 -2
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +12 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +11 -5
- data/doc/skills.md +10 -0
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/runtime_helpers.rb +1 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli.rb +2 -1
- data/lib/kward/config_files.rb +80 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +140 -4
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +1 -0
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/plugin_registry.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface.rb +18 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +4 -1
- data/lib/kward/rpc/server.rb +42 -4
- data/lib/kward/rpc/session_manager.rb +42 -8
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tools/registry.rb +19 -4
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +16 -2
data/lib/kward/model/client.rb
CHANGED
|
@@ -29,6 +29,11 @@ module Kward
|
|
|
29
29
|
OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
|
|
30
30
|
CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
|
|
31
31
|
ANTHROPIC_URL = URI("https://api.anthropic.com/v1/messages")
|
|
32
|
+
LOCAL_BASE_URLS = {
|
|
33
|
+
"ollama" => "http://127.0.0.1:11434/v1",
|
|
34
|
+
"lm_studio" => "http://127.0.0.1:1234/v1",
|
|
35
|
+
"llama_cpp" => "http://127.0.0.1:8080/v1"
|
|
36
|
+
}.freeze
|
|
32
37
|
AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
|
|
33
38
|
OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."
|
|
34
39
|
COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
|
|
@@ -94,18 +99,19 @@ module Kward
|
|
|
94
99
|
@telemetry_logger = telemetry_logger
|
|
95
100
|
@copilot_models = nil
|
|
96
101
|
@openrouter_models = nil
|
|
102
|
+
@local_models = nil
|
|
97
103
|
end
|
|
98
104
|
|
|
99
105
|
def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
|
|
100
106
|
cancellation&.raise_if_cancelled!
|
|
101
107
|
requested_provider = provider
|
|
102
108
|
url, token, resolved_provider, account_id = credentials(provider: requested_provider)
|
|
103
|
-
if token.to_s.empty? && !requested_provider.to_s.empty?
|
|
109
|
+
if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty?
|
|
104
110
|
url, token, resolved_provider, account_id = credentials
|
|
105
111
|
model = nil
|
|
106
112
|
reasoning = nil
|
|
107
113
|
end
|
|
108
|
-
raise auth_error_for(resolved_provider) if token.nil? || token.empty?
|
|
114
|
+
raise auth_error_for(resolved_provider) if authentication_required?(resolved_provider) && (token.nil? || token.empty?)
|
|
109
115
|
|
|
110
116
|
current_model = model_for(resolved_provider, override_model: model)
|
|
111
117
|
current_model = resolved_copilot_chat_model(current_model) if resolved_provider == "Copilot" && model.nil?
|
|
@@ -180,7 +186,10 @@ module Kward
|
|
|
180
186
|
|
|
181
187
|
# Returns the known context window for a provider/model pair.
|
|
182
188
|
def context_window(provider, model)
|
|
183
|
-
|
|
189
|
+
provider = ModelInfo.provider_label(provider)
|
|
190
|
+
return local_context_window if provider == "Local"
|
|
191
|
+
|
|
192
|
+
context_window_for(provider, model)
|
|
184
193
|
end
|
|
185
194
|
|
|
186
195
|
# Returns model choices suitable for settings UIs.
|
|
@@ -226,6 +235,14 @@ module Kward
|
|
|
226
235
|
models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
|
|
227
236
|
end
|
|
228
237
|
|
|
238
|
+
if provider_logged_in?("Local")
|
|
239
|
+
local_model = model_for("Local")
|
|
240
|
+
local_model_choices.each do |id|
|
|
241
|
+
models << model_entry("Local", id, current: provider == "Local" && local_model == id)
|
|
242
|
+
end
|
|
243
|
+
models << model_entry("Local", local_model, current: provider == "Local") unless local_model.to_s.empty? || local_model_choices.include?(local_model)
|
|
244
|
+
end
|
|
245
|
+
|
|
229
246
|
# Sort models by provider, then alphabetically by id
|
|
230
247
|
models.sort_by { |model| [model[:provider], model[:id]] }
|
|
231
248
|
end
|
|
@@ -247,6 +264,7 @@ module Kward
|
|
|
247
264
|
@config = load_config
|
|
248
265
|
@copilot_models = nil
|
|
249
266
|
@openrouter_models = nil
|
|
267
|
+
@local_models = nil
|
|
250
268
|
end
|
|
251
269
|
|
|
252
270
|
private
|
|
@@ -298,6 +316,15 @@ module Kward
|
|
|
298
316
|
on_assistant_delta: on_assistant_delta,
|
|
299
317
|
cancellation: cancellation
|
|
300
318
|
)
|
|
319
|
+
when "Local"
|
|
320
|
+
chat_local_provider(
|
|
321
|
+
url: url,
|
|
322
|
+
token: token,
|
|
323
|
+
request_body: request_body,
|
|
324
|
+
current_model: current_model,
|
|
325
|
+
on_assistant_delta: on_assistant_delta,
|
|
326
|
+
cancellation: cancellation
|
|
327
|
+
)
|
|
301
328
|
else
|
|
302
329
|
chat_openrouter_provider(
|
|
303
330
|
url: url,
|
|
@@ -362,6 +389,31 @@ module Kward
|
|
|
362
389
|
attach_response_metadata(message, provider: "Anthropic", model: current_model)
|
|
363
390
|
end
|
|
364
391
|
|
|
392
|
+
def chat_local_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
393
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
394
|
+
request["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
|
|
395
|
+
request["Content-Type"] = "application/json"
|
|
396
|
+
request["Accept"] = "text/event-stream"
|
|
397
|
+
request.body = request_body
|
|
398
|
+
|
|
399
|
+
message = nil
|
|
400
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https", read_timeout: stream_idle_timeout_seconds) do |http|
|
|
401
|
+
cancellation&.on_cancel { close_http(http) }
|
|
402
|
+
cancellation&.raise_if_cancelled!
|
|
403
|
+
http.request(request) do |response|
|
|
404
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
405
|
+
body = +""
|
|
406
|
+
response.read_body { |chunk| body << chunk }
|
|
407
|
+
raise RequestError.new(provider: "Local", code: response.code, body: redact(body, token))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
message = parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, provider_label: "Local")
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
cancellation&.raise_if_cancelled!
|
|
414
|
+
attach_response_metadata(message, provider: "Local", model: current_model)
|
|
415
|
+
end
|
|
416
|
+
|
|
365
417
|
def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
366
418
|
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
367
419
|
request["Authorization"] = "Bearer #{token}"
|
|
@@ -504,9 +556,30 @@ module Kward
|
|
|
504
556
|
end
|
|
505
557
|
|
|
506
558
|
def context_window_for(provider, id)
|
|
559
|
+
return local_context_window if provider == "Local"
|
|
560
|
+
|
|
507
561
|
ModelInfo.context_window(provider, id, openrouter_models: openrouter_cached_model_entries)
|
|
508
562
|
end
|
|
509
563
|
|
|
564
|
+
def local_model_choices
|
|
565
|
+
return @local_models if @local_models
|
|
566
|
+
|
|
567
|
+
url = local_models_url
|
|
568
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(url))
|
|
569
|
+
request["Authorization"] = "Bearer #{local_api_key}" unless local_api_key.to_s.empty?
|
|
570
|
+
request["Accept"] = "application/json"
|
|
571
|
+
response = Net::HTTP.start(url.hostname, url.port, use_ssl: url.scheme == "https", read_timeout: stream_idle_timeout_seconds) { |http| http.request(request) }
|
|
572
|
+
return @local_models = [] unless response.is_a?(Net::HTTPSuccess)
|
|
573
|
+
|
|
574
|
+
body = JSON.parse(response.body)
|
|
575
|
+
entries = body["data"] || body["models"] || []
|
|
576
|
+
@local_models = Array(entries).filter_map do |entry|
|
|
577
|
+
entry.is_a?(Hash) ? entry["id"] || entry[:id] || entry["model"] || entry[:model] : entry
|
|
578
|
+
end.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
579
|
+
rescue StandardError
|
|
580
|
+
@local_models = []
|
|
581
|
+
end
|
|
582
|
+
|
|
510
583
|
def openrouter_model_choices
|
|
511
584
|
openrouter_cached_models.uniq
|
|
512
585
|
end
|
|
@@ -627,7 +700,11 @@ module Kward
|
|
|
627
700
|
end
|
|
628
701
|
|
|
629
702
|
def parse_openai_chat_sse(body, on_assistant_delta: nil)
|
|
630
|
-
ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage))
|
|
703
|
+
ModelStreamParser.parse_openai_chat_sse(body, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage), provider_label: "Copilot")
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
def parse_openai_chat_sse_stream(response, on_assistant_delta: nil, cancellation: nil, provider_label: "OpenAI-compatible provider")
|
|
707
|
+
ModelStreamParser.parse_openai_chat_sse_stream(response, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), provider_label: provider_label)
|
|
631
708
|
end
|
|
632
709
|
|
|
633
710
|
def anthropic_headers
|
|
@@ -776,6 +853,10 @@ module Kward
|
|
|
776
853
|
|
|
777
854
|
def credentials(provider: nil)
|
|
778
855
|
provider = provider.to_s.empty? ? ModelInfo.provider_label(configured_provider) : ModelInfo.provider_label(provider)
|
|
856
|
+
if provider == "Local"
|
|
857
|
+
return [local_chat_url, local_api_key, provider, nil]
|
|
858
|
+
end
|
|
859
|
+
|
|
779
860
|
if provider == "Copilot"
|
|
780
861
|
return [copilot_chat_url, github_access_token, provider, nil]
|
|
781
862
|
end
|
|
@@ -806,8 +887,14 @@ module Kward
|
|
|
806
887
|
ModelInfo.reasoning_effort(config: @config, provider: provider)
|
|
807
888
|
end
|
|
808
889
|
|
|
890
|
+
def authentication_required?(provider)
|
|
891
|
+
provider != "Local"
|
|
892
|
+
end
|
|
893
|
+
|
|
809
894
|
def provider_logged_in?(provider)
|
|
810
895
|
case provider
|
|
896
|
+
when "Local"
|
|
897
|
+
local_configured?
|
|
811
898
|
when "Codex"
|
|
812
899
|
openai_configured?
|
|
813
900
|
when "OpenRouter"
|
|
@@ -829,6 +916,53 @@ module Kward
|
|
|
829
916
|
false
|
|
830
917
|
end
|
|
831
918
|
|
|
919
|
+
def local_configured?
|
|
920
|
+
configured_provider == "local" || !local_model.to_s.empty? || !config_value("local_base_url").to_s.empty? || !ENV["KWARD_LOCAL_BASE_URL"].to_s.empty?
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def local_model
|
|
924
|
+
ENV["KWARD_LOCAL_MODEL"] || config_value("local_model")
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def local_context_window
|
|
928
|
+
value = ENV["KWARD_LOCAL_CONTEXT_WINDOW"] || config_value("local_context_window")
|
|
929
|
+
positive_integer(value)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def local_api_key
|
|
933
|
+
ENV["KWARD_LOCAL_API_KEY"] || config_value("local_api_key")
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def local_backend
|
|
937
|
+
presence(ENV["KWARD_LOCAL_BACKEND"].to_s.strip) || presence(config_value("local_backend").to_s.strip) || "ollama"
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def local_base_url
|
|
941
|
+
configured = presence(ENV["KWARD_LOCAL_BASE_URL"].to_s.strip) || presence(config_value("local_base_url").to_s.strip)
|
|
942
|
+
return configured if configured
|
|
943
|
+
|
|
944
|
+
LOCAL_BASE_URLS.fetch(local_backend) { raise "Unknown local model backend: #{local_backend}" }
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def local_chat_url
|
|
948
|
+
local_url("chat/completions")
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def local_models_url
|
|
952
|
+
local_url("models")
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def local_url(path)
|
|
956
|
+
base = URI.parse(local_base_url)
|
|
957
|
+
unless base.is_a?(URI::HTTP) && base.host && !base.host.empty? && base.user.nil? && base.password.nil? && base.query.nil? && base.fragment.nil?
|
|
958
|
+
raise "Invalid local model URL: #{local_base_url}"
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
URI("#{base.to_s.sub(%r{/+\z}, "")}/#{path}")
|
|
962
|
+
rescue URI::InvalidURIError
|
|
963
|
+
raise "Invalid local model URL: #{local_base_url}"
|
|
964
|
+
end
|
|
965
|
+
|
|
832
966
|
def openrouter_api_key
|
|
833
967
|
@openrouter_api_key || config_value("openrouter_api_key")
|
|
834
968
|
end
|
|
@@ -869,6 +1003,8 @@ module Kward
|
|
|
869
1003
|
end
|
|
870
1004
|
|
|
871
1005
|
def redact(text, token)
|
|
1006
|
+
return text.to_s if token.to_s.empty?
|
|
1007
|
+
|
|
872
1008
|
text.to_s.gsub(token.to_s, "[REDACTED]")
|
|
873
1009
|
end
|
|
874
1010
|
|
|
@@ -128,6 +128,8 @@ module Kward
|
|
|
128
128
|
return override_model if override_model
|
|
129
129
|
|
|
130
130
|
case provider
|
|
131
|
+
when "Local"
|
|
132
|
+
env["KWARD_LOCAL_MODEL"] || ConfigFiles.config_value(config, "local_model")
|
|
131
133
|
when "OpenRouter"
|
|
132
134
|
env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model")
|
|
133
135
|
when "Copilot"
|
|
@@ -175,6 +177,8 @@ module Kward
|
|
|
175
177
|
|
|
176
178
|
def reasoning_effort(config:, env: ENV, provider: nil)
|
|
177
179
|
case provider.to_s
|
|
180
|
+
when "Local"
|
|
181
|
+
nil
|
|
178
182
|
when "OpenRouter"
|
|
179
183
|
env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
|
|
180
184
|
when "Copilot"
|
|
@@ -191,6 +195,7 @@ module Kward
|
|
|
191
195
|
when "openrouter" then "OpenRouter"
|
|
192
196
|
when "copilot" then "Copilot"
|
|
193
197
|
when "anthropic", "claude" then "Anthropic"
|
|
198
|
+
when "local" then "Local"
|
|
194
199
|
when "codex", "openai" then "Codex"
|
|
195
200
|
else provider.to_s
|
|
196
201
|
end
|
|
@@ -201,6 +206,7 @@ module Kward
|
|
|
201
206
|
when "openrouter" then "openrouter"
|
|
202
207
|
when "copilot" then "copilot"
|
|
203
208
|
when "anthropic", "claude" then "anthropic"
|
|
209
|
+
when "local" then "local"
|
|
204
210
|
else "codex"
|
|
205
211
|
end
|
|
206
212
|
end
|
|
@@ -210,6 +216,7 @@ module Kward
|
|
|
210
216
|
when "openrouter" then "openrouter_model"
|
|
211
217
|
when "copilot" then "copilot_model"
|
|
212
218
|
when "anthropic", "claude" then "anthropic_model"
|
|
219
|
+
when "local" then "local_model"
|
|
213
220
|
else "openai_model"
|
|
214
221
|
end
|
|
215
222
|
end
|
|
@@ -219,6 +226,7 @@ module Kward
|
|
|
219
226
|
when "openrouter" then "openrouter_reasoning_effort"
|
|
220
227
|
when "copilot" then "copilot_reasoning_effort"
|
|
221
228
|
when "anthropic", "claude" then "anthropic_reasoning_effort"
|
|
229
|
+
when "local" then "local_reasoning_effort"
|
|
222
230
|
else "openai_reasoning_effort"
|
|
223
231
|
end
|
|
224
232
|
end
|
|
@@ -357,7 +365,9 @@ module Kward
|
|
|
357
365
|
pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
|
|
358
366
|
end
|
|
359
367
|
|
|
360
|
-
def supports_images?(
|
|
368
|
+
def supports_images?(provider, id)
|
|
369
|
+
return false if provider == "Local"
|
|
370
|
+
|
|
361
371
|
IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
|
|
362
372
|
end
|
|
363
373
|
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -26,6 +26,7 @@ module Kward
|
|
|
26
26
|
|
|
27
27
|
payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
|
|
28
28
|
payload[:reasoning] = { effort: reasoning || reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
|
|
29
|
+
payload[:stream] = true if provider == "Local"
|
|
29
30
|
payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
|
|
30
31
|
payload
|
|
31
32
|
end
|
|
@@ -13,36 +13,68 @@ module Kward
|
|
|
13
13
|
module ModelStreamParser
|
|
14
14
|
module_function
|
|
15
15
|
|
|
16
|
-
def parse_openai_chat_sse(body, on_assistant_delta: nil, usage_normalizer: nil)
|
|
17
|
-
|
|
18
|
-
tool_calls = []
|
|
19
|
-
usage = nil
|
|
16
|
+
def parse_openai_chat_sse(body, on_assistant_delta: nil, usage_normalizer: nil, provider_label: "OpenAI-compatible provider")
|
|
17
|
+
state = openai_chat_sse_state
|
|
20
18
|
body.split(/\r?\n\r?\n/).each do |block|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
process_openai_chat_sse_block(block, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer)
|
|
20
|
+
end
|
|
21
|
+
openai_chat_sse_message(state)
|
|
22
|
+
rescue JSON::ParserError => e
|
|
23
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Incrementally parses a Chat Completions SSE HTTP response body. Local
|
|
27
|
+
# OpenAI-compatible servers stream this shape, so emit assistant deltas as
|
|
28
|
+
# complete SSE blocks arrive rather than waiting for connection close.
|
|
29
|
+
def parse_openai_chat_sse_stream(response, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, provider_label: "OpenAI-compatible provider")
|
|
30
|
+
state = openai_chat_sse_state
|
|
31
|
+
buffer = +""
|
|
32
|
+
|
|
33
|
+
response.read_body do |chunk|
|
|
34
|
+
cancellation&.raise_if_cancelled!
|
|
35
|
+
buffer << chunk
|
|
36
|
+
while (index = buffer.index(/\r?\n\r?\n/))
|
|
37
|
+
delimiter = Regexp.last_match[0]
|
|
38
|
+
block = buffer[0...index]
|
|
39
|
+
buffer = buffer[(index + delimiter.length)..] || +""
|
|
40
|
+
process_openai_chat_sse_block(block, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer)
|
|
35
41
|
end
|
|
36
|
-
message = choice["message"] || {}
|
|
37
|
-
content << message["content"].to_s if content.empty? && message["content"]
|
|
38
|
-
Array(message["tool_calls"]).each { |tool_call| merge_streaming_tool_call(tool_calls, tool_call) }
|
|
39
42
|
end
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
result
|
|
43
|
+
cancellation&.raise_if_cancelled!
|
|
44
|
+
process_openai_chat_sse_block(buffer, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer) unless buffer.empty?
|
|
45
|
+
openai_chat_sse_message(state)
|
|
44
46
|
rescue JSON::ParserError => e
|
|
45
|
-
raise "
|
|
47
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def openai_chat_sse_state
|
|
51
|
+
{ content: +"", tool_calls: [], usage: nil }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process_openai_chat_sse_block(block, state, on_assistant_delta: nil, usage_normalizer: nil)
|
|
55
|
+
data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
|
|
56
|
+
return if data.empty? || data == "[DONE]"
|
|
57
|
+
|
|
58
|
+
event = JSON.parse(data)
|
|
59
|
+
state[:usage] ||= usage_normalizer&.call(event["usage"])
|
|
60
|
+
choice = Array(event["choices"]).first || {}
|
|
61
|
+
delta = choice["delta"] || {}
|
|
62
|
+
if delta["content"]
|
|
63
|
+
text = delta["content"].to_s
|
|
64
|
+
state[:content] << text
|
|
65
|
+
on_assistant_delta&.call(text)
|
|
66
|
+
end
|
|
67
|
+
Array(delta["tool_calls"]).each { |tool_call| merge_streaming_tool_call(state[:tool_calls], tool_call) }
|
|
68
|
+
message = choice["message"] || {}
|
|
69
|
+
state[:content] << message["content"].to_s if state[:content].empty? && message["content"]
|
|
70
|
+
Array(message["tool_calls"]).each { |tool_call| merge_streaming_tool_call(state[:tool_calls], tool_call) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def openai_chat_sse_message(state)
|
|
74
|
+
result = { "role" => "assistant", "content" => state[:content] }
|
|
75
|
+
result["tool_calls"] = finalized_streaming_tool_calls(state[:tool_calls]) unless state[:tool_calls].empty?
|
|
76
|
+
result["usage"] = state[:usage] if state[:usage]
|
|
77
|
+
result
|
|
46
78
|
end
|
|
47
79
|
|
|
48
80
|
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
@@ -164,6 +164,7 @@
|
|
|
164
164
|
<div class="title-row"><span class="live-dot"></span><h1 id="conversation-title">New session</h1></div>
|
|
165
165
|
<div class="top-meta" id="session-meta">Secure LAN workspace</div>
|
|
166
166
|
</div>
|
|
167
|
+
<button class="text-button" id="capture-skill" type="button">Capture skill</button>
|
|
167
168
|
<button class="text-button" id="rename-session" type="button">Rename</button>
|
|
168
169
|
<button class="text-button danger" id="delete-session" type="button">Delete</button>
|
|
169
170
|
</header>
|
|
@@ -456,7 +457,56 @@
|
|
|
456
457
|
document.getElementById('open-sessions').addEventListener('click', () => document.body.classList.add('sessions-open'));
|
|
457
458
|
document.getElementById('close-sessions').addEventListener('click', closeSessions);
|
|
458
459
|
document.getElementById('backdrop').addEventListener('click', closeSessions);
|
|
460
|
+
async function captureSkill() {
|
|
461
|
+
try {
|
|
462
|
+
const sourceResponse = await fetch('/skill-capture/sessions', { cache: 'no-store' });
|
|
463
|
+
const sourceData = await sourceResponse.json();
|
|
464
|
+
const candidates = sourceData.sessions || [];
|
|
465
|
+
if (!candidates.length) throw new Error('No saved sessions found.');
|
|
466
|
+
const choices = candidates.map((session, index) => `${index + 1}. ${session.name || session.firstMessage || 'Untitled session'}`).join('\n');
|
|
467
|
+
const answer = window.prompt(`Capture a personal skill from which saved session?\n\n${choices}`, '1');
|
|
468
|
+
if (answer === null) return;
|
|
469
|
+
const source = candidates[Number(answer) - 1];
|
|
470
|
+
if (!source) throw new Error('Choose a session number from the list.');
|
|
471
|
+
setStatus({ active: true, pending: 0 });
|
|
472
|
+
const draftResponse = await fetch('/skill-capture/draft', {
|
|
473
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionPath: source.path })
|
|
474
|
+
});
|
|
475
|
+
const draft = await draftResponse.json();
|
|
476
|
+
if (!draftResponse.ok || !draft.ok) throw new Error(draft.error || 'Could not create a skill draft.');
|
|
477
|
+
reviewSkillDraft(draft.content);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
showError(error.message);
|
|
480
|
+
} finally {
|
|
481
|
+
setStatus({ active: false, pending: 0 });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function reviewSkillDraft(content) {
|
|
486
|
+
const overlay = document.createElement('div');
|
|
487
|
+
overlay.style.cssText = 'position:fixed;inset:0;z-index:50;display:grid;place-items:center;padding:1rem;background:#000b';
|
|
488
|
+
const panel = document.createElement('section');
|
|
489
|
+
panel.style.cssText = 'display:grid;gap:.8rem;width:min(58rem,100%);max-height:90dvh;padding:1rem;border:1px solid var(--line-bright);border-radius:12px;background:var(--panel)';
|
|
490
|
+
const heading = document.createElement('strong'); heading.textContent = 'Review captured skill';
|
|
491
|
+
const note = document.createElement('p'); note.style.margin = '0'; note.style.color = 'var(--muted)'; note.textContent = 'Edit the draft. Saving writes a personal skill but does not activate it.';
|
|
492
|
+
const editor = document.createElement('textarea'); editor.value = content; editor.style.cssText = 'min-height:55dvh;resize:vertical;padding:.75rem;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font-family:ui-monospace,monospace;font-size:.8rem';
|
|
493
|
+
const actions = document.createElement('div'); actions.style.cssText = 'display:flex;justify-content:flex-end;gap:.6rem';
|
|
494
|
+
const cancel = document.createElement('button'); cancel.className = 'text-button'; cancel.textContent = 'Cancel';
|
|
495
|
+
const save = document.createElement('button'); save.className = 'new-session'; save.style.margin = '0'; save.textContent = 'Save personal skill';
|
|
496
|
+
cancel.onclick = () => overlay.remove();
|
|
497
|
+
save.onclick = async () => {
|
|
498
|
+
const request = async overwrite => fetch('/skill-capture/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: editor.value, overwrite }) });
|
|
499
|
+
let response = await request(false); let result = await response.json();
|
|
500
|
+
if (response.status === 409 && window.confirm(`${result.error}\n\nOverwrite it?`)) { response = await request(true); result = await response.json(); }
|
|
501
|
+
if (!response.ok || !result.ok) { showError(result.error || 'Could not save the skill.'); return; }
|
|
502
|
+
overlay.remove();
|
|
503
|
+
window.alert(`Saved personal skill: ${result.path}`);
|
|
504
|
+
};
|
|
505
|
+
actions.append(cancel, save); panel.append(heading, note, editor, actions); overlay.append(panel); document.body.append(overlay); editor.focus();
|
|
506
|
+
}
|
|
507
|
+
|
|
459
508
|
document.getElementById('new-session').addEventListener('click', () => changeSession('new'));
|
|
509
|
+
document.getElementById('capture-skill').addEventListener('click', captureSkill);
|
|
460
510
|
document.getElementById('rename-session').addEventListener('click', () => {
|
|
461
511
|
if (!activeSession) return;
|
|
462
512
|
const name = window.prompt('Session name', activeSession.name || '');
|
data/lib/kward/pan/server.rb
CHANGED
|
@@ -13,11 +13,12 @@ require_relative "../plugin_registry"
|
|
|
13
13
|
require_relative "../prompts/commands"
|
|
14
14
|
require_relative "../rpc/transcript_normalizer"
|
|
15
15
|
require_relative "../session_store"
|
|
16
|
+
require_relative "../skills/capture"
|
|
16
17
|
require_relative "../session_trash"
|
|
17
18
|
require_relative "../tools/tool_call"
|
|
18
19
|
require_relative "../tools/registry"
|
|
19
20
|
require_relative "../version"
|
|
20
|
-
require_relative "../
|
|
21
|
+
require_relative "../workspace_factory"
|
|
21
22
|
|
|
22
23
|
# Namespace for the Kward CLI agent runtime.
|
|
23
24
|
module Kward
|
|
@@ -33,7 +34,11 @@ module Kward
|
|
|
33
34
|
@client = client
|
|
34
35
|
@output = output
|
|
35
36
|
@udp_socket_class = udp_socket_class
|
|
36
|
-
@workspace =
|
|
37
|
+
@workspace = WorkspaceFactory.build(
|
|
38
|
+
root: working_directory,
|
|
39
|
+
guardrails: ConfigFiles.workspace_guardrails_enabled?(config),
|
|
40
|
+
config: config
|
|
41
|
+
)
|
|
37
42
|
@full_config = config
|
|
38
43
|
@config = pan_config(config)
|
|
39
44
|
@host = @config.fetch("host", DEFAULT_HOST).to_s
|
|
@@ -234,12 +239,18 @@ module Kward
|
|
|
234
239
|
write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
|
|
235
240
|
when ["GET", "/sessions"]
|
|
236
241
|
write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
|
|
242
|
+
when ["GET", "/skill-capture/sessions"]
|
|
243
|
+
write_json(socket, 200, sessions: skill_capture_sessions)
|
|
237
244
|
when ["GET", "/events"]
|
|
238
245
|
stream_events(socket)
|
|
239
246
|
when ["POST", "/turn"]
|
|
240
247
|
handle_turn(socket, request[:body], request[:headers])
|
|
241
248
|
when ["POST", "/sessions"]
|
|
242
249
|
handle_session_action(socket, request[:body], request[:headers])
|
|
250
|
+
when ["POST", "/skill-capture/draft"]
|
|
251
|
+
handle_skill_capture_draft(socket, request[:body], request[:headers])
|
|
252
|
+
when ["POST", "/skill-capture/save"]
|
|
253
|
+
handle_skill_capture_save(socket, request[:body], request[:headers])
|
|
243
254
|
else
|
|
244
255
|
write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
|
|
245
256
|
end
|
|
@@ -308,6 +319,42 @@ module Kward
|
|
|
308
319
|
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
309
320
|
end
|
|
310
321
|
|
|
322
|
+
def handle_skill_capture_draft(socket, body, headers)
|
|
323
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
324
|
+
|
|
325
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
326
|
+
draft = skill_capture.generate(params.fetch("sessionPath"))
|
|
327
|
+
write_json(socket, 200, ok: true, content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description)
|
|
328
|
+
rescue JSON::ParserError
|
|
329
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
330
|
+
rescue Skills::Capture::Error => error
|
|
331
|
+
write_json(socket, 422, ok: false, error: error.message)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def handle_skill_capture_save(socket, body, headers)
|
|
335
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
336
|
+
|
|
337
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
338
|
+
draft = skill_capture.save(params.fetch("content"), overwrite: params["overwrite"] == true)
|
|
339
|
+
write_json(socket, 200, ok: true, path: skill_capture.skill_path(draft.name), name: draft.name, description: draft.description)
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
342
|
+
rescue Skills::Capture::ConflictError => error
|
|
343
|
+
write_json(socket, 409, ok: false, error: error.message)
|
|
344
|
+
rescue Skills::Capture::Error => error
|
|
345
|
+
write_json(socket, 422, ok: false, error: error.message)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def skill_capture_sessions
|
|
349
|
+
@session_store.capture_candidates.map do |session|
|
|
350
|
+
{ path: session.path, name: session.name, firstMessage: session.first_message, workspaceRoot: session.cwd, modifiedAt: session.modified_at.utc.iso8601(3) }
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def skill_capture
|
|
355
|
+
@skill_capture ||= Skills::Capture.new(session_store: @session_store, client: @client, config_dir: @session_store.config_dir)
|
|
356
|
+
end
|
|
357
|
+
|
|
311
358
|
def update_session(params)
|
|
312
359
|
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
313
360
|
|
|
@@ -401,7 +401,7 @@ module Kward
|
|
|
401
401
|
previous_path = self.class.loading_path
|
|
402
402
|
self.class.loading_registry = self
|
|
403
403
|
self.class.loading_path = path
|
|
404
|
-
Kernel.load(path)
|
|
404
|
+
Kernel.load(path, true)
|
|
405
405
|
@paths << path
|
|
406
406
|
rescue StandardError => e
|
|
407
407
|
warn "Warning: skipping Kward plugin #{path}: #{e.message}"
|
|
@@ -153,11 +153,11 @@ module Kward
|
|
|
153
153
|
pending_deletions << line[1..].to_s
|
|
154
154
|
elsif line.start_with?("+")
|
|
155
155
|
deleted = pending_deletions.shift
|
|
156
|
-
rows
|
|
156
|
+
rows.concat(side_by_side_diff_rows(deleted && "- #{deleted}", "+ #{line[1..]}", width))
|
|
157
157
|
else
|
|
158
158
|
rows.concat(flush_side_by_side_deletions(pending_deletions, width))
|
|
159
159
|
text = line.start_with?(" ") ? line[1..].to_s : line
|
|
160
|
-
rows
|
|
160
|
+
rows.concat(side_by_side_diff_rows(" #{text}", " #{text}", width))
|
|
161
161
|
end
|
|
162
162
|
end
|
|
163
163
|
|
|
@@ -170,7 +170,17 @@ module Kward
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
def flush_side_by_side_deletions(lines, width)
|
|
173
|
-
lines.shift(lines.length).
|
|
173
|
+
lines.shift(lines.length).flat_map { |line| side_by_side_diff_rows("- #{line}", nil, width) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def side_by_side_diff_rows(left, right, width)
|
|
177
|
+
return [side_by_side_diff_row(left, right, width)] unless current_editor_soft_wrap?
|
|
178
|
+
|
|
179
|
+
left_lines = side_by_side_diff_cell_lines(left, width)
|
|
180
|
+
right_lines = side_by_side_diff_cell_lines(right, width)
|
|
181
|
+
[left_lines.length, right_lines.length].max.times.map do |index|
|
|
182
|
+
side_by_side_diff_row(left_lines[index], right_lines[index], width)
|
|
183
|
+
end
|
|
174
184
|
end
|
|
175
185
|
|
|
176
186
|
def side_by_side_diff_row(left, right, width)
|
|
@@ -184,6 +194,17 @@ module Kward
|
|
|
184
194
|
visible_truncate(value, width)
|
|
185
195
|
end
|
|
186
196
|
|
|
197
|
+
def side_by_side_diff_cell_lines(text, width)
|
|
198
|
+
return [""] if text.nil?
|
|
199
|
+
|
|
200
|
+
value = text.to_s
|
|
201
|
+
prefix = value.start_with?("- ", "+ ", " ") ? value[0, 2] : ""
|
|
202
|
+
content = value.delete_prefix(prefix)
|
|
203
|
+
return [prefix] if content.empty?
|
|
204
|
+
|
|
205
|
+
content.chars.each_slice([width - prefix.length, 1].max).map { |characters| "#{prefix}#{characters.join}" }
|
|
206
|
+
end
|
|
207
|
+
|
|
187
208
|
def side_by_side_diff_column_width
|
|
188
209
|
content_width = [screen_width - 4, 1].max
|
|
189
210
|
gutter_width = [[999.to_s.length, 4].max + 3, 1].max
|
|
@@ -220,6 +241,7 @@ module Kward
|
|
|
220
241
|
@editor_text_width = nil
|
|
221
242
|
@editor_save_as_active = false
|
|
222
243
|
@editor_save_as_buffer = ""
|
|
244
|
+
@editor_save_callback = nil
|
|
223
245
|
@editor_state = nil
|
|
224
246
|
@prompt_label = "You>"
|
|
225
247
|
self.composer_input = ""
|
|
@@ -1150,6 +1172,17 @@ module Kward
|
|
|
1150
1172
|
return true
|
|
1151
1173
|
end
|
|
1152
1174
|
|
|
1175
|
+
if @editor_save_callback
|
|
1176
|
+
result = @editor_save_callback.call(@editor_state.buffer)
|
|
1177
|
+
if result.to_s.empty?
|
|
1178
|
+
close_editor
|
|
1179
|
+
return true
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
@editor_state.status = result.to_s
|
|
1183
|
+
return false
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1153
1186
|
if path.to_s.strip.empty? && @editor_state.path.to_s.empty?
|
|
1154
1187
|
if prompt_for_path
|
|
1155
1188
|
begin_editor_save_as
|