kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -0,0 +1,114 @@
1
+ # Namespace for Kward model-provider definitions.
2
+ module Kward
3
+ # Stable, frontend-neutral descriptions of providers Kward can present to users.
4
+ # Runtime request and authentication mechanics remain in their respective clients.
5
+ module ProviderCatalog
6
+ Definition = Struct.new(
7
+ :id,
8
+ :name,
9
+ :api_key_env,
10
+ :protocol,
11
+ :oauth_name,
12
+ :requires_setup,
13
+ keyword_init: true
14
+ ) do
15
+ def api_key?
16
+ !api_key_env.empty?
17
+ end
18
+
19
+ def oauth?
20
+ !oauth_name.nil?
21
+ end
22
+ end
23
+
24
+ Runtime = Struct.new(:id, :model_config_key, :reasoning_config_key, :chat_url, :model_list_url, :model_discovery, keyword_init: true) do
25
+ def automatic_model_discovery?
26
+ model_discovery == :live
27
+ end
28
+ end
29
+
30
+ CHAT_URLS = {
31
+ "anthropic" => "https://api.anthropic.com/v1/messages",
32
+ "cerebras" => "https://api.cerebras.ai/v1/chat/completions",
33
+ "deepseek" => "https://api.deepseek.com/chat/completions",
34
+ "fireworks" => "https://api.fireworks.ai/inference/v1/chat/completions",
35
+ "gemini" => "https://generativelanguage.googleapis.com/v1beta",
36
+ "groq" => "https://api.groq.com/openai/v1/chat/completions",
37
+ "mistral" => "https://api.mistral.ai/v1/chat/completions",
38
+ "nvidia" => "https://integrate.api.nvidia.com/v1/chat/completions",
39
+ "openai" => "https://api.openai.com/v1/responses",
40
+ "openrouter" => "https://openrouter.ai/api/v1/chat/completions",
41
+ "together" => "https://api.together.xyz/v1/chat/completions",
42
+ "xai" => "https://api.x.ai/v1/chat/completions"
43
+ }.freeze
44
+ MODEL_LIST_URLS = {
45
+ "anthropic" => "https://api.anthropic.com/v1/models",
46
+ "cerebras" => "https://api.cerebras.ai/v1/models",
47
+ "deepseek" => "https://api.deepseek.com/models",
48
+ "fireworks" => "https://api.fireworks.ai/inference/v1/models",
49
+ "gemini" => "https://generativelanguage.googleapis.com/v1beta/models",
50
+ "groq" => "https://api.groq.com/openai/v1/models",
51
+ "mistral" => "https://api.mistral.ai/v1/models",
52
+ "nvidia" => "https://integrate.api.nvidia.com/v1/models",
53
+ "openai" => "https://api.openai.com/v1/models",
54
+ "together" => "https://api.together.xyz/v1/models",
55
+ "xai" => "https://api.x.ai/v1/models"
56
+ }.freeze
57
+
58
+ DEFINITIONS = [
59
+ Definition.new(id: "anthropic", name: "Anthropic", api_key_env: ["ANTHROPIC_API_KEY"], protocol: "anthropic_messages", oauth_name: "Anthropic Claude"),
60
+ Definition.new(id: "azure_openai", name: "Azure OpenAI", api_key_env: ["AZURE_OPENAI_API_KEY"], protocol: "azure_openai", requires_setup: true),
61
+ Definition.new(id: "cerebras", name: "Cerebras", api_key_env: ["CEREBRAS_API_KEY"], protocol: "openai_chat"),
62
+ Definition.new(id: "deepseek", name: "DeepSeek", api_key_env: ["DEEPSEEK_API_KEY"], protocol: "openai_chat"),
63
+ Definition.new(id: "fireworks", name: "Fireworks AI", api_key_env: ["FIREWORKS_API_KEY"], protocol: "openai_chat"),
64
+ Definition.new(id: "gemini", name: "Google Gemini", api_key_env: ["GEMINI_API_KEY"], protocol: "gemini"),
65
+ Definition.new(id: "groq", name: "Groq", api_key_env: ["GROQ_API_KEY"], protocol: "openai_chat"),
66
+ Definition.new(id: "mistral", name: "Mistral", api_key_env: ["MISTRAL_API_KEY"], protocol: "openai_chat"),
67
+ Definition.new(id: "nvidia", name: "NVIDIA NIM", api_key_env: ["NVIDIA_API_KEY", "NGC_API_KEY"], protocol: "openai_chat"),
68
+ Definition.new(id: "openai", name: "OpenAI", api_key_env: ["OPENAI_API_KEY"], protocol: "openai_responses", oauth_name: "ChatGPT"),
69
+ Definition.new(id: "openrouter", name: "OpenRouter", api_key_env: ["OPENROUTER_API_KEY"], protocol: "openai_chat", oauth_name: "OpenRouter"),
70
+ Definition.new(id: "together", name: "Together AI", api_key_env: ["TOGETHER_API_KEY"], protocol: "openai_chat"),
71
+ Definition.new(id: "xai", name: "xAI", api_key_env: ["XAI_API_KEY"], protocol: "openai_chat", oauth_name: "xAI Grok"),
72
+ Definition.new(id: "copilot", name: "GitHub Copilot", api_key_env: [], protocol: "copilot", oauth_name: "GitHub Copilot")
73
+ ].freeze
74
+
75
+ module_function
76
+
77
+ def all
78
+ DEFINITIONS
79
+ end
80
+
81
+ def fetch(id)
82
+ find(id) || raise(ArgumentError, "Unknown provider: #{id}")
83
+ end
84
+
85
+ def find(id)
86
+ DEFINITIONS.find { |provider| provider.id == id.to_s }
87
+ end
88
+
89
+ def find_by_name(name)
90
+ DEFINITIONS.find { |provider| provider.name.casecmp?(name.to_s) }
91
+ end
92
+
93
+ def runtime(id)
94
+ provider = fetch(id)
95
+ runtime_id = provider.id == "openai" ? "openai_api" : provider.id
96
+ Runtime.new(
97
+ id: runtime_id,
98
+ model_config_key: "#{runtime_id}_model",
99
+ reasoning_config_key: "#{runtime_id}_reasoning_effort",
100
+ chat_url: CHAT_URLS[provider.id],
101
+ model_list_url: MODEL_LIST_URLS[provider.id],
102
+ model_discovery: provider.id == "azure_openai" ? :configured : (MODEL_LIST_URLS.key?(provider.id) ? :live : :curated)
103
+ )
104
+ end
105
+
106
+ def api_key_providers
107
+ DEFINITIONS.select(&:api_key?)
108
+ end
109
+
110
+ def oauth_providers
111
+ DEFINITIONS.select(&:oauth?)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,70 @@
1
+ require_relative "catalog"
2
+ require_relative "model_info"
3
+ require_relative "provider_catalog"
4
+
5
+ # Namespace for Kward model catalogs.
6
+ module Kward
7
+ # Combines live, cached, and curated provider models for selection UIs.
8
+ class ModelSources
9
+ CURATED_MODELS = {
10
+ "anthropic" => %w[claude-sonnet-5 claude-opus-4-8 claude-haiku-4-5],
11
+ "azure_openai" => [],
12
+ "cerebras" => [],
13
+ "deepseek" => %w[deepseek-chat deepseek-reasoner],
14
+ "fireworks" => [],
15
+ "gemini" => %w[gemini-2.5-pro gemini-2.5-flash],
16
+ "groq" => [],
17
+ "mistral" => %w[mistral-large-latest],
18
+ "nvidia" => [],
19
+ "openai" => ModelInfo::OPENAI_MODEL_CHOICES,
20
+ "openrouter" => [],
21
+ "together" => [],
22
+ "xai" => %w[grok-3 grok-3-mini],
23
+ "copilot" => ModelInfo::COPILOT_MODEL_CHOICES
24
+ }.freeze
25
+
26
+ def initialize(provider_id:, api_key: nil, catalog: nil)
27
+ @provider = ProviderCatalog.fetch(provider_id)
28
+ @catalog = catalog || ModelCatalog.new(provider_id: @provider.id, api_key: api_key)
29
+ end
30
+
31
+ def models
32
+ merge(cached_models, curated_models)
33
+ end
34
+
35
+ def refresh
36
+ merge(live_models, curated_models)
37
+ rescue StandardError
38
+ models
39
+ end
40
+
41
+ def manual(id)
42
+ value = id.to_s.strip
43
+ raise ArgumentError, "Model ID must be a non-empty string" if value.empty?
44
+
45
+ { "provider" => @provider.id, "id" => value, "name" => value, "source" => "manual" }
46
+ end
47
+
48
+ private
49
+
50
+ def live_models
51
+ @catalog.refresh.map { |model| model.merge("source" => "live") }
52
+ end
53
+
54
+ def cached_models
55
+ @catalog.models.map { |model| model.merge("source" => "cached") }
56
+ end
57
+
58
+ def curated_models
59
+ CURATED_MODELS.fetch(@provider.id, []).map do |id|
60
+ { "provider" => @provider.id, "id" => id, "name" => id, "source" => "curated" }
61
+ end
62
+ end
63
+
64
+ def merge(*groups)
65
+ groups.flatten.each_with_object({}) do |model, result|
66
+ result[model.fetch("id")] ||= model
67
+ end.values.sort_by { |model| model.fetch("id") }
68
+ end
69
+ end
70
+ end
@@ -77,15 +77,15 @@ module Kward
77
77
  result
