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.
Files changed (61) 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 +24 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +4 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/configuration.md +68 -2
  9. data/doc/extensibility.md +2 -0
  10. data/doc/getting-started.md +1 -1
  11. data/doc/local-models.md +130 -0
  12. data/doc/permissions.md +3 -2
  13. data/doc/releasing.md +1 -1
  14. data/doc/rpc.md +12 -1
  15. data/doc/sandboxing.md +120 -0
  16. data/doc/security.md +11 -5
  17. data/doc/skills.md +10 -0
  18. data/doc/workspace-tools.md +3 -3
  19. data/kward.gemspec +1 -1
  20. data/lib/kward/cli/doctor.rb +21 -0
  21. data/lib/kward/cli/runtime_helpers.rb +1 -1
  22. data/lib/kward/cli/settings.rb +66 -4
  23. data/lib/kward/cli/slash_commands.rb +109 -1
  24. data/lib/kward/cli.rb +2 -1
  25. data/lib/kward/config_files.rb +80 -1
  26. data/lib/kward/conversation.rb +14 -1
  27. data/lib/kward/local_command_runner.rb +2 -2
  28. data/lib/kward/model/client.rb +140 -4
  29. data/lib/kward/model/model_info.rb +11 -1
  30. data/lib/kward/model/payloads.rb +1 -0
  31. data/lib/kward/model/stream_parser.rb +58 -26
  32. data/lib/kward/pan/index.html.erb +50 -0
  33. data/lib/kward/pan/server.rb +49 -2
  34. data/lib/kward/plugin_registry.rb +1 -1
  35. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  36. data/lib/kward/prompt_interface.rb +18 -0
  37. data/lib/kward/prompts/commands.rb +2 -0
  38. data/lib/kward/prompts.rb +16 -5
  39. data/lib/kward/rpc/plugin_chat_manager.rb +4 -1
  40. data/lib/kward/rpc/server.rb +42 -4
  41. data/lib/kward/rpc/session_manager.rb +42 -8
  42. data/lib/kward/sandbox/capabilities.rb +39 -0
  43. data/lib/kward/sandbox/command_runner.rb +28 -0
  44. data/lib/kward/sandbox/environment.rb +24 -0
  45. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  46. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  47. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  48. data/lib/kward/sandbox/policy.rb +74 -0
  49. data/lib/kward/sandbox/runner_factory.rb +55 -0
  50. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  51. data/lib/kward/sandbox.rb +9 -0
  52. data/lib/kward/session_store.rb +26 -0
  53. data/lib/kward/skills/capture.rb +144 -0
  54. data/lib/kward/tools/registry.rb +19 -4
  55. data/lib/kward/version.rb +1 -1
  56. data/lib/kward/workspace.rb +18 -3
  57. data/lib/kward/workspace_factory.rb +17 -0
  58. data/templates/default/fulldoc/html/js/kward.js +1 -0
  59. data/templates/default/kward_navigation.rb +4 -2
  60. data/templates/default/layout/html/setup.rb +1 -0
  61. metadata +16 -2
@@ -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
- 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)
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?(_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
@@ -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)
@@ -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 || '');
@@ -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 "../workspace"
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 = Workspace.new(root: working_directory)
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 << side_by_side_diff_row(deleted && "- #{deleted}", "+ #{line[1..]}", width)
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 << side_by_side_diff_row(" #{text}", " #{text}", width)
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).map { |line| side_by_side_diff_row("- #{line}", nil, width) }
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