kward 0.78.0 → 0.80.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -13,47 +13,79 @@ 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}"
46
48
  end
47
49
 
48
- 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)
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
78
+ end
79
+
80
+ def parse_codex_sse(body, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
49
81
  state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
50
82
  body.split(/\r?\n\r?\n/).each do |block|
51
- process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
83
+ process_codex_sse_block(block, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
52
84
  end
53
85
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
54
86
  codex_sse_message(state)
55
87
  rescue JSON::ParserError => e
56
- raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
88
+ raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
57
89
  end
58
90
 
59
91
  # Incrementally parses a Codex/Responses SSE HTTP response body.
@@ -61,7 +93,7 @@ module Kward
61
93
  # Deltas are yielded as soon as complete SSE blocks arrive so interactive
62
94
  # frontends can render streamed assistant and reasoning text without waiting
63
95
  # for the provider to close the response.
64
- def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
96
+ def parse_codex_sse_stream(response, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
65
97
  state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
66
98
  buffer = +""
67
99
 
@@ -72,15 +104,15 @@ module Kward
72
104
  delimiter = Regexp.last_match[0]
73
105
  block = buffer[0...index]
74
106
  buffer = buffer[(index + delimiter.length)..] || +""
75
- process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
107
+ process_codex_sse_block(block, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
76
108
  end
77
109
  end
78
110
  cancellation&.raise_if_cancelled!
79
- process_codex_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
111
+ process_codex_sse_block(buffer, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
80
112
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
81
113
  codex_sse_message(state)
82
114
  rescue JSON::ParserError => e
83
- raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
115
+ raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
84
116
  end
85
117
 
86
118
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
@@ -103,6 +135,71 @@ module Kward
103
135
  raise "Anthropic returned invalid SSE JSON: #{e.message}"
104
136
  end
105
137
 
138
+ def parse_gemini_sse_stream(response, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil)
139
+ state = { content: +"", calls: {}, usage: nil }
140
+ buffer = +""
141
+ response.read_body do |chunk|
142
+ cancellation&.raise_if_cancelled!
143
+ buffer << chunk
144
+ while (index = buffer.index(/\r?\n\r?\n/))
145
+ delimiter = Regexp.last_match[0]
146
+ block = buffer[0...index]
147
+ buffer = buffer[(index + delimiter.length)..] || +""
148
+ process_gemini_sse_block(block, state, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: usage_normalizer)
149
+ end
150
+ end
151
+ cancellation&.raise_if_cancelled!
152
+ process_gemini_sse_block(buffer, state, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: usage_normalizer) unless buffer.empty?
153
+ gemini_sse_message(state)
154
+ rescue JSON::ParserError => e
155
+ raise "Google Gemini returned invalid SSE JSON: #{e.message}"
156
+ end
157
+
158
+ def process_gemini_sse_block(block, state, on_assistant_delta:, cancellation:, usage_normalizer:)
159
+ data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
160
+ return if data.empty? || data == "[DONE]"
161
+
162
+ cancellation&.raise_if_cancelled!
163
+ event = JSON.parse(data)
164
+ Array(event["candidates"]).each_with_index do |candidate, candidate_index|
165
+ Array(candidate.dig("content", "parts")).each_with_index do |part, part_index|
166
+ text = part["text"].to_s
167
+ unless text.empty?
168
+ state[:content] << text
169
+ on_assistant_delta&.call(text)
170
+ end
171
+ merge_gemini_function_call(state[:calls], part["functionCall"], candidate_index: candidate_index, part_index: part_index)
172
+ end
173
+ end
174
+ state[:usage] = usage_normalizer&.call(event["usageMetadata"]) || state[:usage]
175
+ end
176
+
177
+ def merge_gemini_function_call(calls, function_call, candidate_index:, part_index:)
178
+ return unless function_call.is_a?(Hash)
179
+
180
+ key = function_call["id"] || "#{candidate_index}:#{part_index}:#{function_call["name"]}"
181
+ call = calls[key] ||= { "id" => function_call["id"] || "call_gemini_#{candidate_index}_#{part_index}", "name" => function_call["name"].to_s, "args" => {}, "args_buffer" => +"" }
182
+ call["name"] = function_call["name"].to_s unless function_call["name"].to_s.empty?
183
+ args = function_call["args"]
184
+ if args.is_a?(Hash)
185
+ call["args"].merge!(args)
186
+ elsif !args.nil?
187
+ call["args_buffer"] << args.to_s
188
+ end
189
+ end
190
+
191
+ def gemini_sse_message(state)
192
+ message = { "role" => "assistant", "content" => state[:content] }
193
+ unless state[:calls].empty?
194
+ message["tool_calls"] = state[:calls].values.map do |call|
195
+ arguments = call["args_buffer"].empty? ? JSON.dump(call["args"]) : call["args_buffer"]
196
+ { "id" => call["id"], "type" => "function", "function" => { "name" => call["name"], "arguments" => arguments } }
197
+ end
198
+ end
199
+ message["usage"] = state[:usage] if state[:usage]
200
+ message
201
+ end
202
+
106
203
  def anthropic_sse_state
107
204
  { content: +"", reasoning_summary: +"", blocks: {}, tool_calls: [], usage: nil }
108
205
  end
@@ -201,7 +298,7 @@ module Kward
201
298
 
202
299
  def merge_streaming_tool_call(tool_calls, delta)
203
300
  index = (delta["index"] || tool_calls.length).to_i
204
- tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
301
+ tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => +"", "arguments" => +"" } }
205
302
  current = tool_calls[index]
206
303
  current["id"] = delta["id"] if delta["id"]
207
304
  current["type"] = delta["type"] if delta["type"]
@@ -239,7 +336,7 @@ module Kward
239
336
  }
240
337
  end
241
338
 
242
- def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
339
+ def process_codex_sse_block(block, state, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
243
340
  data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
244
341
  return if data.empty? || data == "[DONE]"
245
342
 
@@ -277,11 +374,11 @@ module Kward
277
374
  end
278
375
  end
279
376
  when "response.failed", "response.incomplete"
280
- raise codex_sse_error(event, request_error_class: request_error_class)
377
+ raise codex_sse_error(event, provider_label: provider_label, request_error_class: request_error_class)
281
378
  end
282
379
  end
283
380
 
284
- def codex_sse_error(event, request_error_class: nil)
381
+ def codex_sse_error(event, provider_label: "Codex", request_error_class: nil)
285
382
  response = event["response"]
286
383
  error = event["error"] || (response["error"] if response.is_a?(Hash)) || {}
287
384
  message = if error.is_a?(Hash)
@@ -292,7 +389,7 @@ module Kward
292
389
  message = event["type"].to_s if message.empty?
293
390
  code = error.is_a?(Hash) && error["code"].to_s == "server_error" ? 500 : 400
294
391
  if request_error_class
295
- request_error_class.new(provider: "Codex", code: code, body: "#{event["type"]}: #{message}")
392
+ request_error_class.new(provider: provider_label, code: code, body: "#{event["type"]}: #{message}")
296
393
  else
297
394
  "#{event["type"]}: #{message}"
298
395
  end
@@ -332,6 +429,10 @@ module Kward
332
429
  part["type"] ||= "output_text"
333
430
  text_key = part["type"] == "refusal" ? "refusal" : "text"
334
431
  part[text_key] = part[text_key].to_s + text
432
+ if codex_streamable_message_item?(item)
433
+ on_assistant_delta&.call(text)
434
+ state[:emitted_message_keys] << codex_item_key(item)
435
+ end
335
436
  end
336
437
  state[:raw_content] << text
337
438
  end
@@ -669,7 +770,8 @@ module Kward
669
770
  end
670
771
 
671
772
  def codex_streamable_message_item?(item)
672
- codex_message_phase(item) == "final_answer"
773
+ phase = codex_message_phase(item)
774
+ phase.empty? || phase == "final_answer"
673
775
  end
674
776
 
675
777
  def codex_message_phase(item)
@@ -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
 
@@ -13,6 +13,7 @@ module Kward
13
13
  MODES = %w[ask read-only workspace-write deny-by-default].freeze
14
14
  NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
15
15
  FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
16
+ MUTATING_TOOLS = (FILE_CHANGE_TOOLS + ["git_commit"]).freeze
16
17
 
17
18
  Decision = Struct.new(:action, :reason, keyword_init: true) do
18
19
  def allowed?
@@ -96,13 +97,15 @@ module Kward
96
97
  def request_for(tool_name, arguments, source:)
97
98
  name = tool_name.to_s
98
99
  args = arguments.to_h
99
- {
100
+ request = {
100
101
  "tool" => name,
101
102
  "path" => args["path"] || args[:path],
102
103
  "host" => request_host(name, args),
103
104
  "command" => args["command"] || args[:command],
104
105
  "source" => source.to_s
105
106
  }.compact.transform_values(&:to_s)
107
+ request["paths"] = args["paths"] || args[:paths] if name == "git_commit"
108
+ request
106
109
  end
107
110
 
108
111
  def request_host(name, arguments)
@@ -135,11 +138,11 @@ module Kward
135
138
  return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
136
139
  return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
137
140
 
138
- if file_change?(request) && outside_write_scopes?(request)
141
+ if mutating?(request) && outside_write_scopes?(request)
139
142
  return Decision.new(action: :deny, reason: "path outside write scopes")
140
143
  end
141
144
 
142
- if @mode == "workspace-write" && file_change?(request)
145
+ if @mode == "workspace-write" && mutating?(request)
143
146
  return Decision.new(action: :allow, reason: "workspace write mode")
144
147
  end
145
148
 
@@ -152,17 +155,28 @@ module Kward
152
155
  FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
153
156
  end
154
157
 
158
+ def mutating?(request)
159
+ MUTATING_TOOLS.include?(request.fetch("tool"))
160
+ end
161
+
155
162
  def network?(request)
156
163
  NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
157
164
  end
158
165
 
159
166
  def risky?(request)
160
- file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
167
+ mutating?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
161
168
  end
162
169
 
163
170
  def outside_write_scopes?(request)
164
171
  return false if @write_scopes.nil?
165
172
 
173
+ if request.fetch("tool") == "git_commit"
174
+ paths = request["paths"]
175
+ return true unless paths.is_a?(Array) && !paths.empty?
176
+
177
+ return paths.any? { |path| !@write_scopes.any? { |scope| glob_match?(scope, path) } }
178
+ end
179
+
166
180
  path = request["path"]
167
181
  path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
168
182
  end