kward 0.74.0 → 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 +35 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +56 -3
- 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 +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -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 +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 +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/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 +26 -6
- data/lib/kward/cli.rb +26 -3
- 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 +117 -25
- 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 +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 +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 +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.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 -14
- 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 +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 +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 +25 -1
|
@@ -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
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require_relative "config_files"
|
|
2
|
+
require_relative "deep_copy"
|
|
3
|
+
require_relative "hooks"
|
|
2
4
|
|
|
3
5
|
# Namespace for the Kward CLI agent runtime.
|
|
4
6
|
module Kward
|
|
@@ -39,6 +41,9 @@ module Kward
|
|
|
39
41
|
end
|
|
40
42
|
end
|
|
41
43
|
|
|
44
|
+
# Registered lifecycle hook handler.
|
|
45
|
+
HookHandler = Struct.new(:event, :id, :description, :path, :order, :match, :failure_policy, :handler, keyword_init: true)
|
|
46
|
+
|
|
42
47
|
# Read-only transcript view exposed to plugin code.
|
|
43
48
|
class Transcript
|
|
44
49
|
# Creates an object for trusted plugin loading and dispatch.
|
|
@@ -50,7 +55,7 @@ module Kward
|
|
|
50
55
|
#
|
|
51
56
|
# @return [Array<Hash>] immutable transcript message data
|
|
52
57
|
def messages
|
|
53
|
-
|
|
58
|
+
DeepCopy.freeze(DeepCopy.dup(@conversation.messages))
|
|
54
59
|
end
|
|
55
60
|
end
|
|
56
61
|
|
|
@@ -102,6 +107,34 @@ module Kward
|
|
|
102
107
|
@conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
|
|
103
108
|
nil
|
|
104
109
|
end
|
|
110
|
+
|
|
111
|
+
def allow(message = nil, metadata: nil)
|
|
112
|
+
Hooks::Decision.allow(message, metadata: metadata)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def deny(message = nil, metadata: nil)
|
|
116
|
+
Hooks::Decision.deny(message, metadata: metadata)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def ask(message = nil, metadata: nil)
|
|
120
|
+
Hooks::Decision.ask(message, metadata: metadata)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def modify(payload, message: nil, metadata: nil)
|
|
124
|
+
Hooks::Decision.modify(payload, message: message, metadata: metadata)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def warn(message = nil, metadata: nil)
|
|
128
|
+
Hooks::Decision.warn(message, metadata: metadata)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def retry(message = nil, payload: nil, metadata: nil)
|
|
132
|
+
Hooks::Decision.retry(message, payload: payload, metadata: metadata)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def defer(message = nil, payload: nil, metadata: nil)
|
|
136
|
+
Hooks::Decision.defer(message, payload: payload, metadata: metadata)
|
|
137
|
+
end
|
|
105
138
|
end
|
|
106
139
|
|
|
107
140
|
# Public DSL object yielded by `Kward.plugin` blocks.
|
|
@@ -165,6 +198,25 @@ module Kward
|
|
|
165
198
|
@registry.register_transcript_event(path: @path, &block)
|
|
166
199
|
end
|
|
167
200
|
|
|
201
|
+
# Registers a lifecycle hook handler.
|
|
202
|
+
#
|
|
203
|
+
# Hooks are deterministic runtime callbacks around Kward lifecycle events.
|
|
204
|
+
# They can return a {Hooks::Decision}, a decision hash, a decision string,
|
|
205
|
+
# or nil to allow the operation.
|
|
206
|
+
#
|
|
207
|
+
# @param event [String, #to_s] lifecycle event name
|
|
208
|
+
# @param id [String, nil] stable hook identifier for logs and diagnostics
|
|
209
|
+
# @param description [String] short human-readable purpose
|
|
210
|
+
# @param order [Integer] lower values run first
|
|
211
|
+
# @param match [Hash, nil] optional event selector
|
|
212
|
+
# @yieldparam event [Hooks::Event] immutable lifecycle event
|
|
213
|
+
# @yieldparam ctx [Context] plugin execution context and decision helpers
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @api public
|
|
216
|
+
def hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, &block)
|
|
217
|
+
@registry.register_hook(event, id: id, description: description, order: order, match: match, failure_policy: failure_policy, path: @path, &block)
|
|
218
|
+
end
|
|
219
|
+
|
|
168
220
|
# Registers prompt context text injected into future system prompts.
|
|
169
221
|
#
|
|
170
222
|
# Keep this text short and never include secrets. The returned string can
|
|
@@ -205,29 +257,6 @@ module Kward
|
|
|
205
257
|
paths.each { |path| registry.load_file(path) }
|
|
206
258
|
registry
|
|
207
259
|
end
|
|
208
|
-
|
|
209
|
-
def deep_dup(value)
|
|
210
|
-
case value
|
|
211
|
-
when Hash
|
|
212
|
-
value.each_with_object({}) { |(key, item), result| result[key] = deep_dup(item) }
|
|
213
|
-
when Array
|
|
214
|
-
value.map { |item| deep_dup(item) }
|
|
215
|
-
else
|
|
216
|
-
value.dup
|
|
217
|
-
end
|
|
218
|
-
rescue TypeError
|
|
219
|
-
value
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def deep_freeze(value)
|
|
223
|
-
case value
|
|
224
|
-
when Hash
|
|
225
|
-
value.each_value { |item| deep_freeze(item) }
|
|
226
|
-
when Array
|
|
227
|
-
value.each { |item| deep_freeze(item) }
|
|
228
|
-
end
|
|
229
|
-
value.freeze
|
|
230
|
-
end
|
|
231
260
|
end
|
|
232
261
|
|
|
233
262
|
# Creates an object for trusted plugin loading and dispatch.
|
|
@@ -239,6 +268,7 @@ module Kward
|
|
|
239
268
|
@footer_path = nil
|
|
240
269
|
@transcript_event_handlers = []
|
|
241
270
|
@prompt_context_renderers = []
|
|
271
|
+
@hook_handlers = []
|
|
242
272
|
@paths = []
|
|
243
273
|
end
|
|
244
274
|
|
|
@@ -276,6 +306,20 @@ module Kward
|
|
|
276
306
|
@prompt_context_renderers.map { |entry| entry[:renderer] }
|
|
277
307
|
end
|
|
278
308
|
|
|
309
|
+
def hook_handlers
|
|
310
|
+
@hook_handlers.dup
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def hook_manager
|
|
314
|
+
manager = Hooks::Manager.new
|
|
315
|
+
@hook_handlers.each do |hook|
|
|
316
|
+
manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
|
|
317
|
+
hook.handler.call(event, context)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
manager
|
|
321
|
+
end
|
|
322
|
+
|
|
279
323
|
def prompt_context(context)
|
|
280
324
|
parts = []
|
|
281
325
|
@prompt_context_renderers.each do |entry|
|
|
@@ -387,6 +431,23 @@ module Kward
|
|
|
387
431
|
@prompt_context_renderers << { path: path, renderer: renderer }
|
|
388
432
|
end
|
|
389
433
|
|
|
434
|
+
def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
|
|
435
|
+
event = event.to_s
|
|
436
|
+
raise "Plugin hook event is required" if event.empty?
|
|
437
|
+
raise "Plugin hook #{event} requires a handler" unless handler
|
|
438
|
+
|
|
439
|
+
@hook_handlers << HookHandler.new(
|
|
440
|
+
event: event,
|
|
441
|
+
id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
|
|
442
|
+
description: description.to_s,
|
|
443
|
+
path: path,
|
|
444
|
+
order: order.to_i,
|
|
445
|
+
match: match,
|
|
446
|
+
failure_policy: failure_policy,
|
|
447
|
+
handler: handler
|
|
448
|
+
)
|
|
449
|
+
end
|
|
450
|
+
|
|
390
451
|
private
|
|
391
452
|
|
|
392
453
|
def transcript_event_for(event)
|
|
@@ -422,7 +483,7 @@ module Kward
|
|
|
422
483
|
def transcript_event(type, payload)
|
|
423
484
|
TranscriptEvent.new(
|
|
424
485
|
type: type,
|
|
425
|
-
payload:
|
|
486
|
+
payload: DeepCopy.freeze(DeepCopy.dup(payload))
|
|
426
487
|
).freeze
|
|
427
488
|
end
|
|
428
489
|
end
|
|
@@ -112,7 +112,9 @@ module Kward
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def open_diff_viewer(path, content)
|
|
115
|
-
|
|
115
|
+
diff_view_mode = current_diff_view_mode
|
|
116
|
+
viewer_content = diff_view_mode == DiffViewMode::SIDE_BY_SIDE ? side_by_side_diff_content(content.to_s) : content.to_s
|
|
117
|
+
@editor_state = EditorState.new(path: path.to_s, content: viewer_content, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: diff_view_mode)
|
|
116
118
|
@prompt_label = "Diff>"
|
|
117
119
|
self.composer_input = ""
|
|
118
120
|
self.composer_cursor = 0
|
|
@@ -131,6 +133,63 @@ module Kward
|
|
|
131
133
|
@editor_mode
|
|
132
134
|
end
|
|
133
135
|
|
|
136
|
+
def current_diff_view_mode
|
|
137
|
+
configured = @diff_view_source.respond_to?(:call) ? @diff_view_source.call : @diff_view
|
|
138
|
+
DiffViewMode.resolve(configured, terminal_width: screen_width)
|
|
139
|
+
rescue StandardError
|
|
140
|
+
DiffViewMode.resolve(@diff_view, terminal_width: screen_width)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def side_by_side_diff_content(content)
|
|
144
|
+
width = side_by_side_diff_column_width
|
|
145
|
+
rows = []
|
|
146
|
+
pending_deletions = []
|
|
147
|
+
|
|
148
|
+
content.to_s.each_line(chomp: true) do |line|
|
|
149
|
+
if side_by_side_diff_metadata_line?(line)
|
|
150
|
+
rows.concat(flush_side_by_side_deletions(pending_deletions, width))
|
|
151
|
+
rows << line
|
|
152
|
+
elsif line.start_with?("-")
|
|
153
|
+
pending_deletions << line[1..].to_s
|
|
154
|
+
elsif line.start_with?("+")
|
|
155
|
+
deleted = pending_deletions.shift
|
|
156
|
+
rows << side_by_side_diff_row(deleted && "- #{deleted}", "+ #{line[1..]}", width)
|
|
157
|
+
else
|
|
158
|
+
rows.concat(flush_side_by_side_deletions(pending_deletions, width))
|
|
159
|
+
text = line.start_with?(" ") ? line[1..].to_s : line
|
|
160
|
+
rows << side_by_side_diff_row(" #{text}", " #{text}", width)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
rows.concat(flush_side_by_side_deletions(pending_deletions, width))
|
|
165
|
+
rows.join("\n")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def side_by_side_diff_metadata_line?(line)
|
|
169
|
+
line.start_with?("diff --git", "index ", "---", "+++", "@@")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def flush_side_by_side_deletions(lines, width)
|
|
173
|
+
lines.shift(lines.length).map { |line| side_by_side_diff_row("- #{line}", nil, width) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def side_by_side_diff_row(left, right, width)
|
|
177
|
+
left_text = side_by_side_diff_cell(left, width)
|
|
178
|
+
right_text = side_by_side_diff_cell(right, width)
|
|
179
|
+
"#{left_text.ljust(width)} │ #{right_text}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def side_by_side_diff_cell(text, width)
|
|
183
|
+
value = text.nil? ? "" : text.to_s
|
|
184
|
+
visible_truncate(value, width)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def side_by_side_diff_column_width
|
|
188
|
+
content_width = [screen_width - 4, 1].max
|
|
189
|
+
gutter_width = [[999.to_s.length, 4].max + 3, 1].max
|
|
190
|
+
[[(content_width - gutter_width - 3) / 2, 10].max, 80].min
|
|
191
|
+
end
|
|
192
|
+
|
|
134
193
|
def current_editor_soft_wrap?
|
|
135
194
|
return @editor_soft_wrap_source.call != false if @editor_soft_wrap_source.respond_to?(:call)
|
|
136
195
|
|
|
@@ -174,6 +174,7 @@ module Kward
|
|
|
174
174
|
|
|
175
175
|
def editor_render_diff_line(line)
|
|
176
176
|
text = line.to_s
|
|
177
|
+
return editor_render_side_by_side_diff_line(text) if @editor_state.side_by_side_diff_view?
|
|
177
178
|
return colored(text, :green) if text.start_with?("+") && !text.start_with?("+++")
|
|
178
179
|
return colored(text, :red) if text.start_with?("-") && !text.start_with?("---")
|
|
179
180
|
return colored(text, :cyan) if text.start_with?("@@")
|
|
@@ -181,6 +182,16 @@ module Kward
|
|
|
181
182
|
text
|
|
182
183
|
end
|
|
183
184
|
|
|
185
|
+
def editor_render_side_by_side_diff_line(text)
|
|
186
|
+
return colored(text, :cyan) if text.start_with?("@@")
|
|
187
|
+
return text unless text.include?(" │ ")
|
|
188
|
+
|
|
189
|
+
left, right = text.split(" │ ", 2)
|
|
190
|
+
rendered_left = left.start_with?("-") ? colored(left, :red) : left
|
|
191
|
+
rendered_right = right.start_with?("+") ? colored(right, :green) : right
|
|
192
|
+
"#{rendered_left} │ #{rendered_right}"
|
|
193
|
+
end
|
|
194
|
+
|
|
184
195
|
def editor_line_number_gutter_width
|
|
185
196
|
[[@editor_state.lines.length.to_s.length, 4].max + 3, 1].max
|
|
186
197
|
end
|