kward 0.74.0 → 0.75.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 +35 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +56 -3
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +72 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +32 -3
- data/lib/kward/cli/rendering.rb +1 -1
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +0 -7
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +117 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +1 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +13 -45
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +24 -0
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +178 -13
- data/lib/kward/pan/server.rb +51 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +86 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +94 -14
- data/lib/kward/rpc/session_manager.rb +258 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +9 -10
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +117 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +25 -1
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require_relative "../config_files"
|
|
2
2
|
require_relative "../conversation"
|
|
3
|
+
require_relative "../hooks"
|
|
3
4
|
require_relative "ask_user_question"
|
|
4
5
|
require_relative "code_search"
|
|
5
6
|
require_relative "context_budget_stats"
|
|
@@ -62,7 +63,7 @@ module Kward
|
|
|
62
63
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
63
64
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
64
65
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
65
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil)
|
|
66
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
|
|
66
67
|
@workspace = workspace
|
|
67
68
|
@prompt = prompt
|
|
68
69
|
@web_search = web_search
|
|
@@ -77,6 +78,9 @@ module Kward
|
|
|
77
78
|
@tool_output_compactor = tool_output_compactor
|
|
78
79
|
@telemetry_logger = telemetry_logger
|
|
79
80
|
@context_budget_meter = context_budget_meter
|
|
81
|
+
@tool_approval = tool_approval
|
|
82
|
+
@hook_manager = hook_manager
|
|
83
|
+
@hook_context = hook_context
|
|
80
84
|
@mcp_clients = if mcp_clients
|
|
81
85
|
mcp_clients
|
|
82
86
|
elsif @allowed_tool_names
|
|
@@ -85,7 +89,7 @@ module Kward
|
|
|
85
89
|
MCP::ServerConfig.clients_from_config(ConfigFiles.read_config)
|
|
86
90
|
end
|
|
87
91
|
@tools = build_tools.freeze
|
|
88
|
-
@schemas = build_schema_tools.map(
|
|
92
|
+
@schemas = build_schema_tools.map { |tool| schema_with_metadata(tool) }.freeze
|
|
89
93
|
end
|
|
90
94
|
|
|
91
95
|
# Executes a model-requested tool call and appends the result to the
|
|
@@ -105,10 +109,18 @@ module Kward
|
|
|
105
109
|
tool = @tools[name]
|
|
106
110
|
|
|
107
111
|
original_content = if tool
|
|
108
|
-
|
|
112
|
+
before_tool = run_hook("tool_call_before", conversation, payload: tool_payload(name, args, tool_call))
|
|
113
|
+
args = hook_arguments(before_tool, args)
|
|
114
|
+
if before_tool.denied?
|
|
115
|
+
hook_denied_content(before_tool, "tool call denied: #{name}")
|
|
116
|
+
elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
|
|
117
|
+
hook_denied_content(before_tool, "tool call approval denied: #{name}")
|
|
118
|
+
elsif mutation_tool?(name) && !write_lock_owned?
|
|
109
119
|
"Workspace write denied: another worker owns the write lock."
|
|
120
|
+
elsif tool_approval_denied?(tool_call, name, args, cancellation)
|
|
121
|
+
"Declined: tool execution denied by user: #{name}"
|
|
110
122
|
else
|
|
111
|
-
tool
|
|
123
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
112
124
|
end
|
|
113
125
|
else
|
|
114
126
|
"Unknown tool: #{name}"
|
|
@@ -121,7 +133,7 @@ module Kward
|
|
|
121
133
|
end
|
|
122
134
|
|
|
123
135
|
artifact_id = nil
|
|
124
|
-
model_content =
|
|
136
|
+
model_content = compact_tool_output(name, content, original_content, conversation) do
|
|
125
137
|
artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
|
|
126
138
|
end
|
|
127
139
|
record_context_budget(conversation, name, before: original_content, after: model_content)
|
|
@@ -136,8 +148,73 @@ module Kward
|
|
|
136
148
|
model_content
|
|
137
149
|
end
|
|
138
150
|
|
|
151
|
+
def metadata_for(name)
|
|
152
|
+
tool = @tools[name.to_s]
|
|
153
|
+
return unknown_tool_metadata(name) unless tool
|
|
154
|
+
|
|
155
|
+
tool_metadata(tool)
|
|
156
|
+
end
|
|
157
|
+
|
|
139
158
|
private
|
|
140
159
|
|
|
160
|
+
def schema_with_metadata(tool)
|
|
161
|
+
tool.schema.merge(metadata: tool_metadata(tool))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def tool_metadata(tool)
|
|
165
|
+
if tool.is_a?(Tools::MCPTool)
|
|
166
|
+
{
|
|
167
|
+
source: "mcp",
|
|
168
|
+
displayName: tool.display_name,
|
|
169
|
+
serverName: tool.server_name,
|
|
170
|
+
remoteName: tool.remote_name
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
{
|
|
174
|
+
source: source_for_tool(tool),
|
|
175
|
+
displayName: tool.name
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def source_for_tool(tool)
|
|
181
|
+
case tool
|
|
182
|
+
when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
|
|
183
|
+
"web"
|
|
184
|
+
when Tools::ReadSkill
|
|
185
|
+
"skill"
|
|
186
|
+
when Tools::AskUserQuestion
|
|
187
|
+
"ui"
|
|
188
|
+
else
|
|
189
|
+
"builtin"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def unknown_tool_metadata(name)
|
|
194
|
+
{
|
|
195
|
+
source: "unknown",
|
|
196
|
+
displayName: name.to_s
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def compact_tool_output(name, content, original_content, conversation, &store_artifact)
|
|
201
|
+
before = run_hook("tool_output_compact_before", conversation, payload: {
|
|
202
|
+
tool_name: name,
|
|
203
|
+
bytes: content.bytesize,
|
|
204
|
+
duplicate: content != original_content
|
|
205
|
+
})
|
|
206
|
+
return content if before.denied? || before.approval_required?
|
|
207
|
+
|
|
208
|
+
compacted = @tool_output_compactor.compact(name, content, &store_artifact)
|
|
209
|
+
run_hook("tool_output_compact_after", conversation, payload: {
|
|
210
|
+
tool_name: name,
|
|
211
|
+
bytes_before: content.bytesize,
|
|
212
|
+
bytes_after: compacted.bytesize,
|
|
213
|
+
compacted: compacted != content
|
|
214
|
+
}) if compacted != content
|
|
215
|
+
compacted
|
|
216
|
+
end
|
|
217
|
+
|
|
141
218
|
def record_context_budget(conversation, name, before:, after:)
|
|
142
219
|
meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
|
|
143
220
|
return unless meter
|
|
@@ -170,10 +247,184 @@ module Kward
|
|
|
170
247
|
)
|
|
171
248
|
end
|
|
172
249
|
|
|
250
|
+
def execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
251
|
+
args = run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
252
|
+
run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
253
|
+
run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
254
|
+
content = tool.call(args, conversation, cancellation: cancellation)
|
|
255
|
+
run_mcp_after_hooks(name, args, content, conversation)
|
|
256
|
+
run_shell_after_hooks(name, args, content, conversation)
|
|
257
|
+
run_file_change_after_hooks(name, args, content, conversation)
|
|
258
|
+
run_hook("tool_call_after", conversation, payload: tool_payload(name, args, tool_call).merge(content: content.to_s))
|
|
259
|
+
content
|
|
260
|
+
rescue HookDenied => e
|
|
261
|
+
e.message
|
|
262
|
+
rescue StandardError => e
|
|
263
|
+
run_mcp_error_hooks(name, args, e, conversation)
|
|
264
|
+
run_hook("tool_call_error", conversation, payload: tool_payload(name, args, tool_call).merge(error: e.message))
|
|
265
|
+
raise
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def run_mcp_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
269
|
+
return unless mcp_tool?(name)
|
|
270
|
+
|
|
271
|
+
result = run_hook("mcp_tool_before", conversation, payload: mcp_payload(name, args))
|
|
272
|
+
if result.denied?
|
|
273
|
+
raise HookDenied, hook_denied_content(result, "MCP tool denied")
|
|
274
|
+
end
|
|
275
|
+
if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
276
|
+
raise HookDenied, hook_denied_content(result, "MCP tool approval denied")
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def run_mcp_after_hooks(name, args, content, conversation)
|
|
281
|
+
return unless mcp_tool?(name)
|
|
282
|
+
|
|
283
|
+
run_hook("mcp_tool_after", conversation, payload: mcp_payload(name, args).merge(content: content.to_s))
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def run_mcp_error_hooks(name, args, error, conversation)
|
|
287
|
+
return unless mcp_tool?(name)
|
|
288
|
+
|
|
289
|
+
run_hook("mcp_tool_error", conversation, payload: mcp_payload(name, args).merge(error: error.message))
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def run_shell_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
293
|
+
return args unless name.to_s == "run_shell_command"
|
|
294
|
+
|
|
295
|
+
result = run_hook("shell_command_before", conversation, payload: shell_payload(args))
|
|
296
|
+
if result.denied?
|
|
297
|
+
raise HookDenied, hook_denied_content(result, "shell command denied")
|
|
298
|
+
end
|
|
299
|
+
if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
300
|
+
raise HookDenied, hook_denied_content(result, "shell command approval denied")
|
|
301
|
+
end
|
|
302
|
+
shell_arguments(result.payload, args)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def run_shell_after_hooks(name, args, content, conversation)
|
|
306
|
+
return unless name.to_s == "run_shell_command"
|
|
307
|
+
|
|
308
|
+
run_hook("shell_command_after", conversation, payload: shell_payload(args).merge(content: content.to_s))
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def run_file_change_before_hooks(name, args, tool_call, conversation, cancellation)
|
|
312
|
+
return unless ToolCall.file_change_tool?(name)
|
|
313
|
+
|
|
314
|
+
result = run_hook("file_change_before", conversation, payload: file_change_payload(name, args))
|
|
315
|
+
if result.denied?
|
|
316
|
+
raise HookDenied, hook_denied_content(result, "file change denied")
|
|
317
|
+
end
|
|
318
|
+
if result.approval_required? && hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
319
|
+
raise HookDenied, hook_denied_content(result, "file change approval denied")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def run_file_change_after_hooks(name, args, content, conversation)
|
|
324
|
+
return unless ToolCall.file_change_tool?(name)
|
|
325
|
+
return unless content.to_s.start_with?("Wrote ", "Edited ")
|
|
326
|
+
|
|
327
|
+
run_hook("file_change_after", conversation, payload: file_change_payload(name, args).merge(content: content.to_s))
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
HookDenied = Class.new(StandardError)
|
|
331
|
+
|
|
332
|
+
def run_hook(name, conversation, payload: {})
|
|
333
|
+
return Hooks::Manager::Result.new(event: nil, decision: Hooks::Decision.allow, decisions: [], warnings: [], messages: [], payload: payload) unless @hook_manager
|
|
334
|
+
|
|
335
|
+
@hook_manager.run(Hooks::Event.new(
|
|
336
|
+
name: name,
|
|
337
|
+
workspace: { root: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil },
|
|
338
|
+
payload: payload
|
|
339
|
+
), context: @hook_context || default_hook_context(conversation))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def default_hook_context(conversation)
|
|
343
|
+
return nil unless defined?(PluginRegistry::Context)
|
|
344
|
+
|
|
345
|
+
PluginRegistry::Context.new(conversation: conversation, workspace_root: @workspace.respond_to?(:root) ? @workspace.root.to_s : Dir.pwd)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def tool_payload(name, args, tool_call)
|
|
349
|
+
metadata = metadata_for(name)
|
|
350
|
+
{
|
|
351
|
+
tool_name: name,
|
|
352
|
+
arguments: args,
|
|
353
|
+
tool_call_id: tool_call["id"] || tool_call[:id],
|
|
354
|
+
source: metadata[:source] || metadata["source"],
|
|
355
|
+
server_name: metadata[:serverName] || metadata["serverName"],
|
|
356
|
+
remote_name: metadata[:remoteName] || metadata["remoteName"]
|
|
357
|
+
}.compact
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def hook_arguments(result, fallback)
|
|
361
|
+
payload_arguments = result.payload[:arguments] || result.payload["arguments"]
|
|
362
|
+
payload_arguments.is_a?(Hash) ? payload_arguments : fallback
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def mcp_payload(name, args)
|
|
366
|
+
tool_payload(name, args, {}).slice(:tool_name, :arguments, :source, :server_name, :remote_name)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def shell_payload(args)
|
|
370
|
+
{
|
|
371
|
+
tool_name: "run_shell_command",
|
|
372
|
+
command: args[:command] || args["command"],
|
|
373
|
+
timeout_seconds: args[:timeout_seconds] || args["timeout_seconds"] || Workspace::DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
|
374
|
+
cwd: @workspace.respond_to?(:root) ? @workspace.root.to_s : nil
|
|
375
|
+
}.compact
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def file_change_payload(name, args)
|
|
379
|
+
operation = name.to_s.start_with?("edit") ? "edit" : "write"
|
|
380
|
+
path = args[:path] || args["path"]
|
|
381
|
+
payload = {
|
|
382
|
+
tool_name: name,
|
|
383
|
+
operation: operation,
|
|
384
|
+
path: path,
|
|
385
|
+
files: [{ path: path, operation: operation }]
|
|
386
|
+
}
|
|
387
|
+
if operation == "edit"
|
|
388
|
+
payload[:edits] = args[:edits] || args["edits"]
|
|
389
|
+
else
|
|
390
|
+
payload[:content] = args[:content] || args["content"]
|
|
391
|
+
end
|
|
392
|
+
payload.compact
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def shell_arguments(payload, fallback)
|
|
396
|
+
fallback.merge(
|
|
397
|
+
command: payload[:command] || payload["command"] || fallback[:command] || fallback["command"],
|
|
398
|
+
timeout_seconds: payload[:timeout_seconds] || payload["timeout_seconds"] || fallback[:timeout_seconds] || fallback["timeout_seconds"]
|
|
399
|
+
)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def hook_denied_content(result, fallback)
|
|
403
|
+
"Declined: #{result.decision.message || fallback}"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def hook_approval_denied?(result, tool_call, name, args, cancellation)
|
|
407
|
+
return true unless @tool_approval
|
|
408
|
+
|
|
409
|
+
approval_args = args.merge("hook_message" => result.decision.message)
|
|
410
|
+
@tool_approval.call(tool_call: tool_call, name: name, args: approval_args, cancellation: cancellation) == false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def mcp_tool?(name)
|
|
414
|
+
metadata = metadata_for(name)
|
|
415
|
+
(metadata[:source] || metadata["source"]).to_s == "mcp"
|
|
416
|
+
end
|
|
417
|
+
|
|
173
418
|
def mutation_tool?(name)
|
|
174
419
|
ToolCall.write_lock_required?(name)
|
|
175
420
|
end
|
|
176
421
|
|
|
422
|
+
def tool_approval_denied?(tool_call, name, args, cancellation)
|
|
423
|
+
return false unless @tool_approval
|
|
424
|
+
|
|
425
|
+
@tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
|
|
426
|
+
end
|
|
427
|
+
|
|
177
428
|
def write_lock_owned?
|
|
178
429
|
return true unless @write_lock
|
|
179
430
|
|
|
@@ -202,7 +453,7 @@ module Kward
|
|
|
202
453
|
Tools::WebSearch.new(web_search: @web_search),
|
|
203
454
|
Tools::FetchContent.new(web_fetch: @web_fetch),
|
|
204
455
|
Tools::FetchRaw.new(web_fetch: @web_fetch),
|
|
205
|
-
Tools::ReadSkill.new,
|
|
456
|
+
Tools::ReadSkill.new(skills: discovered_skills),
|
|
206
457
|
Tools::AskUserQuestion.new(prompt: @prompt)
|
|
207
458
|
] + mcp_tool_values
|
|
208
459
|
end
|
|
@@ -229,9 +480,16 @@ module Kward
|
|
|
229
480
|
true
|
|
230
481
|
end
|
|
231
482
|
|
|
483
|
+
def discovered_skills(workspace_root: nil)
|
|
484
|
+
return @skills unless @skills.nil?
|
|
485
|
+
|
|
486
|
+
@discovered_skills ||= {}
|
|
487
|
+
root = workspace_root || (@workspace.respond_to?(:root) ? @workspace.root : Dir.pwd)
|
|
488
|
+
@discovered_skills[root] ||= ConfigFiles.skills(workspace_root: root)
|
|
489
|
+
end
|
|
490
|
+
|
|
232
491
|
def skills_available?
|
|
233
|
-
|
|
234
|
-
skills.any?
|
|
492
|
+
discovered_skills.any?
|
|
235
493
|
end
|
|
236
494
|
|
|
237
495
|
def mcp_tool_values
|
|
@@ -5,7 +5,9 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "pathname"
|
|
7
7
|
require "uri"
|
|
8
|
+
require_relative "../../path_guard"
|
|
8
9
|
require_relative "../../config_files"
|
|
10
|
+
require_relative "web"
|
|
9
11
|
|
|
10
12
|
# Namespace for the Kward CLI agent runtime.
|
|
11
13
|
module Kward
|
|
@@ -24,7 +26,7 @@ module Kward
|
|
|
24
26
|
ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
|
|
25
27
|
|
|
26
28
|
# Creates an object for code search and repository cache operations.
|
|
27
|
-
def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
|
|
29
|
+
def initialize(cache_root: nil, http_client: WebSearch::NetHttpClient.new(user_agent: "Kward code_search"), git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
|
|
28
30
|
@cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
|
|
29
31
|
@http_client = http_client
|
|
30
32
|
@git_runner = git_runner
|
|
@@ -49,26 +51,6 @@ module Kward
|
|
|
49
51
|
"Error: code_search failed: #{redact(e.message)}"
|
|
50
52
|
end
|
|
51
53
|
|
|
52
|
-
# HTTP adapter used by code search registry/package lookups.
|
|
53
|
-
class NetHttpClient
|
|
54
|
-
def get_json(url, headers: {})
|
|
55
|
-
JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def get_text(url, headers: {})
|
|
59
|
-
uri = URI(url)
|
|
60
|
-
request = Net::HTTP::Get.new(uri)
|
|
61
|
-
request["User-Agent"] = "Kward code_search"
|
|
62
|
-
headers.each { |key, val| request[key] = val }
|
|
63
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
64
|
-
http.request(request)
|
|
65
|
-
end
|
|
66
|
-
raise "HTTP #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
|
|
67
|
-
|
|
68
|
-
response.body
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
54
|
# Git command adapter used by repository cache operations.
|
|
73
55
|
class GitRunner
|
|
74
56
|
def run(*args, chdir: nil)
|
|
@@ -376,7 +358,7 @@ module Kward
|
|
|
376
358
|
end
|
|
377
359
|
|
|
378
360
|
def inside_root?(root, path)
|
|
379
|
-
|
|
361
|
+
PathGuard.inside?(path, root)
|
|
380
362
|
end
|
|
381
363
|
|
|
382
364
|
def inside_cache?(path)
|
|
@@ -54,7 +54,10 @@ module Kward
|
|
|
54
54
|
return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
|
|
55
55
|
|
|
56
56
|
max_results = bounded_max_results(args_value(args, "max_results") || args_value(args, "num_results"))
|
|
57
|
-
|
|
57
|
+
provider_value = args_value(args, "provider")
|
|
58
|
+
provider_value = config_value("provider") if provider_value.nil?
|
|
59
|
+
provider_value = "auto" if provider_value.nil? || provider_value.to_s.empty?
|
|
60
|
+
provider = normalize_provider(provider_value)
|
|
58
61
|
return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
|
|
59
62
|
|
|
60
63
|
options = {
|
|
@@ -549,8 +552,10 @@ module Kward
|
|
|
549
552
|
|
|
550
553
|
def args_value(args, key)
|
|
551
554
|
return nil unless args.is_a?(Hash)
|
|
555
|
+
return args[key] if args.key?(key)
|
|
556
|
+
return args[key.to_sym] if args.key?(key.to_sym)
|
|
552
557
|
|
|
553
|
-
|
|
558
|
+
nil
|
|
554
559
|
end
|
|
555
560
|
|
|
556
561
|
def success?(response)
|
|
@@ -717,10 +722,26 @@ module Kward
|
|
|
717
722
|
class NetHttpClient
|
|
718
723
|
Response = Struct.new(:code, :body, :headers, keyword_init: true)
|
|
719
724
|
|
|
725
|
+
def initialize(user_agent: nil)
|
|
726
|
+
@user_agent = user_agent
|
|
727
|
+
end
|
|
728
|
+
|
|
720
729
|
def get(url, headers: {})
|
|
721
730
|
request(url, Net::HTTP::Get, headers: headers)
|
|
722
731
|
end
|
|
723
732
|
|
|
733
|
+
def get_json(url, headers: {})
|
|
734
|
+
JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def get_text(url, headers: {})
|
|
738
|
+
uri = URI.parse(url)
|
|
739
|
+
response = get(uri.to_s, headers: headers)
|
|
740
|
+
raise "HTTP #{response.code} from #{uri.host}" unless success?(response.code)
|
|
741
|
+
|
|
742
|
+
response.body
|
|
743
|
+
end
|
|
744
|
+
|
|
724
745
|
def post(url, form:, headers: {})
|
|
725
746
|
request(url, Net::HTTP::Post, headers: headers) do |http_request|
|
|
726
747
|
http_request.set_form_data(form)
|
|
@@ -739,12 +760,17 @@ module Kward
|
|
|
739
760
|
uri = URI.parse(url)
|
|
740
761
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
741
762
|
http_request = request_class.new(uri)
|
|
763
|
+
http_request["User-Agent"] = @user_agent if @user_agent
|
|
742
764
|
headers.each { |key, value| http_request[key] = value }
|
|
743
765
|
yield http_request if block_given?
|
|
744
766
|
response = http.request(http_request)
|
|
745
767
|
Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
|
|
746
768
|
end
|
|
747
769
|
end
|
|
770
|
+
|
|
771
|
+
def success?(code)
|
|
772
|
+
code.to_i.between?(200, 299)
|
|
773
|
+
end
|
|
748
774
|
end
|
|
749
775
|
end
|
|
750
776
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
# Cached RubyGems update check for the interactive startup screen.
|
|
10
|
+
class UpdateCheck
|
|
11
|
+
CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
|
12
|
+
LATEST_VERSION_URL = URI("https://rubygems.org/api/v1/versions/kward/latest.json")
|
|
13
|
+
|
|
14
|
+
Notice = Struct.new(:latest_version, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(current_version:, config: ConfigFiles.read_config, path: ConfigFiles.update_check_cache_path, now: Time.now)
|
|
17
|
+
@current_version = current_version.to_s
|
|
18
|
+
@config = config
|
|
19
|
+
@path = path
|
|
20
|
+
@now = now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enabled?
|
|
24
|
+
return false if disabled_by_environment?
|
|
25
|
+
|
|
26
|
+
updates = @config["updates"]
|
|
27
|
+
return false if updates.is_a?(Hash) && updates["check"] == false
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def notice
|
|
33
|
+
return nil unless enabled?
|
|
34
|
+
|
|
35
|
+
latest_version = cache["latest_version"].to_s
|
|
36
|
+
return nil if latest_version.empty?
|
|
37
|
+
return nil if dismissed_version == latest_version
|
|
38
|
+
return nil unless newer_than_current?(latest_version)
|
|
39
|
+
|
|
40
|
+
Notice.new(latest_version: latest_version)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def refresh_if_stale
|
|
44
|
+
return false unless enabled?
|
|
45
|
+
return false unless stale?
|
|
46
|
+
|
|
47
|
+
refresh
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def refresh
|
|
51
|
+
latest_version = fetch_latest_version
|
|
52
|
+
write_cache(cache.merge(
|
|
53
|
+
"checked_at" => @now.utc.iso8601,
|
|
54
|
+
"latest_version" => latest_version
|
|
55
|
+
))
|
|
56
|
+
true
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def disabled_by_environment?
|
|
64
|
+
value = ENV["KWARD_DISABLE_UPDATE_CHECK"].to_s.strip.downcase
|
|
65
|
+
["1", "true", "yes", "on"].include?(value)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def dismissed_version
|
|
69
|
+
cache["dismissed_version"].to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stale?
|
|
73
|
+
checked_at = Time.iso8601(cache["checked_at"].to_s)
|
|
74
|
+
(@now - checked_at) >= CHECK_INTERVAL_SECONDS
|
|
75
|
+
rescue ArgumentError, TypeError
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cache
|
|
80
|
+
@cache ||= read_cache
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_cache
|
|
84
|
+
return {} unless File.file?(@path)
|
|
85
|
+
|
|
86
|
+
data = JSON.parse(File.read(@path))
|
|
87
|
+
data.is_a?(Hash) ? data : {}
|
|
88
|
+
rescue JSON::ParserError, SystemCallError
|
|
89
|
+
{}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write_cache(data)
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
94
|
+
File.write(@path, JSON.pretty_generate(data) + "\n")
|
|
95
|
+
@cache = data
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fetch_latest_version
|
|
99
|
+
request = Net::HTTP::Get.new(LATEST_VERSION_URL)
|
|
100
|
+
request["User-Agent"] = "kward/#{Kward::VERSION}"
|
|
101
|
+
response = Net::HTTP.start(LATEST_VERSION_URL.hostname, LATEST_VERSION_URL.port, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
|
|
102
|
+
http.request(request)
|
|
103
|
+
end
|
|
104
|
+
return "" unless response.is_a?(Net::HTTPSuccess)
|
|
105
|
+
|
|
106
|
+
JSON.parse(response.body)["version"].to_s
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
""
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def newer_than_current?(version)
|
|
112
|
+
Gem::Version.new(version) > Gem::Version.new(@current_version)
|
|
113
|
+
rescue ArgumentError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/kward/version.rb
CHANGED