kward 0.82.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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile.lock +2 -2
  4. data/doc/agent-tools.md +1 -0
  5. data/doc/composer.md +1 -1
  6. data/doc/configuration.md +18 -3
  7. data/doc/editor.md +20 -10
  8. data/doc/files.md +4 -3
  9. data/doc/permissions.md +1 -0
  10. data/doc/releasing.md +12 -4
  11. data/doc/rpc.md +2 -2
  12. data/doc/sandboxing.md +5 -1
  13. data/doc/security.md +1 -1
  14. data/doc/shell.md +40 -16
  15. data/doc/tabs.md +3 -0
  16. data/doc/usage.md +3 -2
  17. data/lib/kward/agent.rb +1 -0
  18. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  19. data/lib/kward/cli/interactive_turn.rb +101 -19
  20. data/lib/kward/cli/runtime_helpers.rb +156 -14
  21. data/lib/kward/cli/slash_commands.rb +12 -1
  22. data/lib/kward/cli/tabs.rb +12 -5
  23. data/lib/kward/cli.rb +10 -0
  24. data/lib/kward/compaction/token_estimator.rb +12 -6
  25. data/lib/kward/config_files.rb +17 -0
  26. data/lib/kward/editor_prompt.rb +46 -0
  27. data/lib/kward/editor_prompt_session.rb +28 -0
  28. data/lib/kward/ekwsh.rb +70 -10
  29. data/lib/kward/model/client.rb +1 -17
  30. data/lib/kward/model/model_info.rb +3 -2
  31. data/lib/kward/model/payloads.rb +0 -2
  32. data/lib/kward/persistent_shell_session.rb +750 -0
  33. data/lib/kward/project_files.rb +18 -5
  34. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  35. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  36. data/lib/kward/prompt_interface/editor/controller.rb +69 -0
  37. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  38. data/lib/kward/prompt_interface/editor/modes/vibe.rb +10 -4
  39. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  40. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  41. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  42. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  43. data/lib/kward/prompt_interface/key_handler.rb +8 -0
  44. data/lib/kward/prompt_interface/project_browser.rb +30 -5
  45. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  46. data/lib/kward/prompt_interface.rb +76 -0
  47. data/lib/kward/prompts/commands.rb +1 -0
  48. data/lib/kward/rpc/auth_manager.rb +1 -1
  49. data/lib/kward/rpc/server.rb +2 -1
  50. data/lib/kward/shell_prompt.rb +50 -0
  51. data/lib/kward/shell_prompt_session.rb +58 -0
  52. data/lib/kward/terminal_keys.rb +1 -0
  53. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  54. data/lib/kward/tools/registry.rb +63 -5
  55. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  56. data/lib/kward/tools/run_shell_command.rb +24 -6
  57. data/lib/kward/version.rb +1 -1
  58. data/templates/default/layout/html/layout.erb +1 -1
  59. metadata +9 -1
@@ -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
- env = @env.dup
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: @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 #{@timeout_seconds} seconds\n", streamed) { |text| yield text } if result.timed_out
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
@@ -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
- apply_codex_identity(request, luna: luna_request?(request.body))
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
 
@@ -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