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
@@ -58,32 +58,33 @@ module Kward
58
58
  # @return [PluginRegistry, nil] registry used to collect plugin prompt context
59
59
  attr_accessor :plugin_registry
60
60
  # @return [String, nil] plugin prompt context used in the current system prompt
61
- attr_reader :last_plugin_prompt_context
61
+ attr_reader :last_plugin_prompt_context, :execution_profile_context
62
62
  # @return [Hash] original large tool outputs retained outside model context
63
63
  attr_reader :tool_output_artifacts
64
64
  # @return [ContextBudgetMeter] runtime context savings for this conversation
65
65
  attr_reader :context_budget_meter
66
66
 
67
- def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
67
+ def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil)
68
68
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
69
69
  @provider = provider
70
70
  @model = model
71
71
  @reasoning_effort = reasoning_effort
72
72
  @plugin_registry = plugin_registry
73
+ @execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
73
74
  @messages = []
74
75
  restored_system_message, transcript_messages = split_system_message(messages)
75
76
  if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
76
- if restored_system_message
77
+ if restored_system_message && @execution_profile_context.to_s.empty?
77
78
  system_message = restored_system_message
78
79
  else
79
80
  @last_plugin_prompt_context = plugin_prompt_context
80
- system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
81
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
81
82
  end
82
83
  end
83
84
  @system_message = system_message
84
85
  @system_message_enabled = !@system_message.nil?
85
86
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
86
- compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time) : nil
87
+ compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context) : nil
87
88
  end
88
89
  @compaction_system_message = compaction_system_message
89
90
  @workspace_agents_mtime = workspace_agents_mtime
@@ -187,15 +188,20 @@ module Kward
187
188
  return nil unless @system_message_enabled
188
189
 
189
190
  @last_plugin_prompt_context = plugin_prompt_context
190
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
191
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
191
192
  @system_message = replacement
192
193
  @on_system_message_change&.call(replacement)
193
- @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
194
+ @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context)
194
195
  @workspace_agents_mtime = workspace_agents_mtime
195
196
  @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
196
197
  replacement
197
198
  end
198
199
 
200
+ def update_execution_profile_context!(context)
201
+ @execution_profile_context = context.to_s.empty? ? nil : context.to_s.freeze
202
+ refresh_system_message!
203
+ end
204
+
199
205
  def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
200
206
  @provider = provider unless provider.to_s.empty?
201
207
  @model = model
