kward 0.81.0 → 0.83.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 +57 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/Rakefile +44 -4
- data/doc/agent-tools.md +1 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +18 -3
- data/doc/editor.md +20 -10
- data/doc/files.md +12 -5
- data/doc/permissions.md +1 -0
- data/doc/releasing.md +79 -38
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +2 -2
- data/doc/shell.md +42 -18
- data/doc/tabs.md +3 -0
- data/doc/usage.md +3 -2
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/agent.rb +1 -0
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +101 -19
- data/lib/kward/cli/rendering.rb +14 -2
- data/lib/kward/cli/runtime_helpers.rb +252 -46
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +12 -1
- data/lib/kward/cli/tabs.rb +71 -9
- data/lib/kward/cli.rb +12 -0
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +17 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/ekwsh.rb +70 -10
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +25 -19
- data/lib/kward/model/client.rb +1 -17
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/persistent_shell_session.rb +750 -0
- data/lib/kward/project_files.rb +18 -5
- data/lib/kward/prompt_interface/composer_controller.rb +3 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
- data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
- data/lib/kward/prompt_interface/editor/controller.rb +75 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
- data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -0
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +21 -7
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +30 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
- data/lib/kward/prompt_interface/project_browser.rb +192 -8
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/runtime_state.rb +5 -1
- data/lib/kward/prompt_interface/screen.rb +40 -20
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +159 -32
- data/lib/kward/prompts/commands.rb +1 -0
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +94 -5
- data/lib/kward/shell_prompt.rb +50 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_keys.rb +1 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +63 -5
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/version.rb +1 -1
- data/templates/default/layout/html/layout.erb +1 -1
- metadata +15 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
data/lib/kward/cli.rb
CHANGED
|
@@ -10,12 +10,19 @@ require_relative "compactor"
|
|
|
10
10
|
require_relative "config_files"
|
|
11
11
|
require_relative "clipboard"
|
|
12
12
|
require_relative "cancellation"
|
|
13
|
+
require_relative "editor_prompt"
|
|
14
|
+
require_relative "editor_prompt_session"
|
|
15
|
+
require_relative "shell_prompt"
|
|
16
|
+
require_relative "shell_prompt_session"
|
|
13
17
|
require_relative "cli_transcript_formatter"
|
|
14
18
|
require_relative "model/context_usage"
|
|
15
19
|
require_relative "events"
|
|
16
20
|
require_relative "export_path"
|
|
17
21
|
require_relative "ekwsh"
|
|
22
|
+
require_relative "persistent_shell_session"
|
|
18
23
|
require_relative "interactive_pty_runner"
|
|
24
|
+
require_relative "adaptive_pty_output_sink"
|
|
25
|
+
require_relative "pty_transcript_normalizer"
|
|
19
26
|
require_relative "auth/anthropic_oauth"
|
|
20
27
|
require_relative "auth/api_key_store"
|
|
21
28
|
require_relative "auth/github_oauth"
|
|
@@ -475,6 +482,11 @@ module Kward
|
|
|
475
482
|
run_ekwsh_loop(active_tab.shell, tab: active_tab, history: build_ekwsh_history(active_tab.agent))
|
|
476
483
|
end
|
|
477
484
|
input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
|
|
485
|
+
if input.is_a?(Hash) && input[:editor_prompt]
|
|
486
|
+
pending_inputs = run_editor_prompt_turn(input[:editor_prompt], active_tab&.agent || agent)
|
|
487
|
+
pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
488
|
+
next
|
|
489
|
+
end
|
|
478
490
|
if input.is_a?(Hash) && input[:tab_action]
|
|
479
491
|
tab_result = handle_tab_action(input, session_store)
|
|
480
492
|
break if tab_result == PromptInterface::EXIT_INPUT
|
|
@@ -31,13 +31,15 @@ module Kward
|
|
|
31
31
|
parts = [role]
|
|
32
32
|
if role.to_s == "compactionSummary"
|
|
33
33
|
parts << value(message, :summary)
|
|
34
|
-
|
|
34
|
+
elsif response_items(message).empty?
|
|
35
35
|
parts << content_text(value(message, :content))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
parts << value(message, :reasoning_summary)
|
|
37
|
+
tool_calls(message).each do |tool_call|
|
|
38
|
+
parts << tool_call_name(tool_call)
|
|
39
|
+
parts << tool_call_arguments(tool_call)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
parts << JSON.generate(response_items(message))
|
|
41
43
|
end
|
|
42
44
|
parts << value(message, :tool_call_id)
|
|
43
45
|
parts << value(message, :name)
|
|
@@ -65,6 +67,10 @@ module Kward
|
|
|
65
67
|
MessageAccess.tool_calls(message)
|
|
66
68
|
end
|
|
67
69
|
|
|
70
|
+
def response_items(message)
|
|
71
|
+
MessageAccess.response_items(message)
|
|
72
|
+
end
|
|
73
|
+
|
|
68
74
|
def tool_call_name(tool_call)
|
|
69
75
|
ToolCall.name(tool_call)
|
|
70
76
|
end
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -136,6 +136,7 @@ module Kward
|
|
|
136
136
|
},
|
|
137
137
|
"editor" => {
|
|
138
138
|
"mode" => "modern",
|
|
139
|
+
"agent" => {},
|
|
139
140
|
"auto_indent" => true,
|
|
140
141
|
"auto_close_pairs" => true,
|
|
141
142
|
"soft_wrap" => true,
|
|
@@ -482,6 +483,22 @@ module Kward
|
|
|
482
483
|
EditorMode.normalize(editor["mode"])
|
|
483
484
|
end
|
|
484
485
|
|
|
486
|
+
# Returns the optional model override for editor-agent turns.
|
|
487
|
+
def editor_agent_model(config = read_config)
|
|
488
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
489
|
+
agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
|
|
490
|
+
value = agent["model"].to_s.strip
|
|
491
|
+
value.empty? ? nil : value
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Returns the optional reasoning-effort override for editor-agent turns.
|
|
495
|
+
def editor_agent_reasoning_effort(config = read_config)
|
|
496
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
497
|
+
agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
|
|
498
|
+
value = agent["reasoning_effort"].to_s.strip
|
|
499
|
+
value.empty? ? nil : value
|
|
500
|
+
end
|
|
501
|
+
|
|
485
502
|
# Returns whether the built-in TUI editor should auto-indent new lines.
|
|
486
503
|
def editor_auto_indent?(config = read_config)
|
|
487
504
|
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Builds the model-facing request for an in-memory editor prompt.
|
|
4
|
+
module EditorPrompt
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def system_message
|
|
8
|
+
{
|
|
9
|
+
role: "system",
|
|
10
|
+
content: <<~PROMPT.strip
|
|
11
|
+
You are Kward's in-memory editor assistant.
|
|
12
|
+
|
|
13
|
+
Act only on the user's explicit request about the active editor buffer. Do not volunteer changes, explanations, or unrelated work. For an actionable edit request, call replace_editor_buffer with the complete replacement buffer. For a request that is only a question or needs clarification, answer briefly without changing the buffer. Never write files to disk and never claim that the buffer was saved.
|
|
14
|
+
PROMPT
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input(instruction, context)
|
|
19
|
+
document = context[:display_path].to_s
|
|
20
|
+
language = context[:language].to_s
|
|
21
|
+
language = "text" if language.empty?
|
|
22
|
+
content = context[:content].to_s
|
|
23
|
+
|
|
24
|
+
<<~PROMPT
|
|
25
|
+
You are operating inside an interactive editor.
|
|
26
|
+
|
|
27
|
+
The user requested:
|
|
28
|
+
#{instruction}
|
|
29
|
+
|
|
30
|
+
Active document: #{document.empty? ? "(untitled)" : document}
|
|
31
|
+
Language: #{language}
|
|
32
|
+
Cursor: line #{context.dig(:cursor, 0).to_i + 1}, column #{context.dig(:cursor, 1).to_i + 1}
|
|
33
|
+
Selected text: #{context[:selection].to_s.empty? ? "(none)" : "present; full-buffer replacement is required"}
|
|
34
|
+
|
|
35
|
+
Current buffer:
|
|
36
|
+
<editor_buffer>
|
|
37
|
+
#{content}
|
|
38
|
+
</editor_buffer>
|
|
39
|
+
|
|
40
|
+
Satisfy the user's request by calling replace_editor_buffer with the complete desired buffer contents.
|
|
41
|
+
The operation changes only the in-memory editor buffer. Do not write files to disk.
|
|
42
|
+
If the request is ambiguous, explain the ambiguity instead of changing the buffer.
|
|
43
|
+
PROMPT
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Transactional draft used while an agent works on an active editor buffer.
|
|
4
|
+
#
|
|
5
|
+
# The live terminal editor remains owned by PromptInterface. Agent tool calls
|
|
6
|
+
# update this draft and the caller commits it only after a successful turn.
|
|
7
|
+
class EditorPromptSession
|
|
8
|
+
attr_reader :context, :content
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context.dup
|
|
12
|
+
@initial_content = @context.fetch(:content).to_s.dup
|
|
13
|
+
@content = @initial_content.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def replace(content)
|
|
17
|
+
@content = content.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def changed?
|
|
21
|
+
@content != @initial_content
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initial_content
|
|
25
|
+
@initial_content.dup
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/kward/ekwsh.rb
CHANGED
|
@@ -18,17 +18,16 @@ module Kward
|
|
|
18
18
|
DEFAULT_TIMEOUT_SECONDS = 300
|
|
19
19
|
DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
|
|
20
20
|
DEFAULT_HISTORY_LIMIT = 1_000
|
|
21
|
+
CONTEXT_OUTPUT_BYTES = 32_000
|
|
21
22
|
|
|
22
|
-
attr_reader :cwd
|
|
23
|
+
attr_reader :cwd, :last_command, :timeout_seconds
|
|
23
24
|
|
|
24
25
|
def command_shell
|
|
25
26
|
@shell
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def child_env(interactive: false)
|
|
29
|
-
|
|
30
|
-
env.delete("GIT_PAGER") if interactive && @defaulted_git_pager
|
|
31
|
-
env
|
|
30
|
+
@env.dup
|
|
32
31
|
end
|
|
33
32
|
|
|
34
33
|
def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES)
|
|
@@ -43,6 +42,8 @@ module Kward
|
|
|
43
42
|
@shell = shell.to_s.empty? ? DEFAULT_SHELL : shell.to_s
|
|
44
43
|
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : DEFAULT_TIMEOUT_SECONDS
|
|
45
44
|
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : DEFAULT_MAX_OUTPUT_BYTES
|
|
45
|
+
@last_command = nil
|
|
46
|
+
@last_result = nil
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def prompt_label
|
|
@@ -53,7 +54,45 @@ module Kward
|
|
|
53
54
|
command = input.to_s.strip
|
|
54
55
|
return Result.new(output: "", exit_status: 0) if command.empty?
|
|
55
56
|
|
|
56
|
-
run_expanded_command(command, cancellation: cancellation, &block)
|
|
57
|
+
result = run_expanded_command(command, cancellation: cancellation, &block)
|
|
58
|
+
remember_result(command, result)
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs a command for the shell assistant without handing it the terminal.
|
|
63
|
+
# Built-ins still execute in this Ekwsh instance so their state persists.
|
|
64
|
+
def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
|
|
65
|
+
command = input.to_s.strip
|
|
66
|
+
return run(command, cancellation: cancellation) if command.empty?
|
|
67
|
+
|
|
68
|
+
result = run(command, cancellation: cancellation)
|
|
69
|
+
return result unless result.interactive_command
|
|
70
|
+
|
|
71
|
+
result = execute(
|
|
72
|
+
result.interactive_command,
|
|
73
|
+
display_command: command,
|
|
74
|
+
timeout_seconds: timeout_seconds,
|
|
75
|
+
cancellation: cancellation
|
|
76
|
+
)
|
|
77
|
+
remember_result(command, result)
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Records the output of an interactive command after terminal handoff.
|
|
82
|
+
def record_interactive_result(output:, exit_status:)
|
|
83
|
+
return unless @last_command
|
|
84
|
+
|
|
85
|
+
@last_result = Result.new(output: ANSI.sanitize_transcript(output.to_s), exit_status: exit_status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns bounded shell state for an explicit shell-agent prompt.
|
|
89
|
+
def context_snapshot(max_output_bytes: CONTEXT_OUTPUT_BYTES)
|
|
90
|
+
{
|
|
91
|
+
cwd: @cwd,
|
|
92
|
+
last_command: @last_command,
|
|
93
|
+
exit_status: @last_result&.exit_status,
|
|
94
|
+
last_output: context_output(@last_result&.output, max_output_bytes)
|
|
95
|
+
}
|
|
57
96
|
end
|
|
58
97
|
|
|
59
98
|
def complete(input, cursor)
|
|
@@ -112,8 +151,6 @@ module Kward
|
|
|
112
151
|
def configure_color_environment
|
|
113
152
|
@env["CLICOLOR"] ||= "1"
|
|
114
153
|
@env["COLORTERM"] ||= "truecolor"
|
|
115
|
-
@defaulted_git_pager = !@env.key?("GIT_PAGER")
|
|
116
|
-
@env["GIT_PAGER"] ||= "cat"
|
|
117
154
|
@env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
|
|
118
155
|
end
|
|
119
156
|
|
|
@@ -522,12 +559,13 @@ module Kward
|
|
|
522
559
|
key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
523
560
|
end
|
|
524
561
|
|
|
525
|
-
def execute(command, display_command: command, cancellation: nil)
|
|
562
|
+
def execute(command, display_command: command, timeout_seconds: nil, cancellation: nil)
|
|
526
563
|
output = command_echo(display_command)
|
|
527
564
|
streamed = block_given?
|
|
528
565
|
yield output.dup if streamed
|
|
566
|
+
timeout = effective_timeout(timeout_seconds)
|
|
529
567
|
result = external_command_runner.new(
|
|
530
|
-
timeout_seconds:
|
|
568
|
+
timeout_seconds: timeout,
|
|
531
569
|
max_output_bytes: @max_output_bytes,
|
|
532
570
|
terminate_on_output_limit: true
|
|
533
571
|
).run(@shell, "-c", command, env: @env, cwd: @cwd, cancellation: cancellation) do |_stream, chunk|
|
|
@@ -537,7 +575,7 @@ module Kward
|
|
|
537
575
|
end
|
|
538
576
|
append_output_newline(output) { |text| yield text if streamed }
|
|
539
577
|
exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
|
|
540
|
-
append_streamed(output, "ekwsh: command timed out after #{
|
|
578
|
+
append_streamed(output, "ekwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
|
|
541
579
|
append_streamed(output, "ekwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
|
|
542
580
|
append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
|
|
543
581
|
Result.new(output: output, exit_status: exit_status, streamed: streamed)
|
|
@@ -549,6 +587,28 @@ module Kward
|
|
|
549
587
|
Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
|
|
550
588
|
end
|
|
551
589
|
|
|
590
|
+
def effective_timeout(timeout_seconds)
|
|
591
|
+
timeout = timeout_seconds.to_i
|
|
592
|
+
timeout.positive? ? timeout : @timeout_seconds
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def remember_result(command, result)
|
|
596
|
+
@last_command = command.to_s
|
|
597
|
+
@last_result = result
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def context_output(output, max_output_bytes)
|
|
601
|
+
text = ANSI.sanitize_transcript(output.to_s)
|
|
602
|
+
limit = max_output_bytes.to_i
|
|
603
|
+
return text if limit <= 0 || text.bytesize <= limit
|
|
604
|
+
|
|
605
|
+
prefix = "[Earlier output omitted; showing the end of the command output.]\n"
|
|
606
|
+
tail_limit = [limit - prefix.bytesize, 0].max
|
|
607
|
+
tail = text.byteslice(-tail_limit, tail_limit).to_s if tail_limit.positive?
|
|
608
|
+
value = "#{prefix}#{tail}"
|
|
609
|
+
ANSI.normalize_transcript_encoding(value.byteslice(-limit, limit).to_s)
|
|
610
|
+
end
|
|
611
|
+
|
|
552
612
|
def external_command_runner
|
|
553
613
|
defined?(LocalPtyCommandRunner) ? LocalPtyCommandRunner : LocalCommandRunner
|
|
554
614
|
end
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
require "base64"
|
|
2
2
|
require "cgi/escape"
|
|
3
|
+
require "open3"
|
|
3
4
|
require "shellwords"
|
|
4
5
|
require "tmpdir"
|
|
5
6
|
require "uri"
|
|
7
|
+
require_relative "terminal_image_support"
|
|
6
8
|
|
|
7
9
|
# Namespace for the Kward CLI agent runtime.
|
|
8
10
|
module Kward
|
|
@@ -291,6 +293,71 @@ module Kward
|
|
|
291
293
|
MIME_TYPES.key?(File.extname(path.to_s).downcase)
|
|
292
294
|
end
|
|
293
295
|
|
|
296
|
+
def image_part_from_path(path)
|
|
297
|
+
expanded_path = File.expand_path(path.to_s)
|
|
298
|
+
return nil unless File.file?(expanded_path)
|
|
299
|
+
|
|
300
|
+
media_type = mime_type(expanded_path)
|
|
301
|
+
return nil unless media_type
|
|
302
|
+
|
|
303
|
+
size = File.size(expanded_path)
|
|
304
|
+
return nil if size > MAX_IMAGE_BYTES
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
type: "image",
|
|
308
|
+
media_type: media_type,
|
|
309
|
+
data: Base64.strict_encode64(File.binread(expanded_path)),
|
|
310
|
+
path: expanded_path,
|
|
311
|
+
size_bytes: size
|
|
312
|
+
}
|
|
313
|
+
rescue SystemCallError
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def terminal_image_part(part, protocol)
|
|
318
|
+
return part unless protocol == :kitty
|
|
319
|
+
return part if image_media_type(part) == "image/png"
|
|
320
|
+
|
|
321
|
+
convert_image_part_to_png(part)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def png_dimensions(part)
|
|
325
|
+
encoded = part[:data] || part["data"]
|
|
326
|
+
header = Base64.strict_decode64(encoded.to_s[0, 32])
|
|
327
|
+
return nil unless header.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
328
|
+
return nil unless header.byteslice(12, 4) == "IHDR"
|
|
329
|
+
|
|
330
|
+
width, height = header.byteslice(16, 8).unpack("NN")
|
|
331
|
+
return nil unless width.positive? && height.positive?
|
|
332
|
+
|
|
333
|
+
[width, height]
|
|
334
|
+
rescue ArgumentError, NoMethodError
|
|
335
|
+
nil
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def convert_image_part_to_png(part)
|
|
339
|
+
path = part[:path] || part["path"]
|
|
340
|
+
return nil unless path && File.file?(path)
|
|
341
|
+
|
|
342
|
+
stdout, _stderr, status = Open3.capture3(
|
|
343
|
+
"magick",
|
|
344
|
+
"-limit", "memory", "128MiB",
|
|
345
|
+
"-limit", "map", "256MiB",
|
|
346
|
+
"#{path}[0]",
|
|
347
|
+
"png:-"
|
|
348
|
+
)
|
|
349
|
+
return nil unless status.success?
|
|
350
|
+
return nil if stdout.empty? || stdout.bytesize > MAX_IMAGE_BYTES
|
|
351
|
+
|
|
352
|
+
part.merge(
|
|
353
|
+
media_type: "image/png",
|
|
354
|
+
data: Base64.strict_encode64(stdout),
|
|
355
|
+
size_bytes: stdout.bytesize
|
|
356
|
+
)
|
|
357
|
+
rescue SystemCallError
|
|
358
|
+
nil
|
|
359
|
+
end
|
|
360
|
+
|
|
294
361
|
def mime_type(path)
|
|
295
362
|
MIME_TYPES[File.extname(path.to_s).downcase]
|
|
296
363
|
end
|
|
@@ -300,36 +367,52 @@ module Kward
|
|
|
300
367
|
"data:#{media_type};base64,#{part[:data] || part["data"]}"
|
|
301
368
|
end
|
|
302
369
|
|
|
303
|
-
def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, env: ENV)
|
|
370
|
+
def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV)
|
|
304
371
|
data = part[:data] || part["data"]
|
|
305
372
|
return nil if data.to_s.empty?
|
|
306
373
|
|
|
374
|
+
protocol ||= terminal_image_protocol(env)
|
|
307
375
|
name = part[:path] || part["path"]
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
376
|
+
case protocol
|
|
377
|
+
when :iterm2
|
|
378
|
+
iterm_image_sequence(data, name, width, height, env: env)
|
|
379
|
+
when :kitty
|
|
380
|
+
return nil unless image_media_type(part) == "image/png"
|
|
381
|
+
|
|
382
|
+
kitty_image_sequence(data, name, width, height, image_id, move_cursor: move_cursor, env: env)
|
|
312
383
|
end
|
|
313
384
|
end
|
|
314
385
|
|
|
386
|
+
def terminal_image_protocol(env)
|
|
387
|
+
TerminalImageSupport.static_protocol(env)
|
|
388
|
+
end
|
|
389
|
+
|
|
315
390
|
def iterm_image_protocol?(env)
|
|
316
|
-
env
|
|
391
|
+
TerminalImageSupport.iterm2_hint?(env)
|
|
317
392
|
end
|
|
318
393
|
|
|
319
394
|
def kitty_image_protocol?(env)
|
|
320
|
-
|
|
395
|
+
TerminalImageSupport.kitty_hint?(env)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def iterm_image_sequence(data, name, width, height = nil, env: ENV)
|
|
399
|
+
sequence = TerminalSequences.iterm2_image(data, name: name, width: width, height: height)
|
|
400
|
+
env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
|
|
321
401
|
end
|
|
322
402
|
|
|
323
|
-
def
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
403
|
+
def kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV)
|
|
404
|
+
sequence = TerminalSequences.kitty_graphics(
|
|
405
|
+
data,
|
|
406
|
+
image_id: image_id,
|
|
407
|
+
columns: width,
|
|
408
|
+
rows: height,
|
|
409
|
+
move_cursor: move_cursor
|
|
410
|
+
)
|
|
411
|
+
env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
|
|
327
412
|
end
|
|
328
413
|
|
|
329
|
-
def
|
|
330
|
-
|
|
331
|
-
params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
|
|
332
|
-
"\e_G#{params.join(";")}:#{data}\e\\"
|
|
414
|
+
def image_media_type(part)
|
|
415
|
+
part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"]
|
|
333
416
|
end
|
|
334
417
|
end
|
|
335
418
|
end
|
|
@@ -2,10 +2,11 @@ require "io/console"
|
|
|
2
2
|
require "pty"
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "local_pty_command_runner"
|
|
5
|
+
require_relative "pty_output_sink"
|
|
5
6
|
|
|
6
7
|
# Namespace for the Kward CLI agent runtime.
|
|
7
8
|
module Kward
|
|
8
|
-
# Runs a command in a PTY while forwarding caller-owned input and output
|
|
9
|
+
# Runs a command in a PTY while forwarding caller-owned input and output sink.
|
|
9
10
|
# This is intentionally low level: UI orchestration decides when terminal
|
|
10
11
|
# ownership is handed to the child process and how the result is presented.
|
|
11
12
|
class InteractivePtyRunner
|
|
@@ -18,7 +19,7 @@ module Kward
|
|
|
18
19
|
@window_size = nil
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def run(*command, env: {}, cwd: Dir.pwd, input: $stdin,
|
|
22
|
+
def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
|
|
22
23
|
pid = nil
|
|
23
24
|
status = nil
|
|
24
25
|
writer = nil
|
|
@@ -29,9 +30,11 @@ module Kward
|
|
|
29
30
|
writer = child_writer
|
|
30
31
|
update_window_size(reader, pid)
|
|
31
32
|
with_raw_input(input) do
|
|
32
|
-
drain_initial_input(input, writer)
|
|
33
|
-
status,
|
|
33
|
+
input_forwarded = drain_initial_input(input, writer, sink)
|
|
34
|
+
status, forwarded = forward_io(reader, writer, pid, input, sink)
|
|
35
|
+
input_forwarded ||= forwarded
|
|
34
36
|
end
|
|
37
|
+
sink.finish if sink.respond_to?(:finish)
|
|
35
38
|
status ||= wait_for_status(pid)
|
|
36
39
|
end
|
|
37
40
|
|
|
@@ -57,19 +60,23 @@ module Kward
|
|
|
57
60
|
yield
|
|
58
61
|
end
|
|
59
62
|
|
|
60
|
-
def drain_initial_input(input, writer)
|
|
63
|
+
def drain_initial_input(input, writer, sink)
|
|
64
|
+
forwarded = false
|
|
61
65
|
loop do
|
|
62
66
|
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
63
67
|
break if chunk.nil? || chunk == :wait_readable
|
|
64
68
|
|
|
69
|
+
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
65
70
|
writer.write(chunk)
|
|
71
|
+
forwarded = true
|
|
66
72
|
end
|
|
67
73
|
writer.flush
|
|
74
|
+
forwarded
|
|
68
75
|
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
69
|
-
|
|
76
|
+
forwarded
|
|
70
77
|
end
|
|
71
78
|
|
|
72
|
-
def forward_io(reader, writer, pid, input,
|
|
79
|
+
def forward_io(reader, writer, pid, input, sink)
|
|
73
80
|
input_open = true
|
|
74
81
|
input_forwarded = false
|
|
75
82
|
loop do
|
|
@@ -77,9 +84,9 @@ module Kward
|
|
|
77
84
|
readers = [reader]
|
|
78
85
|
readers << input if input_open
|
|
79
86
|
readable = IO.select(readers, nil, nil, 0.02)&.first || []
|
|
80
|
-
forward_pty_output(reader,
|
|
87
|
+
forward_pty_output(reader, sink) if readable.include?(reader)
|
|
81
88
|
if input_open && readable.include?(input)
|
|
82
|
-
input_open, forwarded = forward_input(input, writer)
|
|
89
|
+
input_open, forwarded = forward_input(input, writer, sink)
|
|
83
90
|
input_forwarded ||= forwarded
|
|
84
91
|
end
|
|
85
92
|
|
|
@@ -88,23 +95,22 @@ module Kward
|
|
|
88
95
|
|
|
89
96
|
return [finish_stopped_process(pid), input_forwarded] if status.stopped?
|
|
90
97
|
|
|
91
|
-
drain_pty_output(reader,
|
|
98
|
+
drain_pty_output(reader, sink)
|
|
92
99
|
return [status, input_forwarded]
|
|
93
100
|
rescue Errno::EIO
|
|
94
101
|
return [wait_for_status(pid), input_forwarded]
|
|
95
102
|
end
|
|
96
103
|
end
|
|
97
104
|
|
|
98
|
-
def forward_pty_output(reader,
|
|
105
|
+
def forward_pty_output(reader, sink)
|
|
99
106
|
chunk = reader.read_nonblock(READ_SIZE, exception: false)
|
|
100
107
|
return if chunk.nil? || chunk == :wait_readable
|
|
101
108
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
yield chunk if block_given?
|
|
109
|
+
sink.write(chunk)
|
|
110
|
+
sink.flush if sink.respond_to?(:flush)
|
|
105
111
|
end
|
|
106
112
|
|
|
107
|
-
def drain_pty_output(reader,
|
|
113
|
+
def drain_pty_output(reader, sink)
|
|
108
114
|
loop do
|
|
109
115
|
readable, = IO.select([reader], nil, nil, 0.02)
|
|
110
116
|
break unless readable
|
|
@@ -112,19 +118,19 @@ module Kward
|
|
|
112
118
|
chunk = reader.read_nonblock(READ_SIZE, exception: false)
|
|
113
119
|
break if chunk.nil? || chunk == :wait_readable
|
|
114
120
|
|
|
115
|
-
|
|
116
|
-
yield chunk if block_given?
|
|
121
|
+
sink.write(chunk)
|
|
117
122
|
end
|
|
118
|
-
|
|
123
|
+
sink.flush if sink.respond_to?(:flush)
|
|
119
124
|
rescue Errno::EIO
|
|
120
125
|
nil
|
|
121
126
|
end
|
|
122
127
|
|
|
123
|
-
def forward_input(input, writer)
|
|
128
|
+
def forward_input(input, writer, sink)
|
|
124
129
|
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
125
130
|
return [false, false] if chunk.nil?
|
|
126
131
|
return [true, false] if chunk == :wait_readable
|
|
127
132
|
|
|
133
|
+
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
128
134
|
writer.write(chunk)
|
|
129
135
|
writer.flush
|
|
130
136
|
[true, true]
|
data/lib/kward/model/client.rb
CHANGED
|
@@ -906,7 +906,7 @@ module Kward
|
|
|
906
906
|
request["Content-Type"] = "application/json"
|
|
907
907
|
request["Accept"] = "text/event-stream"
|
|
908
908
|
request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
|
|
909
|
-
|
|
909
|
+
request["originator"] = "kward"
|
|
910
910
|
|
|
911
911
|
message = nil
|
|
912
912
|
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
@@ -931,22 +931,6 @@ module Kward
|
|
|
931
931
|
raise e
|
|
932
932
|
end
|
|
933
933
|
|
|
934
|
-
def apply_codex_identity(request, luna:)
|
|
935
|
-
request["originator"] = "kward"
|
|
936
|
-
return unless luna
|
|
937
|
-
|
|
938
|
-
# TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
|
|
939
|
-
request["originator"] = "codex_cli_rs"
|
|
940
|
-
request["User-Agent"] = "codex_cli_rs/0.144.1"
|
|
941
|
-
request["x-openai-internal-codex-responses-lite"] = "true"
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
def luna_request?(request_body)
|
|
945
|
-
JSON.parse(request_body)["model"] == "gpt-5.6-luna"
|
|
946
|
-
rescue JSON::ParserError
|
|
947
|
-
false
|
|
948
|
-
end
|
|
949
|
-
|
|
950
934
|
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
|
|
951
935
|
ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
|
|
952
936
|
end
|
|
@@ -12,6 +12,7 @@ module Kward
|
|
|
12
12
|
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
|
|
13
13
|
ANTHROPIC_MODEL_CHOICES = %w[
|
|
14
14
|
claude-fable-5
|
|
15
|
+
claude-opus-5
|
|
15
16
|
claude-opus-4-8
|
|
16
17
|
claude-sonnet-5
|
|
17
18
|
claude-sonnet-4-6
|
|
@@ -113,7 +114,7 @@ module Kward
|
|
|
113
114
|
[/\Agpt-3\.5-turbo/, 16_385]
|
|
114
115
|
].freeze
|
|
115
116
|
ANTHROPIC_CONTEXT_WINDOWS = [
|
|
116
|
-
[/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)/, 1_000_000],
|
|
117
|
+
[/\Aclaude-(?:fable|mythos|opus|sonnet)-5(?:\z|-)/, 1_000_000],
|
|
117
118
|
[/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
|
|
118
119
|
[/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
|
|
119
120
|
[/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
|
|
@@ -454,7 +455,7 @@ module Kward
|
|
|
454
455
|
|
|
455
456
|
def anthropic_reasoning_effort_choices(id)
|
|
456
457
|
text = normalize_anthropic_model(id)
|
|
457
|
-
return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
|
|
458
|
+
return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|opus|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
|
|
458
459
|
return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
|
|
459
460
|
return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
|
|
460
461
|
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -189,8 +189,6 @@ module Kward
|
|
|
189
189
|
unless reasoning == false
|
|
190
190
|
effort = reasoning || reasoning_effort(provider)
|
|
191
191
|
payload[:reasoning] = { effort: effort, summary: "auto" }
|
|
192
|
-
# TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
|
|
193
|
-
payload[:reasoning][:context] = "all_turns" if provider == "Codex" && parts[:model] == "gpt-5.6-luna"
|
|
194
192
|
end
|
|
195
193
|
payload
|
|
196
194
|
end
|