kward 0.77.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +50 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +5 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/code-search.md +9 -6
  9. data/doc/configuration.md +106 -1
  10. data/doc/extensibility.md +2 -0
  11. data/doc/getting-started.md +1 -1
  12. data/doc/local-models.md +130 -0
  13. data/doc/permissions.md +180 -0
  14. data/doc/plugins.md +58 -0
  15. data/doc/releasing.md +1 -1
  16. data/doc/rpc.md +73 -1
  17. data/doc/sandboxing.md +120 -0
  18. data/doc/security.md +15 -5
  19. data/doc/skills.md +10 -0
  20. data/doc/tabs.md +4 -3
  21. data/doc/web-search.md +4 -2
  22. data/doc/workspace-tools.md +3 -3
  23. data/kward.gemspec +1 -1
  24. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  25. data/lib/kward/auth/github_oauth.rb +3 -3
  26. data/lib/kward/auth/oauth_helpers.rb +4 -2
  27. data/lib/kward/auth/openai_oauth.rb +2 -1
  28. data/lib/kward/cli/doctor.rb +21 -0
  29. data/lib/kward/cli/plugins.rb +22 -0
  30. data/lib/kward/cli/rendering.rb +7 -1
  31. data/lib/kward/cli/runtime_helpers.rb +15 -1
  32. data/lib/kward/cli/settings.rb +66 -4
  33. data/lib/kward/cli/slash_commands.rb +109 -1
  34. data/lib/kward/cli/tabs.rb +140 -34
  35. data/lib/kward/cli.rb +31 -10
  36. data/lib/kward/config_files.rb +90 -1
  37. data/lib/kward/conversation.rb +14 -1
  38. data/lib/kward/hooks/http_handler.rb +2 -1
  39. data/lib/kward/http.rb +18 -0
  40. data/lib/kward/local_command_runner.rb +2 -2
  41. data/lib/kward/model/client.rb +173 -15
  42. data/lib/kward/model/model_info.rb +11 -1
  43. data/lib/kward/model/payloads.rb +7 -1
  44. data/lib/kward/model/stream_parser.rb +58 -26
  45. data/lib/kward/openrouter_model_cache.rb +2 -1
  46. data/lib/kward/pan/index.html.erb +50 -0
  47. data/lib/kward/pan/server.rb +49 -2
  48. data/lib/kward/permissions/policy.rb +171 -0
  49. data/lib/kward/plugin_registry.rb +54 -2
  50. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  51. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  52. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  53. data/lib/kward/prompt_interface.rb +20 -0
  54. data/lib/kward/prompts/commands.rb +2 -0
  55. data/lib/kward/prompts.rb +16 -5
  56. data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
  57. data/lib/kward/rpc/server.rb +76 -3
  58. data/lib/kward/rpc/session_manager.rb +43 -8
  59. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  60. data/lib/kward/sandbox/capabilities.rb +39 -0
  61. data/lib/kward/sandbox/command_runner.rb +28 -0
  62. data/lib/kward/sandbox/environment.rb +24 -0
  63. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  64. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  65. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  66. data/lib/kward/sandbox/policy.rb +74 -0
  67. data/lib/kward/sandbox/runner_factory.rb +55 -0
  68. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  69. data/lib/kward/sandbox.rb +9 -0
  70. data/lib/kward/session_store.rb +26 -0
  71. data/lib/kward/skills/capture.rb +144 -0
  72. data/lib/kward/starter_pack_installer.rb +3 -1
  73. data/lib/kward/tab_driver.rb +87 -0
  74. data/lib/kward/tab_store.rb +74 -12
  75. data/lib/kward/tools/code_search.rb +8 -2
  76. data/lib/kward/tools/fetch_content.rb +4 -2
  77. data/lib/kward/tools/fetch_raw.rb +3 -1
  78. data/lib/kward/tools/registry.rb +56 -8
  79. data/lib/kward/tools/search/code.rb +46 -12
  80. data/lib/kward/tools/search/web.rb +60 -17
  81. data/lib/kward/tools/search/web_fetch.rb +206 -38
  82. data/lib/kward/tools/web_search.rb +3 -1
  83. data/lib/kward/update_check.rb +2 -1
  84. data/lib/kward/version.rb +1 -1
  85. data/lib/kward/workspace.rb +18 -3
  86. data/lib/kward/workspace_factory.rb +17 -0
  87. data/templates/default/fulldoc/html/js/kward.js +1 -0
  88. data/templates/default/kward_navigation.rb +5 -2
  89. data/templates/default/layout/html/layout.erb +2 -0
  90. data/templates/default/layout/html/setup.rb +2 -0
  91. metadata +22 -2
