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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -0
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +4 -4
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/kward.gemspec +1 -1
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +43 -10
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- 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
|
-
|
|
18
|
-
tool_calls = []
|
|
19
|
-
usage = nil
|
|
16
|
+
def parse_openai_chat_sse(body, on_assistant_delta: nil, usage_normalizer: nil, provider_label: "OpenAI-compatible provider")
|
|
17
|
+
state = openai_chat_sse_state
|
|
20
18
|
body.split(/\r?\n\r?\n/).each do |block|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
process_openai_chat_sse_block(block, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer)
|
|
20
|
+
end
|
|
21
|
+
openai_chat_sse_message(state)
|
|
22
|
+
rescue JSON::ParserError => e
|
|
23
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Incrementally parses a Chat Completions SSE HTTP response body. Local
|
|
27
|
+
# OpenAI-compatible servers stream this shape, so emit assistant deltas as
|
|
28
|
+
# complete SSE blocks arrive rather than waiting for connection close.
|
|
29
|
+
def parse_openai_chat_sse_stream(response, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, provider_label: "OpenAI-compatible provider")
|
|
30
|
+
state = openai_chat_sse_state
|
|
31
|
+
buffer = +""
|
|
32
|
+
|
|
33
|
+
response.read_body do |chunk|
|
|
34
|
+
cancellation&.raise_if_cancelled!
|
|
35
|
+
buffer << chunk
|
|
36
|
+
while (index = buffer.index(/\r?\n\r?\n/))
|
|
37
|
+
delimiter = Regexp.last_match[0]
|
|
38
|
+
block = buffer[0...index]
|
|
39
|
+
buffer = buffer[(index + delimiter.length)..] || +""
|
|
40
|
+
process_openai_chat_sse_block(block, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer)
|
|
35
41
|
end
|
|
36
|
-
message = choice["message"] || {}
|
|
37
|
-
content << message["content"].to_s if content.empty? && message["content"]
|
|
38
|
-
Array(message["tool_calls"]).each { |tool_call| merge_streaming_tool_call(tool_calls, tool_call) }
|
|
39
42
|
end
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
result
|
|
43
|
+
cancellation&.raise_if_cancelled!
|
|
44
|
+
process_openai_chat_sse_block(buffer, state, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer) unless buffer.empty?
|
|
45
|
+
openai_chat_sse_message(state)
|
|
44
46
|
rescue JSON::ParserError => e
|
|
45
|
-
raise "
|
|
47
|
+
raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
def
|
|
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 "
|
|
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 "
|
|
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:
|
|
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)
|
|
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 || '');
|
data/lib/kward/pan/server.rb
CHANGED
|
@@ -13,11 +13,12 @@ require_relative "../plugin_registry"
|
|
|
13
13
|
require_relative "../prompts/commands"
|
|
14
14
|
require_relative "../rpc/transcript_normalizer"
|
|
15
15
|
require_relative "../session_store"
|
|
16
|
+
require_relative "../skills/capture"
|
|
16
17
|
require_relative "../session_trash"
|
|
17
18
|
require_relative "../tools/tool_call"
|
|
18
19
|
require_relative "../tools/registry"
|
|
19
20
|
require_relative "../version"
|
|
20
|
-
require_relative "../
|
|
21
|
+
require_relative "../workspace_factory"
|
|
21
22
|
|
|
22
23
|
# Namespace for the Kward CLI agent runtime.
|
|
23
24
|
module Kward
|
|
@@ -33,7 +34,11 @@ module Kward
|
|
|
33
34
|
@client = client
|
|
34
35
|
@output = output
|
|
35
36
|
@udp_socket_class = udp_socket_class
|
|
36
|
-
@workspace =
|
|
37
|
+
@workspace = WorkspaceFactory.build(
|
|
38
|
+
root: working_directory,
|
|
39
|
+
guardrails: ConfigFiles.workspace_guardrails_enabled?(config),
|
|
40
|
+
config: config
|
|
41
|
+
)
|
|
37
42
|
@full_config = config
|
|
38
43
|
@config = pan_config(config)
|
|
39
44
|
@host = @config.fetch("host", DEFAULT_HOST).to_s
|
|
@@ -234,12 +239,18 @@ module Kward
|
|
|
234
239
|
write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
|
|
235
240
|
when ["GET", "/sessions"]
|
|
236
241
|
write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
|
|
242
|
+
when ["GET", "/skill-capture/sessions"]
|
|
243
|
+
write_json(socket, 200, sessions: skill_capture_sessions)
|
|
237
244
|
when ["GET", "/events"]
|
|
238
245
|
stream_events(socket)
|
|
239
246
|
when ["POST", "/turn"]
|
|
240
247
|
handle_turn(socket, request[:body], request[:headers])
|
|
241
248
|
when ["POST", "/sessions"]
|
|
242
249
|
handle_session_action(socket, request[:body], request[:headers])
|
|
250
|
+
when ["POST", "/skill-capture/draft"]
|
|
251
|
+
handle_skill_capture_draft(socket, request[:body], request[:headers])
|
|
252
|
+
when ["POST", "/skill-capture/save"]
|
|
253
|
+
handle_skill_capture_save(socket, request[:body], request[:headers])
|
|
243
254
|
else
|
|
244
255
|
write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
|
|
245
256
|
end
|
|
@@ -308,6 +319,42 @@ module Kward
|
|
|
308
319
|
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
309
320
|
end
|
|
310
321
|
|
|
322
|
+
def handle_skill_capture_draft(socket, body, headers)
|
|
323
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
324
|
+
|
|
325
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
326
|
+
draft = skill_capture.generate(params.fetch("sessionPath"))
|
|
327
|
+
write_json(socket, 200, ok: true, content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description)
|
|
328
|
+
rescue JSON::ParserError
|
|
329
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
330
|
+
rescue Skills::Capture::Error => error
|
|
331
|
+
write_json(socket, 422, ok: false, error: error.message)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def handle_skill_capture_save(socket, body, headers)
|
|
335
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
336
|
+
|
|
337
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
338
|
+
draft = skill_capture.save(params.fetch("content"), overwrite: params["overwrite"] == true)
|
|
339
|
+
write_json(socket, 200, ok: true, path: skill_capture.skill_path(draft.name), name: draft.name, description: draft.description)
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
342
|
+
rescue Skills::Capture::ConflictError => error
|
|
343
|
+
write_json(socket, 409, ok: false, error: error.message)
|
|
344
|
+
rescue Skills::Capture::Error => error
|
|
345
|
+
write_json(socket, 422, ok: false, error: error.message)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def skill_capture_sessions
|
|
349
|
+
@session_store.capture_candidates.map do |session|
|
|
350
|
+
{ path: session.path, name: session.name, firstMessage: session.first_message, workspaceRoot: session.cwd, modifiedAt: session.modified_at.utc.iso8601(3) }
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def skill_capture
|
|
355
|
+
@skill_capture ||= Skills::Capture.new(session_store: @session_store, client: @client, config_dir: @session_store.config_dir)
|
|
356
|
+
end
|
|
357
|
+
|
|
311
358
|
def update_session(params)
|
|
312
359
|
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
313
360
|
|
|
@@ -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
|
|
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" &&
|
|
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
|
-
|
|
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
|