kward 0.74.0 → 0.76.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 +47 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +75 -10
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +2 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +111 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +77 -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 +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +3 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +28 -7
- data/lib/kward/cli/rendering.rb +8 -4
- 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 +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +21 -18
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +120 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +2 -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 +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- 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 +23 -52
- 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 +38 -5
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +183 -18
- data/lib/kward/pan/index.html.erb +1 -0
- data/lib/kward/pan/server.rb +53 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +88 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
- 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 +95 -15
- data/lib/kward/rpc/session_manager.rb +260 -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 +19 -20
- 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 +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +122 -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 +25 -1
|
@@ -4,13 +4,15 @@ require_relative "../config_files"
|
|
|
4
4
|
module Kward
|
|
5
5
|
# Static and configured model metadata helpers.
|
|
6
6
|
module ModelInfo
|
|
7
|
-
DEFAULT_OPENAI_MODEL = "gpt-5.
|
|
7
|
+
DEFAULT_OPENAI_MODEL = "gpt-5.6-sol"
|
|
8
8
|
DEFAULT_COPILOT_MODEL = "gpt-5-mini"
|
|
9
|
-
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-
|
|
9
|
+
DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5"
|
|
10
10
|
DEFAULT_REASONING_EFFORT = "medium"
|
|
11
|
-
OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
|
|
11
|
+
OPENAI_MODEL_CHOICES = %w[gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
|
|
12
12
|
ANTHROPIC_MODEL_CHOICES = %w[
|
|
13
|
+
claude-fable-5
|
|
13
14
|
claude-opus-4-8
|
|
15
|
+
claude-sonnet-5
|
|
14
16
|
claude-sonnet-4-6
|
|
15
17
|
claude-haiku-4-5
|
|
16
18
|
claude-opus-4-7
|
|
@@ -46,6 +48,10 @@ module Kward
|
|
|
46
48
|
["none", "None"],
|
|
47
49
|
*REASONING_EFFORT_CHOICES
|
|
48
50
|
].freeze
|
|
51
|
+
OPENAI_MAX_REASONING_EFFORT_CHOICES = [
|
|
52
|
+
*OPENAI_REASONING_EFFORT_CHOICES,
|
|
53
|
+
["max", "Max"]
|
|
54
|
+
].freeze
|
|
49
55
|
ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES = [
|
|
50
56
|
["low", "Low"],
|
|
51
57
|
["medium", "Medium"],
|
|
@@ -70,6 +76,7 @@ module Kward
|
|
|
70
76
|
].freeze
|
|
71
77
|
|
|
72
78
|
CODEX_CONTEXT_WINDOWS = [
|
|
79
|
+
[/\Agpt-5\.6/, 1_050_000],
|
|
73
80
|
[/\Agpt-5\.5/, 400_000],
|
|
74
81
|
[/\Agpt-5\.4-mini/, 400_000],
|
|
75
82
|
[/\Agpt-5\.4/, 1_050_000],
|
|
@@ -87,6 +94,7 @@ module Kward
|
|
|
87
94
|
[/\Agpt-3\.5-turbo/, 16_385]
|
|
88
95
|
].freeze
|
|
89
96
|
OPENAI_CONTEXT_WINDOWS = [
|
|
97
|
+
[/\Agpt-5\.6/, 1_050_000],
|
|
90
98
|
[/\Agpt-5\.5/, 1_050_000],
|
|
91
99
|
[/\Agpt-5\.4-mini/, 400_000],
|
|
92
100
|
[/\Agpt-5\.4/, 1_050_000],
|
|
@@ -104,7 +112,7 @@ module Kward
|
|
|
104
112
|
[/\Agpt-3\.5-turbo/, 16_385]
|
|
105
113
|
].freeze
|
|
106
114
|
ANTHROPIC_CONTEXT_WINDOWS = [
|
|
107
|
-
[/\Aclaude-(?:fable|mythos)-5(?:\z|-)/, 1_000_000],
|
|
115
|
+
[/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)/, 1_000_000],
|
|
108
116
|
[/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
|
|
109
117
|
[/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
|
|
110
118
|
[/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
|
|
@@ -382,13 +390,14 @@ module Kward
|
|
|
382
390
|
def openai_reasoning_effort_choices(id)
|
|
383
391
|
text = id.to_s.delete_prefix("openai/")
|
|
384
392
|
return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
|
|
393
|
+
return OPENAI_MAX_REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.6(?:\z|-)/)
|
|
385
394
|
|
|
386
395
|
OPENAI_REASONING_EFFORT_CHOICES
|
|
387
396
|
end
|
|
388
397
|
|
|
389
398
|
def anthropic_reasoning_effort_choices(id)
|
|
390
399
|
text = normalize_anthropic_model(id)
|
|
391
|
-
return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
|
|
400
|
+
return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
|
|
392
401
|
return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
|
|
393
402
|
return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
|
|
394
403
|
|
|
@@ -412,6 +421,30 @@ module Kward
|
|
|
412
421
|
}.compact
|
|
413
422
|
end
|
|
414
423
|
|
|
424
|
+
def normalize_available(models, current_provider:, current_model:, current_reasoning_effort: nil)
|
|
425
|
+
Array(models).map do |model|
|
|
426
|
+
normalize(
|
|
427
|
+
model,
|
|
428
|
+
current_provider: current_provider,
|
|
429
|
+
current_model: current_model,
|
|
430
|
+
current_reasoning_effort: current_reasoning_effort
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def current_payload(provider:, model:, reasoning_effort: nil, context_window: nil)
|
|
436
|
+
normalize(
|
|
437
|
+
{
|
|
438
|
+
provider: provider,
|
|
439
|
+
id: model,
|
|
440
|
+
model: model,
|
|
441
|
+
reasoningEffort: reasoning_effort,
|
|
442
|
+
contextWindow: context_window,
|
|
443
|
+
current: true
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
end
|
|
447
|
+
|
|
415
448
|
def boolean_value(value, default: false)
|
|
416
449
|
return default if value.nil?
|
|
417
450
|
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_reasoning_boundary: 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
|
-
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)
|
|
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)
|
|
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_reasoning_boundary: 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|
|
|
@@ -71,11 +72,12 @@ module Kward
|
|
|
71
72
|
delimiter = Regexp.last_match[0]
|
|
72
73
|
block = buffer[0...index]
|
|
73
74
|
buffer = buffer[(index + delimiter.length)..] || +""
|
|
74
|
-
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)
|
|
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)
|
|
75
76
|
end
|
|
76
77
|
end
|
|
77
78
|
cancellation&.raise_if_cancelled!
|
|
78
|
-
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?
|
|
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?
|
|
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,22 +220,26 @@ 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
|
|
235
241
|
|
|
236
|
-
def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
|
|
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)
|
|
237
243
|
data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
|
|
238
244
|
return if data.empty? || data == "[DONE]"
|
|
239
245
|
|
|
@@ -250,9 +256,11 @@ module Kward
|
|
|
250
256
|
when "response.reasoning_summary_text.delta"
|
|
251
257
|
codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
|
|
252
258
|
when "response.reasoning_summary_part.done"
|
|
253
|
-
codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
|
|
259
|
+
codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary)
|
|
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,12 +342,71 @@ 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
|
|
|
359
|
+
item = active_codex_item(state)
|
|
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
|
|
367
|
+
end
|
|
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?
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def codex_reasoning_part_done(state, on_reasoning_delta: nil, on_reasoning_boundary: nil)
|
|
395
|
+
item = active_codex_item(state)
|
|
396
|
+
return unless item&.fetch("type", nil) == "reasoning"
|
|
397
|
+
return if item["summary"].to_a.empty?
|
|
398
|
+
|
|
399
|
+
flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
|
|
400
|
+
item["summary"].last["text"] = "#{item["summary"].last["text"].to_s.rstrip}\n\n"
|
|
401
|
+
state[:reasoning_summary].rstrip!
|
|
402
|
+
state[:reasoning_summary] << "\n\n"
|
|
403
|
+
on_reasoning_boundary&.call
|
|
404
|
+
end
|
|
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
|
+
|
|
342
410
|
item = active_codex_item(state)
|
|
343
411
|
if item&.fetch("type", nil) == "reasoning"
|
|
344
412
|
item["summary"] ||= []
|
|
@@ -349,17 +417,76 @@ module Kward
|
|
|
349
417
|
on_reasoning_delta&.call(text)
|
|
350
418
|
end
|
|
351
419
|
|
|
352
|
-
def
|
|
353
|
-
|
|
354
|
-
return
|
|
355
|
-
return if item["summary"].to_a.empty?
|
|
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?
|
|
356
423
|
|
|
357
|
-
|
|
358
|
-
item["summary"].last["text"] = item["summary"].last["text"].to_s + text
|
|
424
|
+
state[:raw_reasoning_visible] << text
|
|
359
425
|
state[:reasoning_summary] << text
|
|
360
426
|
on_reasoning_delta&.call(text)
|
|
361
427
|
end
|
|
362
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
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
currentReasoning.textContent += JSON.parse(event.data).delta;
|
|
101
101
|
transcript.scrollTop = transcript.scrollHeight;
|
|
102
102
|
});
|
|
103
|
+
events.addEventListener('reasoning_boundary', () => { currentReasoning = null; });
|
|
103
104
|
events.addEventListener('assistant_delta', event => {
|
|
104
105
|
if (!currentAssistant) currentAssistant = entry('assistant', 'Assistant', '');
|
|
105
106
|
currentAssistant.textContent += JSON.parse(event.data).delta;
|
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
|
|
|
@@ -117,6 +166,8 @@ module Kward
|
|
|
117
166
|
case event
|
|
118
167
|
when Events::ReasoningDelta
|
|
119
168
|
broadcast("reasoning_delta", { delta: event.delta.to_s })
|
|
169
|
+
when Events::ReasoningBoundary
|
|
170
|
+
broadcast("reasoning_boundary", {})
|
|
120
171
|
when Events::AssistantDelta
|
|
121
172
|
broadcast("assistant_delta", { delta: event.delta.to_s })
|
|
122
173
|
when Events::Retry
|
|
@@ -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
|