kward 0.73.1 → 0.75.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/CHANGELOG.md +51 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +70 -4
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/mcp.md +72 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -15
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +14 -7
- data/lib/kward/agent.rb +72 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +12 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +9 -3
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +32 -3
- data/lib/kward/cli/rendering.rb +1 -1
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +32 -15
- data/lib/kward/cli.rb +64 -4
- data/lib/kward/cli_transcript_formatter.rb +0 -7
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +152 -27
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +1 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +56 -0
- data/lib/kward/mcp/server_config.rb +59 -0
- data/lib/kward/mcp/stdio_transport.rb +112 -0
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +13 -45
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +24 -0
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +178 -13
- data/lib/kward/pan/server.rb +51 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +86 -25
- data/lib/kward/prompt_interface/editor/controller.rb +124 -4
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +72 -13
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +94 -13
- data/lib/kward/rpc/session_manager.rb +258 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +9 -10
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +122 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +297 -9
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +117 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +30 -1
|
@@ -80,6 +80,10 @@ module Kward
|
|
|
80
80
|
|
|
81
81
|
# Structured context usage result returned to frontends.
|
|
82
82
|
class TiktokenTokenCounter
|
|
83
|
+
def initialize
|
|
84
|
+
@encodings = {}
|
|
85
|
+
end
|
|
86
|
+
|
|
83
87
|
def count(text, model:)
|
|
84
88
|
text = text.to_s
|
|
85
89
|
tokenizer = encoding(model)
|
|
@@ -93,11 +97,13 @@ module Kward
|
|
|
93
97
|
private
|
|
94
98
|
|
|
95
99
|
def encoding(model)
|
|
96
|
-
|
|
100
|
+
@encodings[model.to_s] ||= begin
|
|
101
|
+
require "tiktoken_ruby"
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
Tiktoken.encoding_for_model(model.to_s) || Tiktoken.get_encoding(encoding_name_for_model(model))
|
|
104
|
+
rescue StandardError
|
|
105
|
+
Tiktoken.get_encoding(encoding_name_for_model(model)) if defined?(Tiktoken)
|
|
106
|
+
end
|
|
101
107
|
end
|
|
102
108
|
|
|
103
109
|
def rough_count(text)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Parses and filters GitHub Copilot model catalog responses.
|
|
6
|
+
module CopilotModels
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def parse(body)
|
|
10
|
+
catalog_entries(body).filter_map { |entry| model_id(entry) }.uniq
|
|
11
|
+
rescue JSON::ParserError
|
|
12
|
+
[]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def supported?(model)
|
|
16
|
+
text = model.to_s
|
|
17
|
+
text.match?(/\Agpt-5(?:\.|-|\z)/) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def supported_choices(choices)
|
|
21
|
+
choices.select { |model| supported?(model) }.uniq
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolved_chat_model(configured_model, choices)
|
|
25
|
+
return configured_model if choices.empty? || choices.include?(configured_model)
|
|
26
|
+
|
|
27
|
+
choices.find { |model| supported?(model) } || raise("No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5 Responses and Gemini/GPT-4.1 chat models.")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def catalog_entries(body)
|
|
31
|
+
data = JSON.parse(body.to_s)
|
|
32
|
+
entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
|
|
33
|
+
Array(entries)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_id(entry)
|
|
37
|
+
return entry.to_s.strip unless entry.is_a?(Hash)
|
|
38
|
+
return nil if entry.key?("model_picker_enabled") && entry["model_picker_enabled"] == false
|
|
39
|
+
|
|
40
|
+
id = entry["id"] || entry["model"] || entry["name"]
|
|
41
|
+
id.to_s.strip unless id.to_s.strip.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -412,6 +412,30 @@ module Kward
|
|
|
412
412
|
}.compact
|
|
413
413
|
end
|
|
414
414
|
|
|
415
|
+
def normalize_available(models, current_provider:, current_model:, current_reasoning_effort: nil)
|
|
416
|
+
Array(models).map do |model|
|
|
417
|
+
normalize(
|
|
418
|
+
model,
|
|
419
|
+
current_provider: current_provider,
|
|
420
|
+
current_model: current_model,
|
|
421
|
+
current_reasoning_effort: current_reasoning_effort
|
|
422
|
+
)
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def current_payload(provider:, model:, reasoning_effort: nil, context_window: nil)
|
|
427
|
+
normalize(
|
|
428
|
+
{
|
|
429
|
+
provider: provider,
|
|
430
|
+
id: model,
|
|
431
|
+
model: model,
|
|
432
|
+
reasoningEffort: reasoning_effort,
|
|
433
|
+
contextWindow: context_window,
|
|
434
|
+
current: true
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
end
|
|
438
|
+
|
|
415
439
|
def boolean_value(value, default: false)
|
|
416
440
|
return default if value.nil?
|
|
417
441
|
return value if value == true || value == false
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -188,14 +188,12 @@ module Kward
|
|
|
188
188
|
text = plain_content(content).to_s
|
|
189
189
|
blocks << { type: "text", text: text } unless text.empty?
|
|
190
190
|
MessageAccess.tool_calls(message).each do |tool_call|
|
|
191
|
-
|
|
192
|
-
name = function[:name] || function["name"]
|
|
193
|
-
arguments = function[:arguments] || function["arguments"] || "{}"
|
|
191
|
+
name = ToolCall.name(tool_call)
|
|
194
192
|
blocks << {
|
|
195
193
|
type: "tool_use",
|
|
196
|
-
id: normalize_anthropic_tool_call_id(
|
|
194
|
+
id: normalize_anthropic_tool_call_id(ToolCall.id(tool_call) || "call_#{name}"),
|
|
197
195
|
name: claude_code_tool_name(name),
|
|
198
|
-
input: parse_tool_arguments(
|
|
196
|
+
input: parse_tool_arguments(ToolCall.raw_arguments(tool_call) || "{}")
|
|
199
197
|
}
|
|
200
198
|
end
|
|
201
199
|
output << { role: "assistant", content: blocks } unless blocks.empty?
|
|
@@ -260,11 +258,11 @@ module Kward
|
|
|
260
258
|
end
|
|
261
259
|
|
|
262
260
|
def anthropic_tool_schema(tool)
|
|
263
|
-
function =
|
|
264
|
-
schema = function
|
|
261
|
+
function = ToolCall.function(tool)
|
|
262
|
+
schema = ToolCall.value(function, :parameters) || {}
|
|
265
263
|
{
|
|
266
|
-
name: claude_code_tool_name(function
|
|
267
|
-
description: function
|
|
264
|
+
name: claude_code_tool_name(ToolCall.value(function, :name)),
|
|
265
|
+
description: ToolCall.value(function, :description) || "",
|
|
268
266
|
input_schema: {
|
|
269
267
|
type: "object",
|
|
270
268
|
properties: schema[:properties] || schema["properties"] || {},
|
|
@@ -296,12 +294,12 @@ module Kward
|
|
|
296
294
|
content = plain_content(content)
|
|
297
295
|
input << codex_message("assistant", content.to_s) unless content.to_s.empty?
|
|
298
296
|
MessageAccess.tool_calls(message).each do |tool_call|
|
|
299
|
-
|
|
297
|
+
name = ToolCall.name(tool_call)
|
|
300
298
|
input << {
|
|
301
299
|
type: "function_call",
|
|
302
|
-
call_id:
|
|
303
|
-
name:
|
|
304
|
-
arguments:
|
|
300
|
+
call_id: ToolCall.id(tool_call) || name || "tool-call",
|
|
301
|
+
name: name,
|
|
302
|
+
arguments: ToolCall.raw_arguments(tool_call) || "{}"
|
|
305
303
|
}
|
|
306
304
|
end
|
|
307
305
|
else
|
|
@@ -420,12 +418,12 @@ module Kward
|
|
|
420
418
|
end
|
|
421
419
|
|
|
422
420
|
def codex_tool_schema(tool)
|
|
423
|
-
function =
|
|
421
|
+
function = ToolCall.function(tool)
|
|
424
422
|
{
|
|
425
423
|
type: "function",
|
|
426
|
-
name: function
|
|
427
|
-
description: function
|
|
428
|
-
parameters: function
|
|
424
|
+
name: ToolCall.value(function, :name),
|
|
425
|
+
description: ToolCall.value(function, :description) || "",
|
|
426
|
+
parameters: ToolCall.value(function, :parameters) || {},
|
|
429
427
|
strict: false
|
|
430
428
|
}
|
|
431
429
|
end
|
|
@@ -45,11 +45,12 @@ module Kward
|
|
|
45
45
|
raise "Copilot returned invalid SSE JSON: #{e.message}"
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
49
|
-
state = codex_sse_state
|
|
48
|
+
def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
49
|
+
state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
|
|
50
50
|
body.split(/\r?\n\r?\n/).each do |block|
|
|
51
51
|
process_codex_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)
|
|
52
52
|
end
|
|
53
|
+
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
53
54
|
codex_sse_message(state)
|
|
54
55
|
rescue JSON::ParserError => e
|
|
55
56
|
raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
|
|
@@ -60,8 +61,8 @@ module Kward
|
|
|
60
61
|
# Deltas are yielded as soon as complete SSE blocks arrive so interactive
|
|
61
62
|
# frontends can render streamed assistant and reasoning text without waiting
|
|
62
63
|
# for the provider to close the response.
|
|
63
|
-
def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
|
|
64
|
-
state = codex_sse_state
|
|
64
|
+
def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
|
|
65
|
+
state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
|
|
65
66
|
buffer = +""
|
|
66
67
|
|
|
67
68
|
response.read_body do |chunk|
|
|
@@ -76,6 +77,7 @@ module Kward
|
|
|
76
77
|
end
|
|
77
78
|
cancellation&.raise_if_cancelled!
|
|
78
79
|
process_codex_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?
|
|
80
|
+
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
79
81
|
codex_sse_message(state)
|
|
80
82
|
rescue JSON::ParserError => e
|
|
81
83
|
raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
|
|
@@ -218,17 +220,21 @@ module Kward
|
|
|
218
220
|
end
|
|
219
221
|
end
|
|
220
222
|
|
|
221
|
-
def codex_sse_state
|
|
223
|
+
def codex_sse_state(show_raw_reasoning: false)
|
|
222
224
|
{
|
|
223
225
|
content: +"",
|
|
224
226
|
raw_content: +"",
|
|
225
227
|
emitted_message_keys: [],
|
|
226
228
|
reasoning_summary: +"",
|
|
229
|
+
raw_reasoning_visible: +"",
|
|
230
|
+
pending_reasoning_delta: +"",
|
|
231
|
+
show_raw_reasoning: show_raw_reasoning,
|
|
227
232
|
tool_calls: [],
|
|
228
233
|
response_item_keys: [],
|
|
229
234
|
items_by_id: {},
|
|
230
235
|
active_item_id: nil,
|
|
231
236
|
current_text_content_part: nil,
|
|
237
|
+
current_reasoning_content_part: nil,
|
|
232
238
|
usage: nil
|
|
233
239
|
}
|
|
234
240
|
end
|
|
@@ -252,7 +258,9 @@ module Kward
|
|
|
252
258
|
when "response.reasoning_summary_part.done"
|
|
253
259
|
codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
|
|
254
260
|
when "response.reasoning_text.delta"
|
|
255
|
-
|
|
261
|
+
codex_raw_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
|
|
262
|
+
when "response.reasoning_text.done"
|
|
263
|
+
codex_raw_reasoning_done(state, event["text"], on_reasoning_delta: on_reasoning_delta)
|
|
256
264
|
when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
|
|
257
265
|
codex_tool_arguments_delta(state, event["delta"])
|
|
258
266
|
when "response.function_call_arguments.done"
|
|
@@ -299,6 +307,7 @@ module Kward
|
|
|
299
307
|
remember_codex_item(state, item)
|
|
300
308
|
state[:active_item_id] = codex_item_key(item)
|
|
301
309
|
state[:current_text_content_part] = nil
|
|
310
|
+
state[:current_reasoning_content_part] = nil
|
|
302
311
|
end
|
|
303
312
|
|
|
304
313
|
def codex_content_part_added(state, part)
|
|
@@ -333,20 +342,53 @@ module Kward
|
|
|
333
342
|
|
|
334
343
|
item["summary"] ||= []
|
|
335
344
|
item["summary"] << deep_dup_hash(part)
|
|
345
|
+
state[:current_reasoning_content_part] = nil
|
|
336
346
|
end
|
|
337
347
|
|
|
338
348
|
def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
|
|
349
|
+
text = visible_codex_reasoning_delta(state, delta)
|
|
350
|
+
return if text.empty?
|
|
351
|
+
|
|
352
|
+
append_codex_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def codex_raw_reasoning_delta(state, delta, on_reasoning_delta: nil)
|
|
339
356
|
text = delta.to_s
|
|
340
357
|
return if text.empty?
|
|
341
358
|
|
|
342
359
|
item = active_codex_item(state)
|
|
343
|
-
if item&.fetch("type", nil) == "reasoning"
|
|
344
|
-
item["
|
|
345
|
-
|
|
346
|
-
|
|
360
|
+
if item&.fetch("type", nil) == "reasoning" && state[:show_raw_reasoning]
|
|
361
|
+
item["content"] ||= [{ "type" => "reasoning_text", "text" => +"" }]
|
|
362
|
+
part = state[:current_reasoning_content_part] || item["content"].last
|
|
363
|
+
part = item["content"].last unless part.is_a?(Hash)
|
|
364
|
+
part["type"] ||= "reasoning_text"
|
|
365
|
+
part["text"] = part["text"].to_s + text
|
|
366
|
+
state[:current_reasoning_content_part] = part
|
|
347
367
|
end
|
|
348
|
-
state[:
|
|
349
|
-
|
|
368
|
+
return unless state[:show_raw_reasoning]
|
|
369
|
+
|
|
370
|
+
visible = visible_codex_reasoning_delta(state, text)
|
|
371
|
+
append_codex_raw_reasoning_text(state, visible, on_reasoning_delta: on_reasoning_delta) unless visible.empty?
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def codex_raw_reasoning_done(state, text, on_reasoning_delta: nil)
|
|
375
|
+
item = active_codex_item(state)
|
|
376
|
+
full_text = text.to_s
|
|
377
|
+
if item&.fetch("type", nil) == "reasoning" && state[:show_raw_reasoning]
|
|
378
|
+
item["content"] ||= [{ "type" => "reasoning_text", "text" => +"" }]
|
|
379
|
+
part = state[:current_reasoning_content_part] || item["content"].last
|
|
380
|
+
part = item["content"].last unless part.is_a?(Hash)
|
|
381
|
+
part["type"] ||= "reasoning_text"
|
|
382
|
+
part["text"] = full_text
|
|
383
|
+
state[:current_reasoning_content_part] = part
|
|
384
|
+
end
|
|
385
|
+
return unless state[:show_raw_reasoning]
|
|
386
|
+
|
|
387
|
+
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
388
|
+
visible = sanitize_codex_reasoning_text(full_text)
|
|
389
|
+
emitted = state[:raw_reasoning_visible]
|
|
390
|
+
text = visible.start_with?(emitted) ? visible[emitted.length..].to_s : (emitted.empty? ? visible : +"")
|
|
391
|
+
append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
|
|
350
392
|
end
|
|
351
393
|
|
|
352
394
|
def codex_reasoning_part_done(state, on_reasoning_delta: nil)
|
|
@@ -354,12 +396,97 @@ module Kward
|
|
|
354
396
|
return unless item&.fetch("type", nil) == "reasoning"
|
|
355
397
|
return if item["summary"].to_a.empty?
|
|
356
398
|
|
|
399
|
+
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
357
400
|
text = "\n\n"
|
|
358
401
|
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
359
402
|
state[:reasoning_summary] << text
|
|
360
403
|
on_reasoning_delta&.call(text)
|
|
361
404
|
end
|
|
362
405
|
|
|
406
|
+
def append_codex_reasoning_text(state, text, on_reasoning_delta: nil)
|
|
407
|
+
text = compact_codex_reasoning_delta_spacing(state[:reasoning_summary], text)
|
|
408
|
+
return if text.empty?
|
|
409
|
+
|
|
410
|
+
item = active_codex_item(state)
|
|
411
|
+
if item&.fetch("type", nil) == "reasoning"
|
|
412
|
+
item["summary"] ||= []
|
|
413
|
+
item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
|
|
414
|
+
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
415
|
+
end
|
|
416
|
+
state[:reasoning_summary] << text
|
|
417
|
+
on_reasoning_delta&.call(text)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def append_codex_raw_reasoning_text(state, text, on_reasoning_delta: nil)
|
|
421
|
+
text = compact_codex_reasoning_delta_spacing(state[:reasoning_summary], text)
|
|
422
|
+
return if text.empty?
|
|
423
|
+
|
|
424
|
+
state[:raw_reasoning_visible] << text
|
|
425
|
+
state[:reasoning_summary] << text
|
|
426
|
+
on_reasoning_delta&.call(text)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def visible_codex_reasoning_delta(state, delta)
|
|
430
|
+
state[:pending_reasoning_delta] << delta.to_s
|
|
431
|
+
sanitized = sanitize_codex_reasoning_text(state[:pending_reasoning_delta], keep_incomplete_comment: true)
|
|
432
|
+
text, pending_prefix = split_trailing_comment_prefix(sanitized[:text])
|
|
433
|
+
state[:pending_reasoning_delta].replace(pending_prefix + sanitized[:pending])
|
|
434
|
+
text
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def flush_codex_reasoning_delta(state, on_reasoning_delta: nil)
|
|
438
|
+
pending = state[:pending_reasoning_delta].to_s
|
|
439
|
+
state[:pending_reasoning_delta].clear
|
|
440
|
+
text = sanitize_codex_reasoning_text(pending)
|
|
441
|
+
append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def split_trailing_comment_prefix(text)
|
|
445
|
+
string = text.to_s
|
|
446
|
+
prefix = ["<!-", "<!", "<"].find { |candidate| string.end_with?(candidate) }
|
|
447
|
+
return [string, +""] unless prefix
|
|
448
|
+
|
|
449
|
+
[string[0...-prefix.length], prefix.dup]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def sanitize_codex_reasoning_text(text, keep_incomplete_comment: false)
|
|
453
|
+
string = text.to_s
|
|
454
|
+
visible = +""
|
|
455
|
+
index = 0
|
|
456
|
+
|
|
457
|
+
while (start_index = string.index("<!--", index))
|
|
458
|
+
end_index = string.index("-->", start_index + 4)
|
|
459
|
+
if end_index
|
|
460
|
+
visible << string[index...start_index]
|
|
461
|
+
index = end_index + 3
|
|
462
|
+
elsif keep_incomplete_comment
|
|
463
|
+
visible << string[index...start_index]
|
|
464
|
+
return { text: visible, pending: string[start_index..] }
|
|
465
|
+
else
|
|
466
|
+
visible << string[index..]
|
|
467
|
+
index = string.length
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
visible << string[index..].to_s
|
|
472
|
+
visible = compact_blank_codex_reasoning_lines(visible)
|
|
473
|
+
keep_incomplete_comment ? { text: visible, pending: +"" } : visible
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def compact_blank_codex_reasoning_lines(text)
|
|
477
|
+
text.to_s.gsub(/(?:[ \t]*\n){3,}/, "\n\n")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def compact_codex_reasoning_delta_spacing(existing, text)
|
|
481
|
+
text = compact_blank_codex_reasoning_lines(text)
|
|
482
|
+
trailing_newlines = existing.to_s[/\n+\z/].to_s.length
|
|
483
|
+
leading_newlines = text[/\A\n+/].to_s.length
|
|
484
|
+
excess_newlines = trailing_newlines + leading_newlines - 2
|
|
485
|
+
return text unless excess_newlines.positive? && leading_newlines.positive?
|
|
486
|
+
|
|
487
|
+
text[[excess_newlines, leading_newlines].min..].to_s
|
|
488
|
+
end
|
|
489
|
+
|
|
363
490
|
def codex_tool_arguments_delta(state, delta)
|
|
364
491
|
item = active_codex_item(state)
|
|
365
492
|
return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
|
|
@@ -379,10 +506,40 @@ module Kward
|
|
|
379
506
|
return unless item.is_a?(Hash)
|
|
380
507
|
|
|
381
508
|
item = merge_codex_item(active_or_known_codex_item(state, item), item)
|
|
509
|
+
item = codex_visible_reasoning_item(state, item)
|
|
382
510
|
remember_codex_item(state, item)
|
|
383
511
|
collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
|
|
384
512
|
state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
|
|
385
513
|
state[:current_text_content_part] = nil
|
|
514
|
+
state[:current_reasoning_content_part] = nil
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def codex_visible_reasoning_item(state, item)
|
|
518
|
+
return item unless item.is_a?(Hash) && item["type"] == "reasoning"
|
|
519
|
+
|
|
520
|
+
visible = deep_dup_hash(item)
|
|
521
|
+
sanitize_codex_reasoning_summary_item(visible)
|
|
522
|
+
visible.delete("content") unless state[:show_raw_reasoning]
|
|
523
|
+
return visible unless state[:show_raw_reasoning]
|
|
524
|
+
return visible if visible["summary"].is_a?(Array) && !visible["summary"].empty?
|
|
525
|
+
|
|
526
|
+
text = sanitize_codex_reasoning_text(raw_reasoning_from_codex_items([visible]))
|
|
527
|
+
visible["summary"] = [{ "type" => "summary_text", "text" => text }] unless text.empty?
|
|
528
|
+
visible
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def sanitize_codex_reasoning_summary_item(item)
|
|
532
|
+
return unless item["summary"].is_a?(Array)
|
|
533
|
+
|
|
534
|
+
pending = +""
|
|
535
|
+
item["summary"].each do |part|
|
|
536
|
+
next unless part.is_a?(Hash) && part.key?("text")
|
|
537
|
+
|
|
538
|
+
pending << part["text"].to_s
|
|
539
|
+
sanitized = sanitize_codex_reasoning_text(pending, keep_incomplete_comment: true)
|
|
540
|
+
part["text"] = sanitized[:text]
|
|
541
|
+
pending.replace(sanitized[:pending])
|
|
542
|
+
end
|
|
386
543
|
end
|
|
387
544
|
|
|
388
545
|
def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
|
|
@@ -398,7 +555,7 @@ module Kward
|
|
|
398
555
|
state[:emitted_message_keys] << key
|
|
399
556
|
end
|
|
400
557
|
when "reasoning"
|
|
401
|
-
text = reasoning_summary_from_codex_items([item])
|
|
558
|
+
text = sanitize_codex_reasoning_text(reasoning_summary_from_codex_items([item]))
|
|
402
559
|
if state[:reasoning_summary].empty? && !text.empty?
|
|
403
560
|
state[:reasoning_summary] << text
|
|
404
561
|
on_reasoning_delta&.call(text)
|
|
@@ -563,5 +720,13 @@ module Kward
|
|
|
563
720
|
end
|
|
564
721
|
end.join
|
|
565
722
|
end
|
|
723
|
+
|
|
724
|
+
def raw_reasoning_from_codex_items(items)
|
|
725
|
+
items.flat_map do |item|
|
|
726
|
+
next [] unless item.is_a?(Hash) && item["type"] == "reasoning" && item["content"].is_a?(Array)
|
|
727
|
+
|
|
728
|
+
item["content"].filter_map { |part| part["text"] if part.is_a?(Hash) && part["type"] == "reasoning_text" }
|
|
729
|
+
end.join
|
|
730
|
+
end
|
|
566
731
|
end
|
|
567
732
|
end
|
data/lib/kward/pan/server.rb
CHANGED
|
@@ -9,6 +9,8 @@ require_relative "../agent"
|
|
|
9
9
|
require_relative "../config_files"
|
|
10
10
|
require_relative "../events"
|
|
11
11
|
require_relative "../model/retry_message"
|
|
12
|
+
require_relative "../plugin_registry"
|
|
13
|
+
require_relative "../prompts/commands"
|
|
12
14
|
require_relative "../rpc/transcript_normalizer"
|
|
13
15
|
require_relative "../session_store"
|
|
14
16
|
require_relative "../tools/tool_call"
|
|
@@ -42,10 +44,14 @@ module Kward
|
|
|
42
44
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
43
45
|
)
|
|
44
46
|
@session.attach(@conversation)
|
|
47
|
+
hook_context = lifecycle_hook_context
|
|
48
|
+
hook_manager = lifecycle_hook_manager
|
|
45
49
|
@agent = Agent.new(
|
|
46
50
|
client: @client,
|
|
47
|
-
tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false),
|
|
48
|
-
conversation: @conversation
|
|
51
|
+
tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
|
|
52
|
+
conversation: @conversation,
|
|
53
|
+
hook_manager: hook_manager,
|
|
54
|
+
hook_context: hook_context
|
|
49
55
|
)
|
|
50
56
|
@prompt_queue = Queue.new
|
|
51
57
|
@subscribers = []
|
|
@@ -97,6 +103,49 @@ module Kward
|
|
|
97
103
|
|
|
98
104
|
private
|
|
99
105
|
|
|
106
|
+
def lifecycle_hook_manager
|
|
107
|
+
manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
|
|
108
|
+
manager.on_result = method(:broadcast_lifecycle_hook_event)
|
|
109
|
+
plugin_registry.hook_handlers.each do |hook|
|
|
110
|
+
manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
|
|
111
|
+
hook.handler.call(event, context)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
manager
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def broadcast_lifecycle_hook_event(event, result)
|
|
118
|
+
broadcast("hook_event", {
|
|
119
|
+
event: {
|
|
120
|
+
id: event.id,
|
|
121
|
+
name: event.name,
|
|
122
|
+
phase: event.phase,
|
|
123
|
+
timestamp: event.timestamp.iso8601,
|
|
124
|
+
payloadKeys: event.payload.keys.map(&:to_s).sort
|
|
125
|
+
},
|
|
126
|
+
result: {
|
|
127
|
+
decision: result.decision.decision,
|
|
128
|
+
warnings: result.warnings,
|
|
129
|
+
messages: result.messages,
|
|
130
|
+
decisionCount: result.decisions.length
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def lifecycle_hook_context
|
|
136
|
+
PluginRegistry::Context.new(
|
|
137
|
+
conversation: @conversation,
|
|
138
|
+
args: "",
|
|
139
|
+
session: @session,
|
|
140
|
+
workspace_root: @workspace.root.to_s,
|
|
141
|
+
say_callback: lambda { |message| broadcast("hook_message", { message: message.to_s }) }
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def plugin_registry
|
|
146
|
+
@plugin_registry ||= PluginRegistry.load(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES)
|
|
147
|
+
end
|
|
148
|
+
|
|
100
149
|
def start_worker
|
|
101
150
|
return if @worker_started
|
|
102
151
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Shared path containment predicate for local filesystem guardrails.
|
|
4
|
+
module PathGuard
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def inside?(path, root)
|
|
8
|
+
path = path.to_s
|
|
9
|
+
root = root.to_s
|
|
10
|
+
path == root || path.start_with?(root + File::SEPARATOR)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|