@@ -0,0 +1,291 @@
1
+ require "fileutils"
2
+ require "open3"
3
+ require "pathname"
4
+
5
+ require_relative "path_guard"
6
+
7
+ # Namespace for the Kward CLI agent runtime.
8
+ module Kward
9
+ # Durable association between a session tab and a linked Git worktree.
10
+ class GitWorktreeBinding
11
+ FIELDS = %i[repository_root origin_root path branch base_revision active owned].freeze
12
+
13
+ attr_accessor :repository_root, :origin_root, :path, :branch, :base_revision, :active, :owned
14
+
15
+ def initialize(repository_root:, origin_root:, path:, branch:, base_revision:, active: true, owned: true)
16
+ @repository_root = repository_root.to_s
17
+ @origin_root = origin_root.to_s
18
+ @path = path.to_s
19
+ @branch = branch.to_s
20
+ @base_revision = base_revision.to_s
21
+ @active = active != false
22
+ @owned = owned != false
23
+ end
24
+
25
+ def active?
26
+ active == true
27
+ end
28
+
29
+ def descriptor
30
+ {
31
+ "repository_root" => repository_root,
32
+ "origin_root" => origin_root,
33
+ "path" => path,
34
+ "branch" => branch,
35
+ "base_revision" => base_revision,
36
+ "active" => active?,
37
+ "owned" => owned != false
38
+ }
39
+ end
40
+
41
+ def self.from_descriptor(value)
42
+ data = value.respond_to?(:transform_keys) ? value.transform_keys(&:to_s) : {}
43
+ required = %w[repository_root origin_root path branch base_revision]
44
+ return nil unless required.all? { |key| !data[key].to_s.empty? }
45
+
46
+ new(
47
+ repository_root: data["repository_root"],
48
+ origin_root: data["origin_root"],
49
+ path: data["path"],
50
+ branch: data["branch"],
51
+ base_revision: data["base_revision"],
52
+ active: data.fetch("active", true),
53
+ owned: data.fetch("owned", true)
54
+ )
55
+ end
56
+ end
57
+
58
+ # Performs safe, structured operations on linked Git worktrees.
59
+ class GitWorktreeManager
60
+ class Error < StandardError; end
61
+
62
+ WorktreeInfo = Struct.new(
63
+ :path,
64
+ :head,
65
+ :branch,
66
+ :detached,
67
+ :locked,
68
+ :prunable,
69
+ keyword_init: true
70
+ )
71
+
72
+ GitStatus = Struct.new(:entries, keyword_init: true) do
73
+ def clean?
74
+ entries.empty?
75
+ end
76
+
77
+ def dirty?
78
+ !clean?
79
+ end
80
+ end
81
+
82
+ MergeResult = Struct.new(:status, :output, :conflicts, keyword_init: true) do
83
+ def merged?
84
+ status == :merged
85
+ end
86
+
87
+ def conflicted?
88
+ status == :conflicted
89
+ end
90
+ end
91
+
92
+ def repository_root(path)
93
+ output = run_git(path, "rev-parse", "--show-toplevel")
94
+ canonical_existing_path(output.strip)
95
+ rescue Error
96
+ raise Error, "Not a Git repository: #{path}"
97
+ end
98
+
99
+ def status(path)
100
+ output = run_git(path, "status", "--porcelain=v1", "--untracked-files=all")
101
+ GitStatus.new(entries: output.lines(chomp: true))
102
+ end
103
+
104
+ def current_revision(path)
105
+ run_git(path, "rev-parse", "HEAD").strip
106
+ end
107
+
108
+ def current_branch(path)
109
+ run_git(path, "branch", "--show-current").strip
110
+ end
111
+
112
+ def merge(repository_root:, target_path:, source_branch:)
113
+ repository_root = canonical_existing_path(repository_root)
114
+ target_path = canonical_existing_path(target_path)
115
+ source_branch = source_branch.to_s.strip
116
+ validate_merge!(repository_root, target_path, source_branch)
117
+
118
+ output, status = capture_git(target_path, "merge", "--no-edit", source_branch)
119
+ return MergeResult.new(status: :merged, output: output, conflicts: []) if status.success?
120
+
121
+ conflicts = unmerged_paths(target_path)
122
+ return MergeResult.new(status: :conflicted, output: output, conflicts: conflicts) if merge_in_progress?(target_path)
123
+
124
+ raise Error, git_error("merge", output)
125
+ end
126
+
127
+ def merge_in_progress?(path)
128
+ _output, status = capture_git(path, "rev-parse", "-q", "--verify", "MERGE_HEAD")
129
+ status.success?
130
+ end
131
+
132
+ def unmerged_paths(path)
133
+ run_git(path, "diff", "--name-only", "--diff-filter=U").lines(chomp: true)
134
+ end
135
+
136
+ def abort_merge(path)
137
+ raise Error, "No merge is in progress." unless merge_in_progress?(path)
138
+
139
+ run_git(path, "merge", "--abort")
140
+ true
141
+ end
142
+
143
+ def create(repository_root:, origin_root:, path:, branch:, base: "HEAD")
144
+ repository_root = canonical_existing_path(repository_root)
145
+ origin_root = canonical_existing_path(origin_root)
146
+ path = planned_path(path)
147
+ branch = branch.to_s.strip
148
+ base = base.to_s.strip
149
+ validate_creation!(repository_root, origin_root, path, branch, base)
150
+
151
+ run_git(repository_root, "worktree", "add", "-b", branch, path, base)
152
+ GitWorktreeBinding.new(
153
+ repository_root: repository_root,
154
+ origin_root: origin_root,
155
+ path: path,
156
+ branch: branch,
157
+ base_revision: revision_for(repository_root, base),
158
+ active: true,
159
+ owned: true
160
+ )
161
+ end
162
+
163
+ def inspect(repository_root:, path:)
164
+ repository_root = canonical_existing_path(repository_root)
165
+ expected_path = planned_path(path)
166
+ worktree = list(repository_root).find { |entry| same_path?(entry.path, expected_path) }
167
+ raise Error, "Git worktree is not registered: #{expected_path}" unless worktree
168
+
169
+ worktree
170
+ end
171
+
172
+ def list(repository_root)
173
+ output = run_git(repository_root, "worktree", "list", "--porcelain")
174
+ parse_worktree_list(output)
175
+ end
176
+
177
+ def remove(repository_root:, path:, force: false)
178
+ repository_root = canonical_existing_path(repository_root)
179
+ expected_path = planned_path(path)
180
+ inspect(repository_root: repository_root, path: expected_path)
181
+ arguments = ["worktree", "remove"]
182
+ arguments << "--force" if force
183
+ arguments << expected_path
184
+ run_git(repository_root, *arguments)
185
+ true
186
+ end
187
+
188
+ private
189
+
190
+ def validate_merge!(repository_root, target_path, source_branch)
191
+ raise Error, "Merge target must be inside the repository: #{target_path}" unless PathGuard.inside?(target_path, repository_root)
192
+ raise Error, "Source branch is required" if source_branch.empty?
193
+ raise Error, "Invalid source branch: #{source_branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", source_branch)
194
+ raise Error, "Source branch does not exist: #{source_branch}" unless valid_revision?(repository_root, source_branch)
195
+ raise Error, "A merge is already in progress: #{target_path}" if merge_in_progress?(target_path)
196
+ end
197
+
198
+ def validate_creation!(repository_root, origin_root, path, branch, base)
199
+ raise Error, "Worktree path must be outside the repository: #{path}" if PathGuard.inside?(path, repository_root)
200
+ raise Error, "Worktree origin must be inside the repository: #{origin_root}" unless PathGuard.inside?(origin_root, repository_root)
201
+ raise Error, "Branch name is required" if branch.empty?
202
+ raise Error, "Base revision is required" if base.empty?
203
+ raise Error, "Invalid branch name: #{branch}" unless git_succeeds?(repository_root, "check-ref-format", "--branch", branch)
204
+ raise Error, "Invalid base revision: #{base}" unless valid_revision?(repository_root, base)
205
+ raise Error, "Worktree path is a file: #{path}" if File.file?(path)
206
+ raise Error, "Worktree path is not empty: #{path}" if File.directory?(path) && !Dir.empty?(path)
207
+ raise Error, "Worktree path is already registered: #{path}" if list(repository_root).any? { |entry| same_path?(entry.path, path) }
208
+
209
+ FileUtils.mkdir_p(File.dirname(path))
210
+ end
211
+
212
+ def valid_revision?(repository_root, revision)
213
+ return false if revision.start_with?("-") || revision.match?(/\s/)
214
+
215
+ git_succeeds?(repository_root, "rev-parse", "--verify", "#{revision}^{commit}")
216
+ end
217
+
218
+ def revision_for(repository_root, revision)
219
+ run_git(repository_root, "rev-parse", "--verify", "#{revision}^{commit}").strip
220
+ end
221
+
222
+ def parse_worktree_list(output)
223
+ output.split(/\n\n+/).filter_map do |block|
224
+ fields = block.lines(chomp: true).each_with_object({}) do |line, values|
225
+ key, value = line.split(" ", 2)
226
+ values[key] = value.to_s unless key.nil? || key.empty?
227
+ end
228
+ path = fields["worktree"]
229
+ next if path.to_s.empty?
230
+
231
+ branch = fields["branch"].to_s.delete_prefix("refs/heads/")
232
+ WorktreeInfo.new(
233
+ path: path,
234
+ head: fields["HEAD"].to_s,
235
+ branch: branch.empty? ? nil : branch,
236
+ detached: fields.key?("detached"),
237
+ locked: fields.key?("locked"),
238
+ prunable: fields.key?("prunable")
239
+ )
240
+ end
241
+ end
242
+
243
+ def run_git(root, *arguments)
244
+ output, status = capture_git(root, *arguments)
245
+ return output if status.success?
246
+
247
+ raise Error, git_error(arguments.join(" "), output)
248
+ end
249
+
250
+ def capture_git(root, *arguments)
251
+ Open3.capture2e("git", "-C", root.to_s, *arguments)
252
+ rescue Errno::ENOENT => e
253
+ raise Error, e.message
254
+ end
255
+
256
+ def git_error(command, output)
257
+ message = output.to_s.strip
258
+ message = "git #{command} failed" if message.empty?
259
+ message
260
+ end
261
+
262
+ def git_succeeds?(root, *arguments)
263
+ _output, status = Open3.capture2e("git", "-C", root.to_s, *arguments)
264
+ status.success?
265
+ rescue Errno::ENOENT
266
+ false
267
+ end
268
+
269
+ def canonical_existing_path(path)
270
+ File.realpath(path.to_s)
271
+ rescue Errno::ENOENT, Errno::ENOTDIR
272
+ raise Error, "Path does not exist: #{path}"
273
+ end
274
+
275
+ def planned_path(path)
276
+ expanded = Pathname.new(path.to_s).expand_path
277
+ return File.realpath(expanded.to_s) if File.exist?(expanded.to_s)
278
+
279
+ ancestor = expanded
280
+ ancestor = ancestor.parent until File.exist?(ancestor.to_s) || ancestor == ancestor.parent
281
+ canonical_ancestor = Pathname.new(File.realpath(ancestor.to_s))
282
+ canonical_ancestor.join(expanded.relative_path_from(ancestor)).to_s
283
+ rescue ArgumentError, Errno::ENOENT, Errno::ENOTDIR
284
+ raise Error, "Invalid worktree path: #{path}"
285
+ end
286
+
287
+ def same_path?(left, right)
288
+ planned_path(left) == planned_path(right)
289
+ end
290
+ end
291
+ end
@@ -11,13 +11,14 @@ module Kward
11
11
  class AuditLog