78
78
  end
79
79
 
80
- def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
80
+ def parse_codex_sse(body, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
81
81
  state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
82
82
  body.split(/\r?\n\r?\n/).each do |block|
83
- process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
83
+ process_codex_sse_block(block, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
84
84
  end
85
85
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
86
86
  codex_sse_message(state)
87
87
  rescue JSON::ParserError => e
88
- raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
88
+ raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
89
89
  end
90
90
 
91
91
  # Incrementally parses a Codex/Responses SSE HTTP response body.
@@ -93,7 +93,7 @@ module Kward
93
93
  # Deltas are yielded as soon as complete SSE blocks arrive so interactive
94
94
  # frontends can render streamed assistant and reasoning text without waiting
95
95
  # for the provider to close the response.
96
- def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
96
+ def parse_codex_sse_stream(response, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
97
97
  state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
98
98
  buffer = +""
99
99
 
@@ -104,15 +104,15 @@ module Kward
104
104
  delimiter = Regexp.last_match[0]
105
105
  block = buffer[0...index]
106
106
  buffer = buffer[(index + delimiter.length)..] || +""
107
- process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
107
+ process_codex_sse_block(block, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
108
108
  end
109
109
  end
110
110
  cancellation&.raise_if_cancelled!
111
- process_codex_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
111
+ process_codex_sse_block(buffer, state, provider_label: provider_label, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
112
112
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
113
113
  codex_sse_message(state)
114
114
  rescue JSON::ParserError => e
115
- raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
115
+ raise "#{provider_label} returned invalid SSE JSON: #{e.message}"
116
116
  end
117
117
 
118
118
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
@@ -135,6 +135,71 @@ module Kward
135
135
  raise "Anthropic returned invalid SSE JSON: #{e.message}"
136
136
  end
137
137
 
138
+ def parse_gemini_sse_stream(response, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil)
139
+ state = { content: +"", calls: {}, usage: nil }
140
+ buffer = +""
141
+ response.read_body do |chunk|
142
+ cancellation&.raise_if_cancelled!
143
+ buffer << chunk
144
+ while (index = buffer.index(/\r?\n\r?\n/))
145
+ delimiter = Regexp.last_match[0]
146
+ block = buffer[0...index]
147
+ buffer = buffer[(index + delimiter.length)..] || +""
148
+ process_gemini_sse_block(block, state, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: usage_normalizer)
149
+ end
150
+ end
151
+ cancellation&.raise_if_cancelled!
152
+ process_gemini_sse_block(buffer, state, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: usage_normalizer) unless buffer.empty?
153
+ gemini_sse_message(state)
154
+ rescue JSON::ParserError => e
155
+ raise "Google Gemini returned invalid SSE JSON: #{e.message}"
156
+ end
157
+
158
+ def process_gemini_sse_block(block, state, on_assistant_delta:, cancellation:, usage_normalizer:)
159
+ data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
160
+ return if data.empty? || data == "[DONE]"
161
+
162
+ cancellation&.raise_if_cancelled!
163
+ event = JSON.parse(data)
164
+ Array(event["candidates"]).each_with_index do |candidate, candidate_index|
165
+ Array(candidate.dig("content", "parts")).each_with_index do |part, part_index|
166
+ text = part["text"].to_s
167
+ unless text.empty?
168
+ state[:content] << text
169
+ on_assistant_delta&.call(text)
170
+ end
171
+ merge_gemini_function_call(state[:calls], part["functionCall"], candidate_index: candidate_index, part_index: part_index)
172
+ end
173
+ end
174
+ state[:usage] = usage_normalizer&.call(event["usageMetadata"]) || state[:usage]
175
+ end
176
+
177
+ def merge_gemini_function_call(calls, function_call, candidate_index:, part_index:)
178
+ return unless function_call.is_a?(Hash)
179
+
180
+ key = function_call["id"] || "#{candidate_index}:#{part_index}:#{function_call["name"]}"
181
+ call = calls[key] ||= { "id" => function_call["id"] || "call_gemini_#{candidate_index}_#{part_index}", "name" => function_call["name"].to_s, "args" => {}, "args_buffer" => +"" }
182
+ call["name"] = function_call["name"].to_s unless function_call["name"].to_s.empty?
183
+ args = function_call["args"]
184
+ if args.is_a?(Hash)
185
+ call["args"].merge!(args)
186
+ elsif !args.nil?
187
+ call["args_buffer"] << args.to_s
188
+ end
189
+ end
190
+
191
+ def gemini_sse_message(state)
192
+ message = { "role" => "assistant", "content" => state[:content] }
193
+ unless state[:calls].empty?
194
+ message["tool_calls"] = state[:calls].values.map do |call|
195
+ arguments = call["args_buffer"].empty? ? JSON.dump(call["args"]) : call["args_buffer"]
196
+ { "id" => call["id"], "type" => "function", "function" => { "name" => call["name"], "arguments" => arguments } }
197
+ end
198
+ end
199
+ message["usage"] = state[:usage] if state[:usage]
200
+ message
201
+ end
202
+
138
203
  def anthropic_sse_state
139
204
  { content: +"", reasoning_summary: +"", blocks: {}, tool_calls: [], usage: nil }
140
205
  end
@@ -233,7 +298,7 @@ module Kward
233
298
 
234
299
  def merge_streaming_tool_call(tool_calls, delta)
235
300
  index = (delta["index"] || tool_calls.length).to_i
236
- tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
301
+ tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => +"", "arguments" => +"" } }
237
302
  current = tool_calls[index]
238
303
  current["id"] = delta["id"] if delta["id"]
239
304
  current["type"] = delta["type"] if delta["type"]
@@ -271,7 +336,7 @@ module Kward
271
336
  }
272
337
  end
273
338
 
274
- def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
339
+ def process_codex_sse_block(block, state, provider_label: "Codex", on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
275
340
  data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
276
341
  return if data.empty? || data == "[DONE]"
277
342
 
@@ -309,11 +374,11 @@ module Kward
309
374
  end
310
375
  end
311
376
  when "response.failed", "response.incomplete"
312
- raise codex_sse_error(event, request_error_class: request_error_class)
377
+ raise codex_sse_error(event, provider_label: provider_label, request_error_class: request_error_class)
313
378
  end
314
379
  end
315
380
 
316
- def codex_sse_error(event, request_error_class: nil)
381
+ def codex_sse_error(event, provider_label: "Codex", request_error_class: nil)
317
382
  response = event["response"]
318
383
  error = event["error"] || (response["error"] if response.is_a?(Hash)) || {}
319
384
  message = if error.is_a?(Hash)
@@ -324,7 +389,7 @@ module Kward
324
389
  message = event["type"].to_s if message.empty?
325
390
  code = error.is_a?(Hash) && error["code"].to_s == "server_error" ? 500 : 400
326
391
  if request_error_class
327
- request_error_class.new(provider: "Codex", code: code, body: "#{event["type"]}: #{message}")
392
+ request_error_class.new(provider: provider_label, code: code, body: "#{event["type"]}: #{message}")
328
393
  else
329
394
  "#{event["type"]}: #{message}"
330
395
  end
@@ -364,6 +429,10 @@ module Kward
364
429
  part["type"] ||= "output_text"
365
430
  text_key = part["type"] == "refusal" ? "refusal" : "text"
366
431
  part[text_key] = part[text_key].to_s + text
432
+ if codex_streamable_message_item?(item)
433
+ on_assistant_delta&.call(text)
434
+ state[:emitted_message_keys] << codex_item_key(item)
435
+ end
367
436
  end
368
437
  state[:raw_content] << text
369
438
  end
@@ -701,7 +770,8 @@ module Kward
701
770
  end
702
771
 
703
772
  def codex_streamable_message_item?(item)
704
- codex_message_phase(item) == "final_answer"
773
+ phase = codex_message_phase(item)
774
+ phase.empty? || phase == "final_answer"
705
775
  end
706
776
 
707
777
  def codex_message_phase(item)
@@ -13,6 +13,7 @@ module Kward
13
13
  MODES = %w[ask read-only workspace-write deny-by-default].freeze
14
14
  NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
15
15
  FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
16
+ MUTATING_TOOLS = (FILE_CHANGE_TOOLS + ["git_commit"]).freeze
16
17
 
17
18
  Decision = Struct.new(:action, :reason, keyword_init: true) do
18
19
  def allowed?
@@ -96,13 +97,15 @@ module Kward
96
97
  def request_for(tool_name, arguments, source:)
97
98
  name = tool_name.to_s
98
99
  args = arguments.to_h
99
- {
100
+ request = {
100
101
  "tool" => name,
101
102
  "path" => args["path"] || args[:path],
102
103
  "host" => request_host(name, args),
103
104
  "command" => args["command"] || args[:command],
104
105
  "source" => source.to_s
105
106
  }.compact.transform_values(&:to_s)
107
+ request["paths"] = args["paths"] || args[:paths] if name == "git_commit"
108
+ request
106
109
  end
107
110
 
108
111
  def request_host(name, arguments)
@@ -135,11 +138,11 @@ module Kward
135
138
  return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
136
139
  return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
137
140
 
138
- if file_change?(request) && outside_write_scopes?(request)
141
+ if mutating?(request) && outside_write_scopes?(request)
139
142
  return Decision.new(action: :deny, reason: "path outside write scopes")
140
143
  end
141
144
 
142
- if @mode == "workspace-write" && file_change?(request)
145
+ if @mode == "workspace-write" && mutating?(request)
143
146
  return Decision.new(action: :allow, reason: "workspace write mode")
144
147
  end
145
148
 
@@ -152,17 +155,28 @@ module Kward
152
155
  FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
153
156
  end
154
157
 
158
+ def mutating?(request)
159
+ MUTATING_TOOLS.include?(request.fetch("tool"))
160
+ end
161
+
155
162
  def network?(request)
156
163
  NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
157
164
  end
158
165
 
159
166
  def risky?(request)
160
- file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
167
+ mutating?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
161
168
  end
162
169
 
163
170
  def outside_write_scopes?(request)
164
171
  return false if @write_scopes.nil?
165
172
 
173
+ if request.fetch("tool") == "git_commit"
174
+ paths = request["paths"]
175
+ return true unless paths.is_a?(Array) && !paths.empty?
176
+
177
+ return paths.any? { |path| !@write_scopes.any? { |scope| glob_match?(scope, path) } }
178
+ end
179
+
166
180
  path = request["path"]
167
181
  path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
168
182
  end