kward 0.67.1 → 0.69.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/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +54 -0
- data/Gemfile.lock +8 -2
- data/README.md +37 -30
- data/Rakefile +14 -1
- data/doc/authentication.md +84 -43
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +27 -2
- data/doc/extensibility.md +90 -129
- data/doc/getting-started.md +53 -57
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -99
- data/doc/releasing.md +10 -9
- data/doc/rpc.md +7 -7
- data/doc/usage.md +125 -141
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +30 -3
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +229 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +227 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +134 -0
- data/lib/kward/cli/rendering.rb +378 -0
- data/lib/kward/cli/runtime_helpers.rb +170 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +669 -0
- data/lib/kward/cli/slash_commands.rb +114 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +157 -0
- data/lib/kward/cli.rb +52 -2792
- data/lib/kward/cli_transcript_formatter.rb +40 -12
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +31 -9
- data/lib/kward/config_files.rb +78 -34
- data/lib/kward/conversation.rb +110 -13
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +144 -14
- data/lib/kward/message_access.rb +29 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +14 -10
- data/lib/kward/model/model_info.rb +160 -4
- data/lib/kward/model/payloads.rb +254 -22
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +387 -25
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +63 -7
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +43 -11
- data/lib/kward/rpc/session_manager.rb +139 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +50 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +154 -25
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +3 -2
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +42 -4
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +20 -17
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +27 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +56 -1
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
4
|
module Kward
|
|
5
|
+
# Parses streaming provider responses into Kward assistant messages.
|
|
6
|
+
#
|
|
7
|
+
# `ModelStreamParser` is intentionally provider-shape focused and side-effect
|
|
8
|
+
# light: it accumulates assistant text, reasoning summaries, tool calls, and
|
|
9
|
+
# normalized usage from SSE events. Network IO, retries, credentials, and
|
|
10
|
+
# telemetry stay in `Client`; frontend rendering stays in CLI/RPC event
|
|
11
|
+
# handlers. Keep parser methods deterministic and easy to unit test with raw
|
|
12
|
+
# response bodies.
|
|
4
13
|
module ModelStreamParser
|
|
5
14
|
module_function
|
|
6
15
|
|
|
@@ -46,6 +55,11 @@ module Kward
|
|
|
46
55
|
raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
# Incrementally parses a Codex/Responses SSE HTTP response body.
|
|
59
|
+
#
|
|
60
|
+
# Deltas are yielded as soon as complete SSE blocks arrive so interactive
|
|
61
|
+
# frontends can render streamed assistant and reasoning text without waiting
|
|
62
|
+
# for the provider to close the response.
|
|
49
63
|
def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
|
|
50
64
|
state = codex_sse_state
|
|
51
65
|
buffer = +""
|
|
@@ -67,6 +81,122 @@ module Kward
|
|
|
67
81
|
raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
|
|
68
82
|
end
|
|
69
83
|
|
|
84
|
+
def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
|
|
85
|
+
state = anthropic_sse_state
|
|
86
|
+
buffer = +""
|
|
87
|
+
response.read_body do |chunk|
|
|
88
|
+
cancellation&.raise_if_cancelled!
|
|
89
|
+
buffer << chunk
|
|
90
|
+
while (index = buffer.index(/\r?\n\r?\n/))
|
|
91
|
+
delimiter = Regexp.last_match[0]
|
|
92
|
+
block = buffer[0...index]
|
|
93
|
+
buffer = buffer[(index + delimiter.length)..] || +""
|
|
94
|
+
process_anthropic_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
cancellation&.raise_if_cancelled!
|
|
98
|
+
process_anthropic_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
|
|
99
|
+
anthropic_sse_message(state)
|
|
100
|
+
rescue JSON::ParserError => e
|
|
101
|
+
raise "Anthropic returned invalid SSE JSON: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def anthropic_sse_state
|
|
105
|
+
{ content: +"", reasoning_summary: +"", blocks: {}, tool_calls: [], usage: nil }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def process_anthropic_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
109
|
+
event_name = nil
|
|
110
|
+
data = block.lines.filter_map do |line|
|
|
111
|
+
event_name = line.delete_prefix("event:").strip if line.start_with?("event:")
|
|
112
|
+
line.start_with?("data:") ? line.delete_prefix("data:").strip : nil
|
|
113
|
+
end.join("\n")
|
|
114
|
+
return if data.empty? || data == "[DONE]"
|
|
115
|
+
raise anthropic_sse_error(data, request_error_class: request_error_class) if event_name == "error"
|
|
116
|
+
|
|
117
|
+
event = JSON.parse(data)
|
|
118
|
+
case event["type"]
|
|
119
|
+
when "message_start"
|
|
120
|
+
state[:usage] ||= usage_normalizer&.call(event.dig("message", "usage"))
|
|
121
|
+
when "content_block_start"
|
|
122
|
+
block_data = event["content_block"] || {}
|
|
123
|
+
state[:blocks][event["index"].to_i] = block_data.merge("partial_json" => "")
|
|
124
|
+
if block_data["type"] == "text"
|
|
125
|
+
text = block_data["text"].to_s
|
|
126
|
+
state[:content] << text
|
|
127
|
+
on_assistant_delta&.call(text) unless text.empty?
|
|
128
|
+
elsif block_data["type"] == "thinking"
|
|
129
|
+
text = block_data["thinking"].to_s
|
|
130
|
+
state[:reasoning_summary] << text
|
|
131
|
+
on_reasoning_delta&.call(text) unless text.empty?
|
|
132
|
+
end
|
|
133
|
+
when "content_block_delta"
|
|
134
|
+
current = state[:blocks][event["index"].to_i] ||= { "partial_json" => "" }
|
|
135
|
+
delta = event["delta"] || {}
|
|
136
|
+
case delta["type"]
|
|
137
|
+
when "text_delta"
|
|
138
|
+
text = delta["text"].to_s
|
|
139
|
+
state[:content] << text
|
|
140
|
+
on_assistant_delta&.call(text)
|
|
141
|
+
when "thinking_delta"
|
|
142
|
+
text = delta["thinking"].to_s
|
|
143
|
+
state[:reasoning_summary] << text
|
|
144
|
+
on_reasoning_delta&.call(text)
|
|
145
|
+
when "input_json_delta"
|
|
146
|
+
current["partial_json"] = current["partial_json"].to_s + delta["partial_json"].to_s
|
|
147
|
+
end
|
|
148
|
+
when "content_block_stop"
|
|
149
|
+
block_data = state[:blocks][event["index"].to_i]
|
|
150
|
+
tool_call = anthropic_tool_call(block_data)
|
|
151
|
+
state[:tool_calls] << tool_call if tool_call
|
|
152
|
+
when "message_delta"
|
|
153
|
+
state[:usage] = usage_normalizer&.call(event["usage"]) || state[:usage]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def anthropic_sse_error(data, request_error_class: nil)
|
|
158
|
+
if request_error_class
|
|
159
|
+
request_error_class.new(provider: "Anthropic", code: 400, body: data)
|
|
160
|
+
else
|
|
161
|
+
data
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def anthropic_tool_call(block_data)
|
|
166
|
+
return nil unless block_data.is_a?(Hash) && block_data["type"] == "tool_use"
|
|
167
|
+
|
|
168
|
+
name = from_claude_code_tool_name(block_data["name"])
|
|
169
|
+
arguments = block_data["partial_json"].to_s
|
|
170
|
+
arguments = JSON.dump(block_data["input"] || {}) if arguments.empty?
|
|
171
|
+
arguments = "{}" if arguments.empty?
|
|
172
|
+
{
|
|
173
|
+
"id" => block_data["id"] || "call_#{name}",
|
|
174
|
+
"type" => "function",
|
|
175
|
+
"function" => { "name" => name, "arguments" => arguments }
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def anthropic_sse_message(state)
|
|
180
|
+
message = { "role" => "assistant", "content" => state[:content] }
|
|
181
|
+
message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
|
|
182
|
+
message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
|
|
183
|
+
message["usage"] = state[:usage] if state[:usage]
|
|
184
|
+
message
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def from_claude_code_tool_name(name)
|
|
188
|
+
case name.to_s
|
|
189
|
+
when "Read" then "read_file"
|
|
190
|
+
when "Write" then "write_file"
|
|
191
|
+
when "Edit" then "edit_file"
|
|
192
|
+
when "Bash" then "run_shell_command"
|
|
193
|
+
when "WebSearch" then "web_search"
|
|
194
|
+
when "WebFetch" then "fetch_content"
|
|
195
|
+
when "AskUserQuestion" then "ask_user_question"
|
|
196
|
+
else name.to_s
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
70
200
|
def merge_streaming_tool_call(tool_calls, delta)
|
|
71
201
|
index = (delta["index"] || tool_calls.length).to_i
|
|
72
202
|
tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
|
|
@@ -89,7 +219,18 @@ module Kward
|
|
|
89
219
|
end
|
|
90
220
|
|
|
91
221
|
def codex_sse_state
|
|
92
|
-
{
|
|
222
|
+
{
|
|
223
|
+
content: +"",
|
|
224
|
+
raw_content: +"",
|
|
225
|
+
emitted_message_keys: [],
|
|
226
|
+
reasoning_summary: +"",
|
|
227
|
+
tool_calls: [],
|
|
228
|
+
response_item_keys: [],
|
|
229
|
+
items_by_id: {},
|
|
230
|
+
active_item_id: nil,
|
|
231
|
+
current_text_content_part: nil,
|
|
232
|
+
usage: nil
|
|
233
|
+
}
|
|
93
234
|
end
|
|
94
235
|
|
|
95
236
|
def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
@@ -98,30 +239,33 @@ module Kward
|
|
|
98
239
|
|
|
99
240
|
event = JSON.parse(data)
|
|
100
241
|
case event["type"]
|
|
101
|
-
when "response.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
242
|
+
when "response.output_item.added"
|
|
243
|
+
codex_output_item_added(state, event["item"])
|
|
244
|
+
when "response.content_part.added"
|
|
245
|
+
codex_content_part_added(state, event["part"])
|
|
246
|
+
when "response.output_text.delta", "response.refusal.delta"
|
|
247
|
+
codex_output_text_delta(state, event["delta"], on_assistant_delta: on_assistant_delta)
|
|
248
|
+
when "response.reasoning_summary_part.added"
|
|
249
|
+
codex_reasoning_summary_part_added(state, event["part"])
|
|
105
250
|
when "response.reasoning_summary_text.delta"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
on_reasoning_delta
|
|
251
|
+
codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
|
|
252
|
+
when "response.reasoning_summary_part.done"
|
|
253
|
+
codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
|
|
254
|
+
when "response.reasoning_text.delta"
|
|
255
|
+
codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
|
|
256
|
+
when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
|
|
257
|
+
codex_tool_arguments_delta(state, event["delta"])
|
|
258
|
+
when "response.function_call_arguments.done"
|
|
259
|
+
codex_tool_arguments_done(state, event["arguments"])
|
|
109
260
|
when "response.output_item.done"
|
|
110
|
-
|
|
111
|
-
state[:final_output] << item if item.is_a?(Hash)
|
|
112
|
-
tool_call = codex_tool_call(item)
|
|
113
|
-
state[:tool_calls] << tool_call if tool_call
|
|
261
|
+
codex_output_item_done(state, event["item"], on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
114
262
|
when "response.completed"
|
|
115
263
|
response = event["response"]
|
|
116
264
|
state[:usage] ||= usage_normalizer&.call(response["usage"]) if response.is_a?(Hash)
|
|
117
265
|
state[:usage] ||= usage_normalizer&.call(event["usage"])
|
|
118
|
-
if
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
state[:content] << text
|
|
122
|
-
on_assistant_delta&.call(text) unless text.empty?
|
|
123
|
-
if state[:reasoning_summary].empty?
|
|
124
|
-
state[:reasoning_summary] << reasoning_summary_from_codex_items(state[:final_output])
|
|
266
|
+
if response.is_a?(Hash) && response["output"].is_a?(Array) && state[:response_item_keys].empty?
|
|
267
|
+
response["output"].each do |item|
|
|
268
|
+
codex_output_item_done(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
125
269
|
end
|
|
126
270
|
end
|
|
127
271
|
when "response.failed", "response.incomplete"
|
|
@@ -146,21 +290,239 @@ module Kward
|
|
|
146
290
|
end
|
|
147
291
|
end
|
|
148
292
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
293
|
+
def codex_output_item_added(state, item)
|
|
294
|
+
return unless item.is_a?(Hash)
|
|
295
|
+
|
|
296
|
+
item = deep_dup_hash(item)
|
|
297
|
+
item["content"] = [] if item["type"] == "message" && !item["content"].is_a?(Array)
|
|
298
|
+
item["summary"] = [] if item["type"] == "reasoning" && !item["summary"].is_a?(Array)
|
|
299
|
+
remember_codex_item(state, item)
|
|
300
|
+
state[:active_item_id] = codex_item_key(item)
|
|
301
|
+
state[:current_text_content_part] = nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def codex_content_part_added(state, part)
|
|
305
|
+
item = active_codex_item(state)
|
|
306
|
+
return unless item&.fetch("type", nil) == "message" && part.is_a?(Hash)
|
|
307
|
+
return unless ["output_text", "text", "refusal"].include?(part["type"])
|
|
308
|
+
|
|
309
|
+
item["content"] ||= []
|
|
310
|
+
item["content"] << deep_dup_hash(part)
|
|
311
|
+
state[:current_text_content_part] = item["content"].last
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def codex_output_text_delta(state, delta, on_assistant_delta: nil)
|
|
315
|
+
text = delta.to_s
|
|
316
|
+
return if text.empty?
|
|
317
|
+
|
|
318
|
+
item = active_codex_item(state)
|
|
319
|
+
if item&.fetch("type", nil) == "message"
|
|
320
|
+
item["content"] ||= [{ "type" => "output_text", "text" => +"" }]
|
|
321
|
+
part = state[:current_text_content_part] || item["content"].last
|
|
322
|
+
part = item["content"].last unless part.is_a?(Hash)
|
|
323
|
+
part["type"] ||= "output_text"
|
|
324
|
+
text_key = part["type"] == "refusal" ? "refusal" : "text"
|
|
325
|
+
part[text_key] = part[text_key].to_s + text
|
|
326
|
+
end
|
|
327
|
+
state[:raw_content] << text
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def codex_reasoning_summary_part_added(state, part)
|
|
331
|
+
item = active_codex_item(state)
|
|
332
|
+
return unless item&.fetch("type", nil) == "reasoning" && part.is_a?(Hash)
|
|
333
|
+
|
|
334
|
+
item["summary"] ||= []
|
|
335
|
+
item["summary"] << deep_dup_hash(part)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
|
|
339
|
+
text = delta.to_s
|
|
340
|
+
return if text.empty?
|
|
341
|
+
|
|
342
|
+
item = active_codex_item(state)
|
|
343
|
+
if item&.fetch("type", nil) == "reasoning"
|
|
344
|
+
item["summary"] ||= []
|
|
345
|
+
item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
|
|
346
|
+
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
347
|
+
end
|
|
348
|
+
state[:reasoning_summary] << text
|
|
349
|
+
on_reasoning_delta&.call(text)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def codex_reasoning_part_done(state, on_reasoning_delta: nil)
|
|
353
|
+
item = active_codex_item(state)
|
|
354
|
+
return unless item&.fetch("type", nil) == "reasoning"
|
|
355
|
+
return if item["summary"].to_a.empty?
|
|
356
|
+
|
|
357
|
+
text = "\n\n"
|
|
358
|
+
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
359
|
+
state[:reasoning_summary] << text
|
|
360
|
+
on_reasoning_delta&.call(text)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def codex_tool_arguments_delta(state, delta)
|
|
364
|
+
item = active_codex_item(state)
|
|
365
|
+
return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
|
|
366
|
+
|
|
367
|
+
key = item["type"] == "custom_tool_call" ? "input" : "arguments"
|
|
368
|
+
item[key] = item[key].to_s + delta.to_s
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def codex_tool_arguments_done(state, arguments)
|
|
372
|
+
item = active_codex_item(state)
|
|
373
|
+
return unless item&.fetch("type", nil) == "function_call"
|
|
374
|
+
|
|
375
|
+
item["arguments"] = arguments.to_s
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def codex_output_item_done(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
|
|
379
|
+
return unless item.is_a?(Hash)
|
|
380
|
+
|
|
381
|
+
item = merge_codex_item(active_or_known_codex_item(state, item), item)
|
|
382
|
+
remember_codex_item(state, item)
|
|
383
|
+
collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
384
|
+
state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
|
|
385
|
+
state[:current_text_content_part] = nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
|
|
389
|
+
case item["type"]
|
|
390
|
+
when "message"
|
|
391
|
+
text = text_from_codex_items([item])
|
|
392
|
+
return if text.empty? || !codex_streamable_message_item?(item)
|
|
393
|
+
|
|
394
|
+
state[:content] << text
|
|
395
|
+
key = codex_item_key(item)
|
|
396
|
+
unless state[:emitted_message_keys].include?(key)
|
|
397
|
+
on_assistant_delta&.call(text)
|
|
398
|
+
state[:emitted_message_keys] << key
|
|
154
399
|
end
|
|
400
|
+
when "reasoning"
|
|
401
|
+
text = reasoning_summary_from_codex_items([item])
|
|
402
|
+
if state[:reasoning_summary].empty? && !text.empty?
|
|
403
|
+
state[:reasoning_summary] << text
|
|
404
|
+
on_reasoning_delta&.call(text)
|
|
405
|
+
end
|
|
406
|
+
when "function_call", "custom_tool_call"
|
|
407
|
+
tool_call = codex_tool_call(item)
|
|
408
|
+
state[:tool_calls] << tool_call if tool_call && !state[:tool_calls].any? { |call| call["id"] == tool_call["id"] }
|
|
155
409
|
end
|
|
410
|
+
end
|
|
156
411
|
|
|
157
|
-
|
|
412
|
+
def active_or_known_codex_item(state, item)
|
|
413
|
+
key = codex_item_key(item)
|
|
414
|
+
known = state[:items_by_id][key]
|
|
415
|
+
return known if known
|
|
416
|
+
|
|
417
|
+
active = active_codex_item(state)
|
|
418
|
+
return active if active && (!codex_item_has_stable_key?(item) || codex_item_key(active) == key)
|
|
419
|
+
|
|
420
|
+
nil
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def codex_item_has_stable_key?(item)
|
|
424
|
+
item.key?("id") || item.key?("call_id")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def active_codex_item(state)
|
|
428
|
+
key = state[:active_item_id]
|
|
429
|
+
key ? state[:items_by_id][key] : nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def remember_codex_item(state, item)
|
|
433
|
+
key = codex_item_key(item)
|
|
434
|
+
if state[:items_by_id].key?(key)
|
|
435
|
+
stored = state[:items_by_id][key]
|
|
436
|
+
stored.replace(merge_codex_item(stored, item))
|
|
437
|
+
else
|
|
438
|
+
state[:items_by_id][key] = item
|
|
439
|
+
state[:response_item_keys] << key
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def codex_item_key(item)
|
|
444
|
+
item["id"] || item["call_id"] || "item_#{item.object_id}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def merge_codex_item(existing, update)
|
|
448
|
+
return deep_dup_hash(update) unless existing.is_a?(Hash)
|
|
449
|
+
|
|
450
|
+
merged = deep_dup_hash(existing)
|
|
451
|
+
update.each do |key, value|
|
|
452
|
+
next if value.nil?
|
|
453
|
+
|
|
454
|
+
merged[key] = if key == "content" && merged[key].is_a?(Array) && value.is_a?(Array)
|
|
455
|
+
value.empty? ? merged[key] : value
|
|
456
|
+
elsif key == "summary" && merged[key].is_a?(Array) && value.is_a?(Array)
|
|
457
|
+
value.empty? ? merged[key] : value
|
|
458
|
+
elsif key == "arguments" && value.to_s.empty? && !merged[key].to_s.empty?
|
|
459
|
+
merged[key]
|
|
460
|
+
else
|
|
461
|
+
deep_dup(value)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
merged
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def deep_dup_hash(hash)
|
|
468
|
+
deep_dup(hash)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def deep_dup(value)
|
|
472
|
+
case value
|
|
473
|
+
when Hash
|
|
474
|
+
value.each_with_object({}) { |(key, entry), result| result[key] = deep_dup(entry) }
|
|
475
|
+
when Array
|
|
476
|
+
value.map { |entry| deep_dup(entry) }
|
|
477
|
+
when String
|
|
478
|
+
value.dup
|
|
479
|
+
else
|
|
480
|
+
value
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def codex_sse_message(state)
|
|
485
|
+
message = { "role" => "assistant", "content" => codex_visible_content(state) }
|
|
158
486
|
message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
|
|
159
487
|
message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
|
|
488
|
+
response_items = codex_response_items(state)
|
|
489
|
+
message["response_items"] = response_items unless response_items.empty?
|
|
160
490
|
message["usage"] = state[:usage] if state[:usage]
|
|
161
491
|
message
|
|
162
492
|
end
|
|
163
493
|
|
|
494
|
+
def codex_visible_content(state)
|
|
495
|
+
return state[:content] unless state[:content].empty?
|
|
496
|
+
|
|
497
|
+
response_items = codex_response_items(state)
|
|
498
|
+
visible_text = text_from_codex_items(visible_codex_message_items(response_items, tool_calls: state[:tool_calls]))
|
|
499
|
+
return visible_text unless visible_text.empty?
|
|
500
|
+
return "" unless state[:tool_calls].empty?
|
|
501
|
+
|
|
502
|
+
state[:raw_content]
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def visible_codex_message_items(items, tool_calls: [])
|
|
506
|
+
messages = Array(items).select { |item| item.is_a?(Hash) && item["type"] == "message" }
|
|
507
|
+
final_messages = messages.select { |item| codex_message_phase(item) == "final_answer" }
|
|
508
|
+
return final_messages unless final_messages.empty?
|
|
509
|
+
return [] unless tool_calls.empty?
|
|
510
|
+
|
|
511
|
+
messages.reject { |item| codex_message_phase(item) == "commentary" }
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def codex_streamable_message_item?(item)
|
|
515
|
+
codex_message_phase(item) == "final_answer"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def codex_message_phase(item)
|
|
519
|
+
item["phase"].to_s
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def codex_response_items(state)
|
|
523
|
+
state[:response_item_keys].filter_map { |key| state[:items_by_id][key] }
|
|
524
|
+
end
|
|
525
|
+
|
|
164
526
|
def codex_tool_call(item)
|
|
165
527
|
return nil unless item.is_a?(Hash) && ["function_call", "custom_tool_call"].include?(item["type"])
|
|
166
528
|
|
data/lib/kward/pan/server.rb
CHANGED
|
@@ -15,7 +15,9 @@ require_relative "../tools/tool_call"
|
|
|
15
15
|
require_relative "../tools/registry"
|
|
16
16
|
require_relative "../workspace"
|
|
17
17
|
|
|
18
|
+
# Namespace for the Kward CLI agent runtime.
|
|
18
19
|
module Kward
|
|
20
|
+
# Minimal local HTTP server for the experimental Pan web UI.
|
|
19
21
|
class PanServer
|
|
20
22
|
DEFAULT_HOST = "0.0.0.0"
|
|
21
23
|
DEFAULT_PORT = 8765
|
|
@@ -269,7 +271,7 @@ module Kward
|
|
|
269
271
|
end
|
|
270
272
|
|
|
271
273
|
def pan_config(config)
|
|
272
|
-
values = config["pan_mode"]
|
|
274
|
+
values = config["pan_mode"]
|
|
273
275
|
values.is_a?(Hash) ? values : {}
|
|
274
276
|
end
|
|
275
277
|
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
require_relative "config_files"
|
|
2
2
|
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
4
|
module Kward
|
|
4
5
|
# Loads trusted user plugin files and provides the plugin DSL.
|
|
5
6
|
#
|
|
6
7
|
# Plugins live in the user plugin directory, run as local Ruby code, and can
|
|
7
8
|
# register slash commands, one footer renderer, prompt context, and live
|
|
8
9
|
# transcript-event observers for CLI and RPC frontends.
|
|
10
|
+
#
|
|
11
|
+
# This registry is intentionally trust-based, not a sandbox. Keep plugin loading
|
|
12
|
+
# restricted to `ConfigFiles.plugin_paths`, keep workspace-local code out of the
|
|
13
|
+
# load path, and expose immutable transcript views so plugins can observe state
|
|
14
|
+
# without corrupting active conversations.
|
|
9
15
|
class PluginRegistry
|
|
10
16
|
COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
|
|
11
17
|
|
|
@@ -26,6 +32,7 @@ module Kward
|
|
|
26
32
|
|
|
27
33
|
# Read-only transcript view exposed to plugin code.
|
|
28
34
|
class Transcript
|
|
35
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
29
36
|
def initialize(conversation)
|
|
30
37
|
@conversation = conversation
|
|
31
38
|
end
|
|
@@ -43,6 +50,7 @@ module Kward
|
|
|
43
50
|
class Context
|
|
44
51
|
attr_reader :args, :workspace_root
|
|
45
52
|
|
|
53
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
46
54
|
def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil)
|
|
47
55
|
@conversation = conversation
|
|
48
56
|
@args = args.to_s
|
|
@@ -89,6 +97,7 @@ module Kward
|
|
|
89
97
|
|
|
90
98
|
# DSL object yielded by `Kward.plugin` blocks.
|
|
91
99
|
class DSL
|
|
100
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
92
101
|
def initialize(registry, path)
|
|
93
102
|
@registry = registry
|
|
94
103
|
@path = path
|
|
@@ -128,6 +137,7 @@ module Kward
|
|
|
128
137
|
end
|
|
129
138
|
end
|
|
130
139
|
|
|
140
|
+
# Mutable singleton guard used while loading trusted plugin files.
|
|
131
141
|
class << self
|
|
132
142
|
attr_accessor :loading_registry, :loading_path
|
|
133
143
|
|
|
@@ -161,6 +171,7 @@ module Kward
|
|
|
161
171
|
end
|
|
162
172
|
end
|
|
163
173
|
|
|
174
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
164
175
|
def initialize(reserved_commands: [])
|
|
165
176
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
166
177
|
@commands = {}
|
|
@@ -170,6 +181,7 @@ module Kward
|
|
|
170
181
|
@prompt_context_renderers = []
|
|
171
182
|
end
|
|
172
183
|
|
|
184
|
+
# @return [String, nil] plugin file currently responsible for footer output
|
|
173
185
|
attr_reader :footer_path
|
|
174
186
|
|
|
175
187
|
def commands
|
data/lib/kward/private_file.rb
CHANGED
|
@@ -2,8 +2,11 @@ require_relative "../ansi"
|
|
|
2
2
|
require_relative "../resources/avatar_kward_logo"
|
|
3
3
|
require_relative "../resources/pixel_logo"
|
|
4
4
|
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
6
|
module Kward
|
|
7
|
+
# Startup banner logo and message renderer.
|
|
6
8
|
class PromptInterface
|
|
9
|
+
# Startup banner rendering data and helpers for the prompt interface.
|
|
7
10
|
class Banner
|
|
8
11
|
LOGO_WIDTH = 32
|
|
9
12
|
LOGO_PIXEL_HEIGHT = 32
|