12
12
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
13
13
 
14
- def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
14
+ def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
15
15
  @path = path
16
16
  @config_path = config_path
17
17
  @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
18
18
  @clock = clock
19
19
  @monotonic_clock = monotonic_clock
20
20
  @error_output = error_output
21
+ @warning_sink = warning_sink
21
22
  @mutex = Mutex.new
22
23
  @warned = false
23
24
  end
@@ -112,7 +113,9 @@ module Kward
112
113
  return if @warned
113
114
 
114
115
  @warned = true
115
- @error_output&.puts("Warning: hook audit logging failed: #{error.message}")
116
+ message = "Warning: hook audit logging failed: #{error.message}"
117
+ sink = @warning_sink || ConfigFiles.warning_sink
118
+ sink ? sink.call(message) : @error_output&.puts(message)
116
119
  rescue StandardError
117
120
  nil
118
121
  end
@@ -0,0 +1,54 @@
1
+ require "uri"
2
+
3
+ # Namespace for Kward model-provider support.
4
+ module Kward
5
+ # Validated Azure OpenAI endpoint and deployment settings.
6
+ class AzureOpenAIConfig
7
+ DEPLOYMENT_PATTERN = /\A[a-zA-Z0-9._-]+\z/
8
+ API_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
9
+
10
+ def initialize(endpoint:, deployment:, api_version:)
11
+ @endpoint = normalize_endpoint(endpoint)
12
+ @deployment = validate_value(deployment, "deployment name", DEPLOYMENT_PATTERN)
13
+ @api_version = validate_value(api_version, "API version", API_VERSION_PATTERN)
14
+ end
15
+
16
+ attr_reader :endpoint, :deployment, :api_version
17
+
18
+ def chat_completions_url
19
+ URI("#{endpoint}/openai/deployments/#{deployment}/chat/completions?api-version=#{api_version}")
20
+ end
21
+
22
+ def to_config
23
+ {
24
+ "azure_openai_endpoint" => endpoint,
25
+ "azure_openai_model" => deployment,
26
+ "azure_openai_api_version" => api_version
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def normalize_endpoint(value)
33
+ text = value.to_s.strip
34
+ raise ArgumentError, "Azure OpenAI endpoint must be a non-empty HTTPS URL" if text.empty?
35
+
36
+ uri = URI.parse(text)
37
+ unless uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? && uri.user.nil? && uri.password.nil? && uri.query.nil? && uri.fragment.nil?
38
+ raise ArgumentError, "Azure OpenAI endpoint must be an HTTPS URL without credentials, query, or fragment"
39
+ end
40
+
41
+ uri.to_s.sub(%r{/+\z}, "")
42
+ rescue URI::InvalidURIError
43
+ raise ArgumentError, "Azure OpenAI endpoint must be a valid HTTPS URL"
44
+ end
45
+
46
+ def validate_value(value, label, pattern)
47
+ text = value.to_s.strip
48
+ raise ArgumentError, "Azure OpenAI #{label} must be a non-empty string" if text.empty?
49
+ raise ArgumentError, "Azure OpenAI #{label} contains invalid characters" unless text.match?(pattern)
50
+
51
+ text
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,113 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "time"
4
+ require "uri"
5
+ require_relative "../config_files"
6
+ require_relative "../http"
7
+ require_relative "../private_file"
8
+ require_relative "provider_catalog"
9
+
10
+ # Namespace for Kward model catalogs.
11
+ module Kward
12
+ # Fetches, normalizes, and caches provider model lists without storing secrets.
13
+ class ModelCatalog
14
+ VERSION = 1
15
+ attr_reader :path
16
+
17
+ def initialize(provider_id:, api_key:, path: nil, requester: nil)
18
+ @provider = ProviderCatalog.fetch(provider_id)
19
+ @api_key = api_key.to_s
20
+ @path = File.expand_path(path || ConfigFiles.model_catalog_cache_path(@provider.id))
21
+ @requester = requester
22
+ end
23
+
24
+ def refresh
25
+ raise "No #{@provider.name} API key found" if @api_key.empty?
26
+ raise "#{@provider.name} does not support automatic model discovery" unless runtime.automatic_model_discovery?
27
+
28
+ data = cache_data(normalize(response_body))
29
+ PrivateFile.write_json(path, data)
30
+ data.fetch("models")
31
+ end
32
+
33
+ def models
34
+ Array(read&.fetch("models", []))
35
+ end
36
+
37
+ def read
38
+ return nil unless File.exist?(path)
39
+
40
+ data = JSON.parse(File.read(path))
41
+ return nil unless data.is_a?(Hash) && data["version"] == VERSION && data["provider"] == @provider.id
42
+
43
+ data
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def response_body
51
+ return @requester.call(url, headers) if @requester
52
+
53
+ response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) do |http|
54
+ http.request(Http.apply_user_agent(Net::HTTP::Get.new(url)).tap do |request|
55
+ headers.each { |name, value| request[name] = value }
56
+ end)
57
+ end
58
+ raise "#{@provider.name} model refresh failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
59
+
60
+ response.body
61
+ end
62
+
63
+ def url
64
+ base = runtime.model_list_url
65
+ return URI("#{base}?key=#{URI.encode_www_form_component(@api_key)}") if @provider.id == "gemini"
66
+
67
+ URI(base)
68
+ end
69
+
70
+ def runtime
71
+ ProviderCatalog.runtime(@provider.id)
72
+ end
73
+
74
+ def headers
75
+ return { "x-api-key" => @api_key, "anthropic-version" => "2023-06-01" } if @provider.id == "anthropic"
76
+ return {} if @provider.id == "gemini"
77
+
78
+ { "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json" }
79
+ end
80
+
81
+ def normalize(body)
82
+ payload = JSON.parse(body.to_s)
83
+ entries = payload["data"] || payload["models"] || []
84
+ Array(entries).filter_map { |entry| normalize_entry(entry) }.sort_by { |entry| entry["id"] }
85
+ rescue JSON::ParserError
86
+ raise "#{@provider.name} model refresh returned invalid JSON"
87
+ end
88
+
89
+ def normalize_entry(entry)
90
+ return unless entry.is_a?(Hash)
91
+
92
+ id = (entry["id"] || entry["name"]).to_s.delete_prefix("models/")
93
+ return if id.empty?
94
+
95
+ {
96
+ "provider" => @provider.id,
97
+ "id" => id,
98
+ "name" => entry["display_name"] || entry["displayName"] || entry["name"] || id,
99
+ "contextWindow" => entry["context_window"] || entry["context_length"] || entry["inputTokenLimit"],
100
+ "supportedParameters" => Array(entry["supported_parameters"] || entry["supportedGenerationMethods"])
101
+ }.compact
102
+ end
103
+
104
+ def cache_data(models)
105
+ {
106
+ "version" => VERSION,
107
+ "provider" => @provider.id,
108
+ "refreshed_at" => Time.now.utc.iso8601,
109
+ "models" => models
110
+ }
111
+ end
112
+ end
113
+ end