@@ -18,7 +18,7 @@ module Kward
18
18
  @terminate_on_output_limit = terminate_on_output_limit
19
19
  end
20
20
 
21
- def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
21
+ def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, unsetenv_others: false, &block)
22
22
  cancellation&.raise_if_cancelled!
23
23
  stdout_buffer = +""
24
24
  stderr_buffer = +""
@@ -27,7 +27,7 @@ module Kward
27
27
  timed_out = false
28
28
  queue = Queue.new
29
29
 
30
- Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
30
+ Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true, unsetenv_others: unsetenv_others) do |stdin, stdout, stderr, wait_thread|
31
31
  stdin.close
32
32
  readers = [
33
33
  read_stream(stdout, :stdout, queue),
@@ -6,6 +6,7 @@ require_relative "../auth/github_oauth"
6
6
  require_relative "../auth/openai_oauth"
7
7
  require_relative "../cancellation"
8
8
  require_relative "../config_files"
9
+ require_relative "../http"
9
10
  require_relative "../openrouter_model_cache"
10
11
  require_relative "context_overflow"
11
12
  require_relative "copilot_models"
@@ -28,6 +29,11 @@ module Kward
28
29
  OPENROUTER_URL = URI("https://openrouter.ai/api/v1/chat/completions")
29
30
  CODEX_URL = URI("https://chatgpt.com/backend-api/codex/responses")
30
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
31
37
  AUTH_ERROR = "No OpenAI OAuth login found. Run `ruby lib/main.rb login`, or set OPENAI_ACCESS_TOKEN/OPENROUTER_API_KEY."
32
38
  OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Set OPENROUTER_API_KEY or add openrouter_api_key to your Kward config."
33
39
  COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `ruby lib/main.rb login github` or set COPILOT_GITHUB_TOKEN."
@@ -35,6 +41,7 @@ module Kward
35
41
  DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
36
42
  DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
37
43
  RETRY_DELAYS = [1, 2].freeze
44
+ DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 120
38
45
  NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS = [
39
46
  /GoUsageLimitError/i,
40
47
  /FreeUsageLimitError/i,
@@ -92,18 +99,19 @@ module Kward
92
99
  @telemetry_logger = telemetry_logger
93
100
  @copilot_models = nil
94
101
  @openrouter_models = nil
102
+ @local_models = nil
95
103
  end
96
104
 
97
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)
98
106
  cancellation&.raise_if_cancelled!
99
107
  requested_provider = provider
100
108
  url, token, resolved_provider, account_id = credentials(provider: requested_provider)
101
- 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?
102
110
  url, token, resolved_provider, account_id = credentials
103
111
  model = nil
104
112
  reasoning = nil
105
113
  end
106
- 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?)
107
115
 
108
116
  current_model = model_for(resolved_provider, override_model: model)
109
117
  current_model = resolved_copilot_chat_model(current_model) if resolved_provider == "Copilot" && model.nil?
@@ -178,7 +186,10 @@ module Kward
178
186
 
179
187
  # Returns the known context window for a provider/model pair.
180
188
  def context_window(provider, model)
181
- context_window_for(ModelInfo.provider_label(provider), model)
189
+ provider = ModelInfo.provider_label(provider)
190
+ return local_context_window if provider == "Local"
191
+
192
+ context_window_for(provider, model)
182
193
  end
183
194
 
184
195
  # Returns model choices suitable for settings UIs.
@@ -224,6 +235,14 @@ module Kward
224
235
  models << model_entry("Anthropic", anthropic_model, current: provider == "Anthropic") unless ModelInfo::ANTHROPIC_MODEL_CHOICES.include?(anthropic_model)
225
236
  end
226
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
+
227
246
  # Sort models by provider, then alphabetically by id
228
247
  models.sort_by { |model| [model[:provider], model[:id]] }
229
248
  end
@@ -245,6 +264,7 @@ module Kward
245
264
  @config = load_config
246
265
  @copilot_models = nil
247
266
  @openrouter_models = nil
267
+ @local_models = nil
248
268
  end
249
269
 
250
270
  private
@@ -296,6 +316,15 @@ module Kward
296
316
  on_assistant_delta: on_assistant_delta,
297
317
  cancellation: cancellation
298
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
+ )
299
328
  else
