kward 0.82.0 → 0.84.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 +96 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +1 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +82 -20
- data/doc/editor.md +47 -22
- data/doc/files.md +12 -7
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/permissions.md +1 -0
- data/doc/platform-support.md +48 -0
- data/doc/releasing.md +12 -4
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +4 -3
- data/doc/shell.md +101 -60
- data/doc/tabs.md +3 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +7 -6
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +7 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +144 -21
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +260 -55
- data/lib/kward/cli/slash_commands.rb +21 -12
- data/lib/kward/cli/tabs.rb +91 -13
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +55 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +94 -63
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +35 -39
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +843 -0
- data/lib/kward/project_files.rb +19 -6
- data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
- data/lib/kward/prompt_interface/editor/controller.rb +99 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
- data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +21 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +92 -22
- data/lib/kward/prompt_interface/key_handler.rb +75 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +443 -17
- data/lib/kward/prompt_interface/runtime_state.rb +55 -2
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +169 -18
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +52 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_keys.rb +13 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +75 -8
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +44 -8
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
data/lib/kward/kwshrc.rb
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
require_relative "kwsh"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Parses the declarative subset of a shell startup file used by kwsh.
|
|
7
|
+
#
|
|
8
|
+
# Only aliases, exports, and source directives are handled. Other shell
|
|
9
|
+
# syntax is intentionally ignored until kwsh supports scripting.
|
|
10
|
+
class Kwshrc
|
|
11
|
+
VARIABLE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
12
|
+
VARIABLE_PATTERN = /[A-Za-z_][A-Za-z0-9_]*/
|
|
13
|
+
|
|
14
|
+
class ParseError < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.read(paths, env: ENV.to_h)
|
|
18
|
+
new(env: env).read(paths)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.read_file(path, env: ENV.to_h)
|
|
22
|
+
parser = new(env: env)
|
|
23
|
+
parser.read_path(path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.resolve_path(path, cwd:, env: ENV.to_h)
|
|
27
|
+
parser = new(env: env)
|
|
28
|
+
expanded = parser.send(:expand_variables, path.to_s)
|
|
29
|
+
expanded = expanded.sub(/\A~(?=\/|\z)/, Dir.home)
|
|
30
|
+
File.expand_path(expanded, cwd)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(env: ENV.to_h)
|
|
34
|
+
@environment = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
35
|
+
@exports = {}
|
|
36
|
+
@aliases = {}
|
|
37
|
+
@loading = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read(paths)
|
|
41
|
+
Array(paths).each { |path| read_file(path, required: false) }
|
|
42
|
+
configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read_path(path)
|
|
46
|
+
read_file(path, required: true)
|
|
47
|
+
configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def configuration
|
|
53
|
+
{ env: @exports, aliases: @aliases }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_file(path, required:)
|
|
57
|
+
path = File.expand_path(path.to_s)
|
|
58
|
+
unless File.file?(path)
|
|
59
|
+
raise ParseError, "#{path}: file not found" if required
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
raise ParseError, "#{path}: recursive source" if @loading.include?(path)
|
|
63
|
+
|
|
64
|
+
@loading << path
|
|
65
|
+
File.foreach(path).with_index(1) do |line, line_number|
|
|
66
|
+
parse_line(strip_comment(line).strip, path, line_number)
|
|
67
|
+
end
|
|
68
|
+
rescue Errno::EACCES, Errno::EISDIR, Errno::ENOENT => e
|
|
69
|
+
raise ParseError, "#{path}: #{e.message}"
|
|
70
|
+
ensure
|
|
71
|
+
@loading.delete(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_line(line, path, line_number)
|
|
75
|
+
return if line.empty?
|
|
76
|
+
|
|
77
|
+
words = Shellwords.split(line)
|
|
78
|
+
return if words.empty?
|
|
79
|
+
|
|
80
|
+
case words.shift
|
|
81
|
+
when "alias"
|
|
82
|
+
parse_aliases(words)
|
|
83
|
+
when "export"
|
|
84
|
+
parse_exports(expand_variables(line), path, line_number)
|
|
85
|
+
when "source", "."
|
|
86
|
+
parse_source(expand_variables(line), path, line_number)
|
|
87
|
+
end
|
|
88
|
+
rescue ArgumentError => e
|
|
89
|
+
raise ParseError, "#{path}:#{line_number}: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_aliases(words)
|
|
93
|
+
words.each do |assignment|
|
|
94
|
+
name, command = assignment.split("=", 2)
|
|
95
|
+
next unless command && Kwsh.valid_alias_name?(name)
|
|
96
|
+
next if command.empty?
|
|
97
|
+
|
|
98
|
+
@aliases[name] = command
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_exports(line, path, line_number)
|
|
103
|
+
words = Shellwords.split(line)
|
|
104
|
+
assignments = words.drop(1)
|
|
105
|
+
raise ParseError, "#{path}:#{line_number}: export requires a variable" if assignments.empty?
|
|
106
|
+
|
|
107
|
+
assignments.each do |assignment|
|
|
108
|
+
name, value = assignment.split("=", 2)
|
|
109
|
+
next unless name.match?(VARIABLE_NAME)
|
|
110
|
+
|
|
111
|
+
value = @environment[name] if value.nil? && @environment.key?(name)
|
|
112
|
+
next if value.nil?
|
|
113
|
+
|
|
114
|
+
@environment[name] = value
|
|
115
|
+
@exports[name] = value
|
|
116
|
+
end
|
|
117
|
+
rescue ArgumentError => e
|
|
118
|
+
raise ParseError, "#{path}:#{line_number}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_source(line, path, line_number)
|
|
122
|
+
words = Shellwords.split(line)
|
|
123
|
+
source_path = words[1]
|
|
124
|
+
if words.length != 2 || source_path.to_s.empty?
|
|
125
|
+
raise ParseError, "#{path}:#{line_number}: source requires one file"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
source_path = File.expand_path(source_path, File.dirname(path))
|
|
129
|
+
read_file(source_path, required: true)
|
|
130
|
+
rescue ArgumentError => e
|
|
131
|
+
raise ParseError, "#{path}:#{line_number}: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def strip_comment(line)
|
|
135
|
+
result = +""
|
|
136
|
+
quote = nil
|
|
137
|
+
escaped = false
|
|
138
|
+
|
|
139
|
+
line.each_char do |character|
|
|
140
|
+
if escaped
|
|
141
|
+
result << character
|
|
142
|
+
escaped = false
|
|
143
|
+
elsif character == "\\" && quote != "'"
|
|
144
|
+
result << character
|
|
145
|
+
escaped = true
|
|
146
|
+
elsif quote
|
|
147
|
+
result << character
|
|
148
|
+
quote = nil if character == quote
|
|
149
|
+
elsif character == "'" || character == '"'
|
|
150
|
+
result << character
|
|
151
|
+
quote = character
|
|
152
|
+
elsif character == "#"
|
|
153
|
+
break
|
|
154
|
+
else
|
|
155
|
+
result << character
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def expand_variables(text)
|
|
162
|
+
result = +""
|
|
163
|
+
quote = nil
|
|
164
|
+
escaped = false
|
|
165
|
+
index = 0
|
|
166
|
+
|
|
167
|
+
while index < text.length
|
|
168
|
+
character = text[index]
|
|
169
|
+
if escaped
|
|
170
|
+
result << character
|
|
171
|
+
escaped = false
|
|
172
|
+
index += 1
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
if character == "\\" && quote != "'"
|
|
176
|
+
result << character
|
|
177
|
+
escaped = true
|
|
178
|
+
index += 1
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
if character == "'"
|
|
182
|
+
quote = if quote == "'"
|
|
183
|
+
nil
|
|
184
|
+
elsif quote.nil?
|
|
185
|
+
character
|
|
186
|
+
else
|
|
187
|
+
quote
|
|
188
|
+
end
|
|
189
|
+
result << character
|
|
190
|
+
index += 1
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
if character == '"'
|
|
194
|
+
quote = if quote == '"'
|
|
195
|
+
nil
|
|
196
|
+
elsif quote.nil?
|
|
197
|
+
character
|
|
198
|
+
else
|
|
199
|
+
quote
|
|
200
|
+
end
|
|
201
|
+
result << character
|
|
202
|
+
index += 1
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if character == "$" && quote != "'"
|
|
207
|
+
name, length = variable_at(text, index)
|
|
208
|
+
if name
|
|
209
|
+
result << @environment.fetch(name, "")
|
|
210
|
+
index += length
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
result << character
|
|
216
|
+
index += 1
|
|
217
|
+
end
|
|
218
|
+
result
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def variable_at(text, index)
|
|
222
|
+
if text[index + 1] == "{"
|
|
223
|
+
closing = text.index("}", index + 2)
|
|
224
|
+
name = text[(index + 2)...closing] if closing
|
|
225
|
+
return [name, closing - index + 1] if name&.match?(VARIABLE_NAME)
|
|
226
|
+
else
|
|
227
|
+
match = text[(index + 1)..].match(/\A#{VARIABLE_PATTERN}/)
|
|
228
|
+
return [match[0], match[0].length + 1] if match
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Finds fenced Markdown code blocks and manages their adjacent output fields.
|
|
4
|
+
class MarkdownCodeBlock
|
|
5
|
+
Block = Struct.new(
|
|
6
|
+
:source, :open_line, :close_line, :language, :body_start, :body_end, :close_end, :next_fence_start,
|
|
7
|
+
keyword_init: true
|
|
8
|
+
) do
|
|
9
|
+
def code
|
|
10
|
+
source[body_start...body_end].to_s
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def output_match
|
|
14
|
+
limit = next_fence_start || source.length
|
|
15
|
+
match = source.match(/<output\b[^>]*>.*?<\/output>/mi, close_end)
|
|
16
|
+
match if match && match.begin(0) < limit && match.end(0) <= limit
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def output_edit(output)
|
|
20
|
+
replacement = MarkdownCodeBlock.format_output(output, newline: newline)
|
|
21
|
+
match = output_match
|
|
22
|
+
if match
|
|
23
|
+
[match.begin(0), match.end(0), replacement]
|
|
24
|
+
else
|
|
25
|
+
insertion = if source[close_end - newline.length, newline.length] == newline
|
|
26
|
+
"#{newline}#{replacement}#{newline}"
|
|
27
|
+
else
|
|
28
|
+
"#{newline}#{newline}#{replacement}#{newline}"
|
|
29
|
+
end
|
|
30
|
+
[close_end, close_end, insertion]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def with_output(output)
|
|
35
|
+
start_index, end_index, replacement = output_edit(output)
|
|
36
|
+
source.dup.tap { |content| content[start_index...end_index] = replacement }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def newline
|
|
42
|
+
source[body_end...close_end].to_s.end_with?("\r\n") ? "\r\n" : "\n"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Fence = Struct.new(:line, :start, :finish, :marker, :length, :language, :close_line, :close_start, :close_end, keyword_init: true)
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
def for_selection(source, first_line, last_line)
|
|
50
|
+
blocks = parse(source)
|
|
51
|
+
candidates = blocks.select do |block|
|
|
52
|
+
full_fence = first_line == block.open_line && last_line == block.close_line
|
|
53
|
+
body_only = block.open_line < first_line && last_line < block.close_line
|
|
54
|
+
full_fence || body_only
|
|
55
|
+
end
|
|
56
|
+
candidates.min_by { |block| block.close_line - block.open_line }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def for_cursor(source, line)
|
|
60
|
+
parse(source).select do |block|
|
|
61
|
+
block.open_line <= line && line <= block.close_line
|
|
62
|
+
end.min_by { |block| block.close_line - block.open_line }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse(source)
|
|
66
|
+
source = source.to_s
|
|
67
|
+
lines = source.lines
|
|
68
|
+
fences = []
|
|
69
|
+
open_fences = []
|
|
70
|
+
offset = 0
|
|
71
|
+
|
|
72
|
+
lines.each_with_index do |line, line_number|
|
|
73
|
+
content = line.chomp
|
|
74
|
+
if (match = content.match(/\A\s*(`{3,}|~{3,})(.*)\z/))
|
|
75
|
+
marker = match[1][0]
|
|
76
|
+
length = match[1].length
|
|
77
|
+
rest = match[2]
|
|
78
|
+
closing = rest.match?(/\A\s*\z/)
|
|
79
|
+
|
|
80
|
+
if closing && open_fences.last&.marker == marker && length >= open_fences.last.length
|
|
81
|
+
opening = open_fences.pop
|
|
82
|
+
fences << Fence.new(
|
|
83
|
+
line: line_number,
|
|
84
|
+
start: offset,
|
|
85
|
+
finish: offset + line.length,
|
|
86
|
+
marker: marker,
|
|
87
|
+
length: length,
|
|
88
|
+
language: opening.language
|
|
89
|
+
)
|
|
90
|
+
opening.close_line = line_number
|
|
91
|
+
opening.close_start = offset
|
|
92
|
+
opening.close_end = offset + line.length
|
|
93
|
+
offset += line.length
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
next if closing
|
|
98
|
+
|
|
99
|
+
opening = Fence.new(
|
|
100
|
+
line: line_number,
|
|
101
|
+
start: offset,
|
|
102
|
+
finish: offset + line.length,
|
|
103
|
+
marker: marker,
|
|
104
|
+
length: length,
|
|
105
|
+
language: rest.strip.split(/\s+/, 2).first
|
|
106
|
+
)
|
|
107
|
+
open_fences << opening
|
|
108
|
+
fences << opening
|
|
109
|
+
end
|
|
110
|
+
offset += line.length
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
completed = fences.select { |fence| fence.close_line }
|
|
114
|
+
completed.map do |opening|
|
|
115
|
+
next_fence = fences.map(&:start).select { |start| start > opening.close_end }.min
|
|
116
|
+
Block.new(
|
|
117
|
+
source: source,
|
|
118
|
+
open_line: opening.line,
|
|
119
|
+
close_line: opening.close_line,
|
|
120
|
+
language: opening.language,
|
|
121
|
+
body_start: opening.finish,
|
|
122
|
+
body_end: opening.close_start,
|
|
123
|
+
close_end: opening.close_end,
|
|
124
|
+
next_fence_start: next_fence
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def format_output(output, newline: "\n")
|
|
130
|
+
body = output.to_s.gsub(/\r\n?/, "\n").chomp
|
|
131
|
+
body = body.gsub("\n", newline)
|
|
132
|
+
body.empty? ? "<output>#{newline}</output>" : "<output>#{newline}#{body}#{newline}</output>"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/kward/model/client.rb
CHANGED
|
@@ -37,12 +37,12 @@ module Kward
|
|
|
37
37
|
"lm_studio" => "http://127.0.0.1:1234/v1",
|
|
38
38
|
"llama_cpp" => "http://127.0.0.1:8080/v1"
|
|
39
39
|
}.freeze
|
|
40
|
-
AUTH_ERROR = "No OpenAI OAuth login found. Run `
|
|
41
|
-
OPENROUTER_AUTH_ERROR = "No OpenRouter API key found.
|
|
42
|
-
OPENAI_API_AUTH_ERROR = "No OpenAI API key found.
|
|
43
|
-
AZURE_OPENAI_AUTH_ERROR = "No Azure OpenAI API key found.
|
|
44
|
-
COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `
|
|
45
|
-
ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `
|
|
40
|
+
AUTH_ERROR = "No OpenAI OAuth login found. Run `kward login`, or set OPENAI_ACCESS_TOKEN or OPENROUTER_API_KEY."
|
|
41
|
+
OPENROUTER_AUTH_ERROR = "No OpenRouter API key found. Run `kward login openrouter`, or set OPENROUTER_API_KEY."
|
|
42
|
+
OPENAI_API_AUTH_ERROR = "No OpenAI API key found. Run `kward login`, or set OPENAI_API_KEY."
|
|
43
|
+
AZURE_OPENAI_AUTH_ERROR = "No Azure OpenAI API key found. Run `kward login`, or set AZURE_OPENAI_API_KEY."
|
|
44
|
+
COPILOT_AUTH_ERROR = "No GitHub Copilot OAuth login found. Run `kward login github`, or set COPILOT_GITHUB_TOKEN."
|
|
45
|
+
ANTHROPIC_AUTH_ERROR = "No Anthropic OAuth login found. Run `kward login anthropic`."
|
|
46
46
|
DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
|
|
47
47
|
DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
|
|
48
48
|
RETRY_DELAYS = [1, 2].freeze
|
|
@@ -61,6 +61,8 @@ module Kward
|
|
|
61
61
|
/(?:exceeded|reached).*(?:usage|quota|credit|budget|balance)/i
|
|
62
62
|
].freeze
|
|
63
63
|
|
|
64
|
+
AuthenticationError = Class.new(RuntimeError)
|
|
65
|
+
|
|
64
66
|
RequestError = Class.new(StandardError) do
|
|
65
67
|
attr_reader :provider, :code, :body
|
|
66
68
|
|
|
@@ -108,11 +110,12 @@ module Kward
|
|
|
108
110
|
@local_models = nil
|
|
109
111
|
end
|
|
110
112
|
|
|
111
|
-
def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
|
|
113
|
+
def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil, provider_required: false)
|
|
112
114
|
cancellation&.raise_if_cancelled!
|
|
113
115
|
requested_provider = provider
|
|
116
|
+
validate_required_provider!(requested_provider) if provider_required
|
|
114
117
|
url, token, resolved_provider, account_id = credentials(provider: requested_provider)
|
|
115
|
-
if token.to_s.empty? && authentication_required?(resolved_provider) && !requested_provider.to_s.empty? && resolved_provider != "OpenAI"
|
|
118
|
+
if token.to_s.empty? && authentication_required?(resolved_provider) && !provider_required && !requested_provider.to_s.empty? && resolved_provider != "OpenAI"
|
|
116
119
|
url, token, resolved_provider, account_id = credentials
|
|
117
120
|
model = nil
|
|
118
121
|
reasoning = nil
|
|
@@ -593,20 +596,22 @@ module Kward
|
|
|
593
596
|
end
|
|
594
597
|
|
|
595
598
|
def auth_error_for(provider)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
599
|
+
message =
|
|
600
|
+
case provider
|
|
601
|
+
when "Azure OpenAI"
|
|
602
|
+
AZURE_OPENAI_AUTH_ERROR
|
|
603
|
+
when "OpenAI"
|
|
604
|
+
OPENAI_API_AUTH_ERROR
|
|
605
|
+
when "OpenRouter"
|
|
606
|
+
OPENROUTER_AUTH_ERROR
|
|
607
|
+
when "Copilot"
|
|
608
|
+
COPILOT_AUTH_ERROR
|
|
609
|
+
when "Anthropic"
|
|
610
|
+
ANTHROPIC_AUTH_ERROR
|
|
611
|
+
else
|
|
612
|
+
AUTH_ERROR
|
|
613
|
+
end
|
|
614
|
+
AuthenticationError.new(message)
|
|
610
615
|
end
|
|
611
616
|
|
|
612
617
|
def with_retries(provider, model, request_bytes: nil, on_retry: nil, cancellation: nil)
|
|
@@ -906,7 +911,7 @@ module Kward
|
|
|
906
911
|
request["Content-Type"] = "application/json"
|
|
907
912
|
request["Accept"] = "text/event-stream"
|
|
908
913
|
request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
|
|
909
|
-
|
|
914
|
+
request["originator"] = "kward"
|
|
910
915
|
|
|
911
916
|
message = nil
|
|
912
917
|
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
@@ -931,22 +936,6 @@ module Kward
|
|
|
931
936
|
raise e
|
|
932
937
|
end
|
|
933
938
|
|
|
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
939
|
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
|
|
951
940
|
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
941
|
end
|
|
@@ -1079,6 +1068,13 @@ module Kward
|
|
|
1079
1068
|
ModelInfo.reasoning_effort(config: @config, provider: provider)
|
|
1080
1069
|
end
|
|
1081
1070
|
|
|
1071
|
+
def validate_required_provider!(provider)
|
|
1072
|
+
provider_id = provider.to_s.strip.downcase
|
|
1073
|
+
return if ProviderCatalog.configuration_ids.include?(provider_id)
|
|
1074
|
+
|
|
1075
|
+
raise ArgumentError, "Unknown provider: #{provider}"
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1082
1078
|
def authentication_required?(provider)
|
|
1083
1079
|
provider != "Local"
|
|
1084
1080
|
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
|
|
@@ -71,9 +71,14 @@ module Kward
|
|
|
71
71
|
Definition.new(id: "xai", name: "xAI", api_key_env: ["XAI_API_KEY"], protocol: "openai_chat", oauth_name: "xAI Grok"),
|
|
72
72
|
Definition.new(id: "copilot", name: "GitHub Copilot", api_key_env: [], protocol: "copilot", oauth_name: "GitHub Copilot")
|
|
73
73
|
].freeze
|
|
74
|
+
CONFIGURATION_IDS = (["codex", "openai_api", "local"] + DEFINITIONS.map(&:id).reject { |id| id == "openai" }).freeze
|
|
74
75
|
|
|
75
76
|
module_function
|
|
76
77
|
|
|
78
|
+
def configuration_ids
|
|
79
|
+
CONFIGURATION_IDS
|
|
80
|
+
end
|
|
81
|
+
|
|
77
82
|
def all
|
|
78
83
|
DEFINITIONS
|
|
79
84
|
end
|
|
@@ -347,7 +347,7 @@ module Kward
|
|
|
347
347
|
when "response.content_part.added"
|
|
348
348
|
codex_content_part_added(state, event["part"])
|
|
349
349
|
when "response.output_text.delta", "response.refusal.delta"
|
|
350
|
-
codex_output_text_delta(state, event["delta"], on_assistant_delta: on_assistant_delta)
|
|
350
|
+
codex_output_text_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta)
|
|
351
351
|
when "response.reasoning_summary_part.added"
|
|
352
352
|
codex_reasoning_summary_part_added(state, event["part"])
|
|
353
353
|
when "response.reasoning_summary_text.delta"
|
|
@@ -417,7 +417,7 @@ module Kward
|
|
|
417
417
|
state[:current_text_content_part] = item["content"].last
|
|
418
418
|
end
|
|
419
419
|
|
|
420
|
-
def codex_output_text_delta(state, delta, on_assistant_delta: nil)
|
|
420
|
+
def codex_output_text_delta(state, delta, on_reasoning_delta: nil, on_assistant_delta: nil)
|
|
421
421
|
text = delta.to_s
|
|
422
422
|
return if text.empty?
|
|
423
423
|
|
|
@@ -432,6 +432,9 @@ module Kward
|
|
|
432
432
|
if codex_streamable_message_item?(item)
|
|
433
433
|
on_assistant_delta&.call(text)
|
|
434
434
|
state[:emitted_message_keys] << codex_item_key(item)
|
|
435
|
+
elsif codex_commentary_message_item?(item)
|
|
436
|
+
on_reasoning_delta&.call(text)
|
|
437
|
+
state[:emitted_message_keys] << codex_item_key(item)
|
|
435
438
|
end
|
|
436
439
|
end
|
|
437
440
|
state[:raw_content] << text
|
|
@@ -647,10 +650,19 @@ module Kward
|
|
|
647
650
|
case item["type"]
|
|
648
651
|
when "message"
|
|
649
652
|
text = text_from_codex_items([item])
|
|
650
|
-
return if text.empty?
|
|
653
|
+
return if text.empty?
|
|
651
654
|
|
|
652
|
-
state[:content] << text
|
|
653
655
|
key = codex_item_key(item)
|
|
656
|
+
if codex_commentary_message_item?(item)
|
|
657
|
+
unless state[:emitted_message_keys].include?(key)
|
|
658
|
+
on_reasoning_delta&.call(text)
|
|
659
|
+
state[:emitted_message_keys] << key
|
|
660
|
+
end
|
|
661
|
+
return
|
|
662
|
+
end
|
|
663
|
+
return unless codex_streamable_message_item?(item)
|
|
664
|
+
|
|
665
|
+
state[:content] << text
|
|
654
666
|
unless state[:emitted_message_keys].include?(key)
|
|
655
667
|
on_assistant_delta&.call(text)
|
|
656
668
|
state[:emitted_message_keys] << key
|
|
@@ -774,6 +786,10 @@ module Kward
|
|
|
774
786
|
phase.empty? || phase == "final_answer"
|
|
775
787
|
end
|
|
776
788
|
|
|
789
|
+
def codex_commentary_message_item?(item)
|
|
790
|
+
codex_message_phase(item) == "commentary"
|
|
791
|
+
end
|
|
792
|
+
|
|
777
793
|
def codex_message_phase(item)
|
|
778
794
|
item["phase"].to_s
|
|
779
795
|
end
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
<button class="icon-button" id="open-sessions" type="button" aria-label="Open sessions">☰</button>
|
|
163
163
|
<div class="title-block">
|
|
164
164
|
<div class="title-row"><span class="live-dot"></span><h1 id="conversation-title">New session</h1></div>
|
|
165
|
-
<div class="top-meta" id="session-meta">
|
|
165
|
+
<div class="top-meta" id="session-meta">Authenticated HTTP workspace</div>
|
|
166
166
|
</div>
|
|
167
167
|
<button class="text-button" id="capture-skill" type="button">Capture skill</button>
|
|
168
168
|
<button class="text-button" id="rename-session" type="button">Rename</button>
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
<div class="composer-shell">
|
|
177
177
|
<form id="prompt-form">
|
|
178
178
|
<div class="composer">
|
|
179
|
-
<textarea id="prompt" name="prompt" rows="1" autocomplete="off" enterkeyhint="send" aria-label="Prompt" placeholder="
|
|
179
|
+
<textarea id="prompt" name="prompt" rows="1" autocomplete="off" enterkeyhint="send" aria-label="Prompt" placeholder="Ask <%= ERB::Util.html_escape(@assistant_label) %> to inspect, explain, or change code…"></textarea>
|
|
180
180
|
<button class="send" id="send" type="submit" aria-label="Send prompt">↑</button>
|
|
181
181
|
</div>
|
|
182
182
|
<div class="composer-meta"><span id="status">Connecting…</span><span>Return to send · Shift Return for a new line</span></div>
|
|
@@ -437,7 +437,7 @@
|
|
|
437
437
|
if (!activeSession) return;
|
|
438
438
|
titleEl.textContent = activeSession.title || activeSession.name || 'New session';
|
|
439
439
|
const details = [activeSession.model, activeSession.messageCount != null ? `${activeSession.messageCount} messages` : null].filter(Boolean);
|
|
440
|
-
sessionMeta.textContent = details.join(' · ') || '
|
|
440
|
+
sessionMeta.textContent = details.join(' · ') || 'Authenticated HTTP workspace';
|
|
441
441
|
}
|
|
442
442
|
|
|
443
443
|
async function changeSession(action, values = {}) {
|