300
329
  chat_openrouter_provider(
301
330
  url: url,
@@ -335,7 +364,7 @@ module Kward
335
364
  end
336
365
 
337
366
  def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
338
- request = Net::HTTP::Post.new(url)
367
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
339
368
  request["Authorization"] = "Bearer #{token}"
340
369
  request["Content-Type"] = "application/json"
341
370
  request["Accept"] = "text/event-stream"
@@ -343,7 +372,7 @@ module Kward
343
372
  request.body = request_body
344
373
 
345
374
  message = nil
346
- Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
375
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
347
376
  cancellation&.on_cancel { close_http(http) }
348
377
  cancellation&.raise_if_cancelled!
349
378
  http.request(request) do |response|
@@ -360,8 +389,33 @@ module Kward
360
389
  attach_response_metadata(message, provider: "Anthropic", model: current_model)
361
390
  end
362
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
+
363
417
  def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
364
- request = Net::HTTP::Post.new(url)
418
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
365
419
  request["Authorization"] = "Bearer #{token}"
366
420
  request["Content-Type"] = "application/json"
367
421
  request.body = request_body
@@ -502,9 +556,30 @@ module Kward
502
556
  end
503
557
 
504
558
  def context_window_for(provider, id)
559
+ return local_context_window if provider == "Local"
560
+
505
561
  ModelInfo.context_window(provider, id, openrouter_models: openrouter_cached_model_entries)
506
562
  end
507
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
+
508
583
  def openrouter_model_choices
509
584
  openrouter_cached_models.uniq
510
585
  end
@@ -547,7 +622,7 @@ module Kward
547
622
  return [] if token.empty?
548
623
 
549
624
  url = URI("#{@github_oauth.base_url}/models")
550
- request = Net::HTTP::Get.new(url)
625
+ request = Http.apply_user_agent(Net::HTTP::Get.new(url))
551
626
  request["Authorization"] = "Bearer #{token}"
552
627
  request["Accept"] = "application/json"
553
628
  copilot_headers([]).each { |key, value| request[key] = value }
@@ -577,7 +652,7 @@ module Kward
577
652
 
578
653
  def copilot_responses_chat(token, request_body:, on_assistant_delta: nil, cancellation: nil)
579
654
  url = URI("#{@github_oauth.base_url}/responses")
580
- request = Net::HTTP::Post.new(url)
655
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
581
656
  request["Authorization"] = "Bearer #{token}"
582
657
  request["Content-Type"] = "application/json"
583
658
  request["Accept"] = "text/event-stream"
@@ -585,7 +660,7 @@ module Kward
585
660
  request.body = request_body
586
661
 
587
662
  message = nil
588
- Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
663
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
589
664
  cancellation&.on_cancel { close_http(http) }
590
665
  cancellation&.raise_if_cancelled!
591
666
  http.request(request) do |response|
@@ -603,7 +678,7 @@ module Kward
603
678
  end
604
679
 
605
680
  def copilot_chat(url, token, messages, tools, request_body: nil, on_assistant_delta: nil, cancellation: nil)
606
- request = Net::HTTP::Post.new(url)
681
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
607
682
  request["Authorization"] = "Bearer #{token}"
608
683
  request["Content-Type"] = "application/json"
609
684
  request["Accept"] = "text/event-stream"
@@ -625,7 +700,11 @@ module Kward
625
700
  end
626
701
 
627
702
  def parse_openai_chat_sse(body, on_assistant_delta: nil)
628
- 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)
629
708
  end
630
709
 
631
710
  def anthropic_headers
@@ -633,7 +712,6 @@ module Kward
633
712
  "anthropic-version" => "2023-06-01",
634
713
  "anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
635
714
  "anthropic-dangerous-direct-browser-access" => "true",
636
- "user-agent" => "claude-cli/2.1.75",
637
715
  "x-app" => "cli"
638
716
  }
639
717
  end
@@ -653,16 +731,16 @@ module Kward
653
731
  end
654
732
 
655
733
  def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
656
- request = Net::HTTP::Post.new(url)
734
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
657
735
  request["Authorization"] = "Bearer #{token}"
658
736
  request["ChatGPT-Account-Id"] = account_id if account_id
659
737
  request["Content-Type"] = "application/json"
660
738
  request["Accept"] = "text/event-stream"
661
- request["originator"] = "codex_cli_rs"
662
739
  request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
740
+ apply_codex_identity(request, luna: luna_request?(request.body))
663
741
 
664
742
  message = nil
665
- Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
743
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
666
744
  cancellation&.on_cancel { close_http(http) }
667
745
  cancellation&.raise_if_cancelled!
668
746
  http.request(request) do |response|
@@ -684,6 +762,22 @@ module Kward
684
762
  raise e
685
763
  end
686
764
 
765
+ def apply_codex_identity(request, luna:)
766
+ request["originator"] = "kward"
767
+ return unless luna
768
+
769
+ # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
770
+ request["originator"] = "codex_cli_rs"
771
+ request["User-Agent"] = "codex_cli_rs/0.144.1"
772
+ request["x-openai-internal-codex-responses-lite"] = "true"
773
+ end
774
+
775
+ def luna_request?(request_body)
776
+ JSON.parse(request_body)["model"] == "gpt-5.6-luna"
777
+ rescue JSON::ParserError
778
+ false
779
+ end
780
+
687
781
  def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
688
782
  ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
689
783
  end
@@ -759,6 +853,10 @@ module Kward
759
853
 
760
854
  def credentials(provider: nil)
761
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
+
762
860
  if provider == "Copilot"
763
861
  return [copilot_chat_url, github_access_token, provider, nil]
764
862
  end
@@ -789,8 +887,14 @@ module Kward
789
887
  ModelInfo.reasoning_effort(config: @config, provider: provider)
790
888
  end
791
889
 
890
+ def authentication_required?(provider)
891
+ provider != "Local"
892
+ end
893
+
792
894
  def provider_logged_in?(provider)
793
895
  case provider
896
+ when "Local"
897
+ local_configured?
794
898
  when "Codex"
795
899
  openai_configured?
796
900
  when "OpenRouter"
@@ -812,6 +916,53 @@ module Kward
812
916
  false
813
917
  end
814
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
+
815
966
  def openrouter_api_key
816
967
  @openrouter_api_key || config_value("openrouter_api_key")
817
968
  end
@@ -842,11 +993,18 @@ module Kward
842
993
  @config["codex_show_raw_reasoning"] == true
843
994
  end
844
995
 
996
+ def stream_idle_timeout_seconds
997
+ value = @config["stream_idle_timeout_seconds"].to_i
998
+ value.positive? ? value : DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS
999
+ end
1000
+
845
1001
  def load_config
846
1002
  ConfigFiles.read_config(@config_path)
847
1003
  end
848
1004
 
849
1005
  def redact(text, token)
1006
+ return text.to_s if token.to_s.empty?
1007
+
850
1008
  text.to_s.gsub(token.to_s, "[REDACTED]")
851
1009
  end
852
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?(_provider, id)
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
 
@@ -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
@@ -126,7 +127,12 @@ module Kward
126
127
  store: false,
127
128
  include: []
128
129
  }
129
- payload[:reasoning] = { effort: reasoning || reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
130
+ unless reasoning == false
131
+ effort = reasoning || reasoning_effort("Codex")
132
+ payload[:reasoning] = { effort: effort, summary: "auto" }
133
+ # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
134
+ payload[:reasoning][:context] = "all_turns" if parts[:model] == "gpt-5.6-luna"
135
+ end
130
136
  payload
131
137
  end
132
138
 
@@ -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
- content = +""
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
- data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
22
- next if data.empty? || data == "[DONE]"
23
-
24
- event = JSON.parse(data)
25
- usage ||= usage_normalizer&.call(event["usage"])
26
- choice = Array(event["choices"]).first || {}
27
- delta = choice["delta"] || {}
28
- if delta["content"]
29
- text = delta["content"].to_s
30
- content << text
31
- on_assistant_delta&.call(text)
32
- end
33
- Array(delta["tool_calls"]).each do |tool_call|
34
- merge_streaming_tool_call(tool_calls, tool_call)
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
- result = { "role" => "assistant", "content" => content }
41
- result["tool_calls"] = finalized_streaming_tool_calls(tool_calls) unless tool_calls.empty?
42
- result["usage"] = usage if usage
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 "Copilot returned invalid SSE JSON: #{e.message}"
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)
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "time"
5
5
  require "uri"
6
6
  require_relative "config_files"
7
+ require_relative "http"
7
8
  require_relative "private_file"
8
9
 
9
10
  # Namespace for the Kward CLI agent runtime.
@@ -71,7 +72,7 @@ module Kward
71
72
  private
72
73
 
73
74
  def refresh_request
74
- Net::HTTP::Get.new(MODELS_URL).tap do |request|
75
+ Http.apply_user_agent(Net::HTTP::Get.new(MODELS_URL)).tap do |request|
75
76
  request["Authorization"] = "Bearer #{@api_key}"
76
77
  request["Accept"] = "application/json"
77
78
  end
@@ -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 || '');