kward 0.77.0 → 0.79.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/.github/workflows/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
data/lib/kward/pan/server.rb
CHANGED
|
@@ -13,11 +13,12 @@ require_relative "../plugin_registry"
|
|
|
13
13
|
require_relative "../prompts/commands"
|
|
14
14
|
require_relative "../rpc/transcript_normalizer"
|
|
15
15
|
require_relative "../session_store"
|
|
16
|
+
require_relative "../skills/capture"
|
|
16
17
|
require_relative "../session_trash"
|
|
17
18
|
require_relative "../tools/tool_call"
|
|
18
19
|
require_relative "../tools/registry"
|
|
19
20
|
require_relative "../version"
|
|
20
|
-
require_relative "../
|
|
21
|
+
require_relative "../workspace_factory"
|
|
21
22
|
|
|
22
23
|
# Namespace for the Kward CLI agent runtime.
|
|
23
24
|
module Kward
|
|
@@ -33,7 +34,11 @@ module Kward
|
|
|
33
34
|
@client = client
|
|
34
35
|
@output = output
|
|
35
36
|
@udp_socket_class = udp_socket_class
|
|
36
|
-
@workspace =
|
|
37
|
+
@workspace = WorkspaceFactory.build(
|
|
38
|
+
root: working_directory,
|
|
39
|
+
guardrails: ConfigFiles.workspace_guardrails_enabled?(config),
|
|
40
|
+
config: config
|
|
41
|
+
)
|
|
37
42
|
@full_config = config
|
|
38
43
|
@config = pan_config(config)
|
|
39
44
|
@host = @config.fetch("host", DEFAULT_HOST).to_s
|
|
@@ -234,12 +239,18 @@ module Kward
|
|
|
234
239
|
write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
|
|
235
240
|
when ["GET", "/sessions"]
|
|
236
241
|
write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
|
|
242
|
+
when ["GET", "/skill-capture/sessions"]
|
|
243
|
+
write_json(socket, 200, sessions: skill_capture_sessions)
|
|
237
244
|
when ["GET", "/events"]
|
|
238
245
|
stream_events(socket)
|
|
239
246
|
when ["POST", "/turn"]
|
|
240
247
|
handle_turn(socket, request[:body], request[:headers])
|
|
241
248
|
when ["POST", "/sessions"]
|
|
242
249
|
handle_session_action(socket, request[:body], request[:headers])
|
|
250
|
+
when ["POST", "/skill-capture/draft"]
|
|
251
|
+
handle_skill_capture_draft(socket, request[:body], request[:headers])
|
|
252
|
+
when ["POST", "/skill-capture/save"]
|
|
253
|
+
handle_skill_capture_save(socket, request[:body], request[:headers])
|
|
243
254
|
else
|
|
244
255
|
write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
|
|
245
256
|
end
|
|
@@ -308,6 +319,42 @@ module Kward
|
|
|
308
319
|
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
309
320
|
end
|
|
310
321
|
|
|
322
|
+
def handle_skill_capture_draft(socket, body, headers)
|
|
323
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
324
|
+
|
|
325
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
326
|
+
draft = skill_capture.generate(params.fetch("sessionPath"))
|
|
327
|
+
write_json(socket, 200, ok: true, content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description)
|
|
328
|
+
rescue JSON::ParserError
|
|
329
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
330
|
+
rescue Skills::Capture::Error => error
|
|
331
|
+
write_json(socket, 422, ok: false, error: error.message)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def handle_skill_capture_save(socket, body, headers)
|
|
335
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
336
|
+
|
|
337
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
338
|
+
draft = skill_capture.save(params.fetch("content"), overwrite: params["overwrite"] == true)
|
|
339
|
+
write_json(socket, 200, ok: true, path: skill_capture.skill_path(draft.name), name: draft.name, description: draft.description)
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
342
|
+
rescue Skills::Capture::ConflictError => error
|
|
343
|
+
write_json(socket, 409, ok: false, error: error.message)
|
|
344
|
+
rescue Skills::Capture::Error => error
|
|
345
|
+
write_json(socket, 422, ok: false, error: error.message)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def skill_capture_sessions
|
|
349
|
+
@session_store.capture_candidates.map do |session|
|
|
350
|
+
{ path: session.path, name: session.name, firstMessage: session.first_message, workspaceRoot: session.cwd, modifiedAt: session.modified_at.utc.iso8601(3) }
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def skill_capture
|
|
355
|
+
@skill_capture ||= Skills::Capture.new(session_store: @session_store, client: @client, config_dir: @session_store.config_dir)
|
|
356
|
+
end
|
|
357
|
+
|
|
311
358
|
def update_session(params)
|
|
312
359
|
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
313
360
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
# Namespace for Kward's permission-policy runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
module Permissions
|
|
6
|
+
# Evaluates the opt-in permission policy for model-requested tool calls.
|
|
7
|
+
#
|
|
8
|
+
# The policy is intentionally a decision layer, not a process sandbox. It
|
|
9
|
+
# can prevent Kward from starting a tool, but it cannot constrain a shell
|
|
10
|
+
# command after it has begun. OS-enforced sandboxing belongs at a lower
|
|
11
|
+
# process boundary.
|
|
12
|
+
class Policy
|
|
13
|
+
MODES = %w[ask read-only workspace-write deny-by-default].freeze
|
|
14
|
+
NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
|
|
15
|
+
FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
|
|
16
|
+
|
|
17
|
+
Decision = Struct.new(:action, :reason, keyword_init: true) do
|
|
18
|
+
def allowed?
|
|
19
|
+
action == :allow
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def approval_required?
|
|
23
|
+
action == :ask
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def denied?
|
|
27
|
+
action == :deny
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_config(config)
|
|
32
|
+
permissions = config["permissions"].is_a?(Hash) ? config["permissions"] : {}
|
|
33
|
+
new(
|
|
34
|
+
enabled: permissions["enabled"] == true,
|
|
35
|
+
mode: permissions["mode"],
|
|
36
|
+
allow: permissions["allow"],
|
|
37
|
+
ask: permissions["ask"],
|
|
38
|
+
deny: permissions["deny"],
|
|
39
|
+
write_scopes: permissions.key?("write_scopes") ? permissions["write_scopes"] : nil
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(enabled: false, mode: "ask", allow: [], ask: [], deny: [], write_scopes: nil)
|
|
44
|
+
@enabled = enabled == true
|
|
45
|
+
@mode = normalize_mode(mode)
|
|
46
|
+
@allow = normalize_rules(allow)
|
|
47
|
+
@ask = normalize_rules(ask)
|
|
48
|
+
@deny = normalize_rules(deny)
|
|
49
|
+
@write_scopes = normalize_scopes(write_scopes)
|
|
50
|
+
@session_allowed_tools = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enabled?
|
|
54
|
+
@enabled
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Allows this model tool for the remaining lifetime of this policy object.
|
|
58
|
+
# A matching deny or ask rule still takes precedence over this temporary grant.
|
|
59
|
+
def allow_for_session!(tool_name)
|
|
60
|
+
@session_allowed_tools[tool_name.to_s] = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decision_for(tool_name, arguments, source: nil)
|
|
64
|
+
return Decision.new(action: :allow, reason: "permissions disabled") unless enabled?
|
|
65
|
+
|
|
66
|
+
request = request_for(tool_name, arguments, source: source)
|
|
67
|
+
return Decision.new(action: :deny, reason: "matched deny rule") if matches?(@deny, request)
|
|
68
|
+
return Decision.new(action: :ask, reason: "matched ask rule") if matches?(@ask, request)
|
|
69
|
+
return Decision.new(action: :allow, reason: "matched allow rule") if matches?(@allow, request)
|
|
70
|
+
return Decision.new(action: :allow, reason: "allowed for this session") if @session_allowed_tools[request.fetch("tool")]
|
|
71
|
+
|
|
72
|
+
default_decision(request)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_mode(mode)
|
|
78
|
+
value = mode.to_s
|
|
79
|
+
MODES.include?(value) ? value : "ask"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_rules(rules)
|
|
83
|
+
Array(rules).filter_map do |rule|
|
|
84
|
+
next unless rule.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
rule.transform_keys(&:to_s).slice("tool", "path", "host", "command", "source")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_scopes(scopes)
|
|
91
|
+
return nil if scopes.nil?
|
|
92
|
+
|
|
93
|
+
Array(scopes).map(&:to_s).reject(&:empty?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def request_for(tool_name, arguments, source:)
|
|
97
|
+
name = tool_name.to_s
|
|
98
|
+
args = arguments.to_h
|
|
99
|
+
{
|
|
100
|
+
"tool" => name,
|
|
101
|
+
"path" => args["path"] || args[:path],
|
|
102
|
+
"host" => request_host(name, args),
|
|
103
|
+
"command" => args["command"] || args[:command],
|
|
104
|
+
"source" => source.to_s
|
|
105
|
+
}.compact.transform_values(&:to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def request_host(name, arguments)
|
|
109
|
+
return nil unless %w[fetch_content fetch_raw].include?(name)
|
|
110
|
+
|
|
111
|
+
URI.parse((arguments["url"] || arguments[:url]).to_s).host
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def matches?(rules, request)
|
|
117
|
+
rules.any? { |rule| rule_matches?(rule, request) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def rule_matches?(rule, request)
|
|
121
|
+
return false if rule.empty?
|
|
122
|
+
|
|
123
|
+
rule.all? do |field, pattern|
|
|
124
|
+
value = request[field]
|
|
125
|
+
value && glob_match?(pattern.to_s, value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def glob_match?(pattern, value)
|
|
130
|
+
expression = Regexp.escape(pattern).gsub("\\*\\*", ".*").gsub("\\*", "[^/]*")
|
|
131
|
+
Regexp.new("\\A#{expression}\\z").match?(value)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def default_decision(request)
|
|
135
|
+
return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
|
|
136
|
+
return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
|
|
137
|
+
|
|
138
|
+
if file_change?(request) && outside_write_scopes?(request)
|
|
139
|
+
return Decision.new(action: :deny, reason: "path outside write scopes")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if @mode == "workspace-write" && file_change?(request)
|
|
143
|
+
return Decision.new(action: :allow, reason: "workspace write mode")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return Decision.new(action: :allow, reason: "read-only tool") unless risky?(request)
|
|
147
|
+
|
|
148
|
+
Decision.new(action: :ask, reason: "#{request.fetch("tool")} requires approval")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def file_change?(request)
|
|
152
|
+
FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def network?(request)
|
|
156
|
+
NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def risky?(request)
|
|
160
|
+
file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def outside_write_scopes?(request)
|
|
164
|
+
return false if @write_scopes.nil?
|
|
165
|
+
|
|
166
|
+
path = request["path"]
|
|
167
|
+
path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -34,6 +34,10 @@ module Kward
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Registered plugin-owned tab runtime. Its factory receives a
|
|
38
|
+
# `PluginTabHost` and its persisted descriptor, then returns a driver.
|
|
39
|
+
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
|
|
40
|
+
|
|
37
41
|
# Read-only event passed to plugin transcript observers.
|
|
38
42
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
39
43
|
def to_h
|
|
@@ -264,6 +268,23 @@ module Kward
|
|
|
264
268
|
def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
|
|
265
269
|
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
266
270
|
end
|
|
271
|
+
|
|
272
|
+
# Registers a plugin-owned tab type for the interactive CLI. `id` is a
|
|
273
|
+
# durable identifier used in persisted tab layouts and must not change.
|
|
274
|
+
# The factory receives a `PluginTabHost` and a descriptor hash.
|
|
275
|
+
#
|
|
276
|
+
# @param name [String] command name used by `/tab open <name>`
|
|
277
|
+
# @param id [String] stable persisted tab type identifier
|
|
278
|
+
# @param title [String] default tab label
|
|
279
|
+
# @param singleton [Symbol] `:global` for one shared plugin runtime
|
|
280
|
+
# @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
|
|
281
|
+
# @yieldparam host [PluginTabHost] supported host dependencies
|
|
282
|
+
# @yieldparam descriptor [Hash] persisted tab descriptor
|
|
283
|
+
# @return [void]
|
|
284
|
+
# @api public
|
|
285
|
+
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
|
|
286
|
+
@registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
|
|
287
|
+
end
|
|
267
288
|
end
|
|
268
289
|
|
|
269
290
|
# Mutable singleton guard used while loading trusted plugin files.
|
|
@@ -282,6 +303,8 @@ module Kward
|
|
|
282
303
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
283
304
|
@commands = {}
|
|
284
305
|
@interactive_commands = {}
|
|
306
|
+
@tab_types = {}
|
|
307
|
+
@tab_types_by_id = {}
|
|
285
308
|
@footer = nil
|
|
286
309
|
@footer_path = nil
|
|
287
310
|
@transcript_event_handlers = []
|
|
@@ -312,6 +335,18 @@ module Kward
|
|
|
312
335
|
@interactive_commands[name.to_s]
|
|
313
336
|
end
|
|
314
337
|
|
|
338
|
+
def tab_types
|
|
339
|
+
@tab_types.values
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def tab_type_for(name)
|
|
343
|
+
@tab_types[name.to_s]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def tab_type_for_id(id)
|
|
347
|
+
@tab_types_by_id[id.to_s]
|
|
348
|
+
end
|
|
349
|
+
|
|
315
350
|
def footer_renderer
|
|
316
351
|
@footer
|
|
317
352
|
end
|
|
@@ -366,7 +401,7 @@ module Kward
|
|
|
366
401
|
previous_path = self.class.loading_path
|
|
367
402
|
self.class.loading_registry = self
|
|
368
403
|
self.class.loading_path = path
|
|
369
|
-
Kernel.load(path)
|
|
404
|
+
Kernel.load(path, true)
|
|
370
405
|
@paths << path
|
|
371
406
|
rescue StandardError => e
|
|
372
407
|
warn "Warning: skipping Kward plugin #{path}: #{e.message}"
|
|
@@ -429,6 +464,23 @@ module Kward
|
|
|
429
464
|
)
|
|
430
465
|
end
|
|
431
466
|
|
|
467
|
+
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
|
|
468
|
+
name = name.to_s
|
|
469
|
+
id = id.to_s
|
|
470
|
+
raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
471
|
+
raise "Plugin tab type id is required" if id.empty?
|
|
472
|
+
raise "Plugin tab type #{name} requires a handler" unless handler
|
|
473
|
+
|
|
474
|
+
if @tab_types.key?(name) || @tab_types_by_id.key?(id)
|
|
475
|
+
warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
|
|
476
|
+
return nil
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
|
|
480
|
+
@tab_types[name] = tab_type
|
|
481
|
+
@tab_types_by_id[id] = tab_type
|
|
482
|
+
end
|
|
483
|
+
|
|
432
484
|
def register_footer(path: nil, &renderer)
|
|
433
485
|
raise "Plugin footer requires a renderer" unless renderer
|
|
434
486
|
|
|
@@ -500,7 +552,7 @@ module Kward
|
|
|
500
552
|
end
|
|
501
553
|
end
|
|
502
554
|
|
|
503
|
-
def transcript_event(type, payload)
|
|
555
|
+
def transcript_event(type, payload = {})
|
|
504
556
|
TranscriptEvent.new(
|
|
505
557
|
type: type,
|
|
506
558
|
payload: DeepCopy.freeze(DeepCopy.dup(payload))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
class PromptInterface
|
|
6
|
+
# Tool-approval overlay built on the structured question modal.
|
|
7
|
+
module ApprovalPrompt
|
|
8
|
+
# Asks the captain to approve a model-requested tool call. Cancellation is
|
|
9
|
+
# intentionally a denial so a closed or interrupted overlay never permits
|
|
10
|
+
# a side effect.
|
|
11
|
+
def ask_tool_approval(tool_name:, args:, reason: nil)
|
|
12
|
+
summary, details = tool_approval_details(tool_name, args)
|
|
13
|
+
question = (["The agent wants to #{summary}."] + Array(details) + [reason.to_s].reject(&:empty?)).join("\n")
|
|
14
|
+
answers = ask_user_question([
|
|
15
|
+
{
|
|
16
|
+
header: "Approval required · #{tool_approval_title(tool_name)}",
|
|
17
|
+
question: question,
|
|
18
|
+
options: [
|
|
19
|
+
{ label: "Allow once", description: "Run this tool call." },
|
|
20
|
+
{ label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
|
|
21
|
+
{ label: "Deny", description: "Do not run this tool call." }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
])
|
|
25
|
+
answer = answers&.first
|
|
26
|
+
return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
|
|
27
|
+
|
|
28
|
+
case answer&.fetch(:answer, nil)
|
|
29
|
+
when "Allow once" then true
|
|
30
|
+
when "Allow this tool for this session" then :allow_for_session
|
|
31
|
+
else false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tool_approval_title(tool_name)
|
|
38
|
+
case tool_name.to_s
|
|
39
|
+
when "run_shell_command" then "Shell command"
|
|
40
|
+
when "write_file" then "Write file"
|
|
41
|
+
when "edit_file" then "Edit file"
|
|
42
|
+
when "fetch_content", "fetch_raw" then "Network request"
|
|
43
|
+
when "web_search" then "Web search"
|
|
44
|
+
else tool_name.to_s.tr("_", " ").capitalize
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_approval_details(tool_name, args)
|
|
49
|
+
action = case tool_name.to_s
|
|
50
|
+
when "run_shell_command" then "run this shell command"
|
|
51
|
+
when "write_file" then "write this file"
|
|
52
|
+
when "edit_file" then "edit this file"
|
|
53
|
+
when "read_file", "read_skill" then "read these resources"
|
|
54
|
+
when "fetch_content", "fetch_raw" then "make this network request"
|
|
55
|
+
when "web_search" then "search the web"
|
|
56
|
+
else "use #{tool_name}"
|
|
57
|
+
end
|
|
58
|
+
[action, ["Arguments:\n#{JSON.pretty_generate(args.to_h)}"]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -153,11 +153,11 @@ module Kward
|
|
|
153
153
|
pending_deletions << line[1..].to_s
|
|
154
154
|
elsif line.start_with?("+")
|
|
155
155
|
deleted = pending_deletions.shift
|
|
156
|
-
rows
|
|
156
|
+
rows.concat(side_by_side_diff_rows(deleted && "- #{deleted}", "+ #{line[1..]}", width))
|
|
157
157
|
else
|
|
158
158
|
rows.concat(flush_side_by_side_deletions(pending_deletions, width))
|
|
159
159
|
text = line.start_with?(" ") ? line[1..].to_s : line
|
|
160
|
-
rows
|
|
160
|
+
rows.concat(side_by_side_diff_rows(" #{text}", " #{text}", width))
|
|
161
161
|
end
|
|
162
162
|
end
|
|
163
163
|
|
|
@@ -170,7 +170,17 @@ module Kward
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
def flush_side_by_side_deletions(lines, width)
|
|
173
|
-
lines.shift(lines.length).
|
|
173
|
+
lines.shift(lines.length).flat_map { |line| side_by_side_diff_rows("- #{line}", nil, width) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def side_by_side_diff_rows(left, right, width)
|
|
177
|
+
return [side_by_side_diff_row(left, right, width)] unless current_editor_soft_wrap?
|
|
178
|
+
|
|
179
|
+
left_lines = side_by_side_diff_cell_lines(left, width)
|
|
180
|
+
right_lines = side_by_side_diff_cell_lines(right, width)
|
|
181
|
+
[left_lines.length, right_lines.length].max.times.map do |index|
|
|
182
|
+
side_by_side_diff_row(left_lines[index], right_lines[index], width)
|
|
183
|
+
end
|
|
174
184
|
end
|
|
175
185
|
|
|
176
186
|
def side_by_side_diff_row(left, right, width)
|
|
@@ -184,6 +194,17 @@ module Kward
|
|
|
184
194
|
visible_truncate(value, width)
|
|
185
195
|
end
|
|
186
196
|
|
|
197
|
+
def side_by_side_diff_cell_lines(text, width)
|
|
198
|
+
return [""] if text.nil?
|
|
199
|
+
|
|
200
|
+
value = text.to_s
|
|
201
|
+
prefix = value.start_with?("- ", "+ ", " ") ? value[0, 2] : ""
|
|
202
|
+
content = value.delete_prefix(prefix)
|
|
203
|
+
return [prefix] if content.empty?
|
|
204
|
+
|
|
205
|
+
content.chars.each_slice([width - prefix.length, 1].max).map { |characters| "#{prefix}#{characters.join}" }
|
|
206
|
+
end
|
|
207
|
+
|
|
187
208
|
def side_by_side_diff_column_width
|
|
188
209
|
content_width = [screen_width - 4, 1].max
|
|
189
210
|
gutter_width = [[999.to_s.length, 4].max + 3, 1].max
|
|
@@ -220,6 +241,7 @@ module Kward
|
|
|
220
241
|
@editor_text_width = nil
|
|
221
242
|
@editor_save_as_active = false
|
|
222
243
|
@editor_save_as_buffer = ""
|
|
244
|
+
@editor_save_callback = nil
|
|
223
245
|
@editor_state = nil
|
|
224
246
|
@prompt_label = "You>"
|
|
225
247
|
self.composer_input = ""
|
|
@@ -1150,6 +1172,17 @@ module Kward
|
|
|
1150
1172
|
return true
|
|
1151
1173
|
end
|
|
1152
1174
|
|
|
1175
|
+
if @editor_save_callback
|
|
1176
|
+
result = @editor_save_callback.call(@editor_state.buffer)
|
|
1177
|
+
if result.to_s.empty?
|
|
1178
|
+
close_editor
|
|
1179
|
+
return true
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
@editor_state.status = result.to_s
|
|
1183
|
+
return false
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1153
1186
|
if path.to_s.strip.empty? && @editor_state.path.to_s.empty?
|
|
1154
1187
|
if prompt_for_path
|
|
1155
1188
|
begin_editor_save_as
|
|
@@ -337,11 +337,12 @@ module Kward
|
|
|
337
337
|
|
|
338
338
|
def question_overlay_rows(width)
|
|
339
339
|
title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
content_width = [overlay_card_width(width) - 4, 1].max
|
|
341
|
+
lines = question_text_rows(@question_state[:question], content_width, :bold)
|
|
342
|
+
lines.concat([
|
|
342
343
|
overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
|
|
343
344
|
overlay_blank_line
|
|
344
|
-
]
|
|
345
|
+
])
|
|
345
346
|
question_choices.each_with_index do |choice, index|
|
|
346
347
|
selected = index == question_selection_index
|
|
347
348
|
lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
|
|
@@ -349,6 +350,14 @@ module Kward
|
|
|
349
350
|
overlay_card_rows(title, lines, width)
|
|
350
351
|
end
|
|
351
352
|
|
|
353
|
+
def question_text_rows(text, width, style)
|
|
354
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
355
|
+
wrapped = ANSI.wrap_visible(line, width)
|
|
356
|
+
wrapped = [""] if wrapped.empty?
|
|
357
|
+
wrapped.map { |row| overlay_text_line(row, style) }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
352
361
|
def choice_text(choice, selected: false)
|
|
353
362
|
if choice[:custom]
|
|
354
363
|
selected ? "Type a custom answer below." : "Type something."
|
|
@@ -25,6 +25,7 @@ require_relative "prompt_interface/file_overlay"
|
|
|
25
25
|
require_relative "prompt_interface/project_browser"
|
|
26
26
|
require_relative "prompt_interface/selection_prompt"
|
|
27
27
|
require_relative "prompt_interface/question_prompt"
|
|
28
|
+
require_relative "prompt_interface/approval_prompt"
|
|
28
29
|
require_relative "prompt_interface/git_prompt"
|
|
29
30
|
require_relative "prompt_interface/overlay_renderer"
|
|
30
31
|
require_relative "prompt_interface/editor/renderer"
|
|
@@ -75,6 +76,7 @@ module Kward
|
|
|
75
76
|
include ProjectBrowser
|
|
76
77
|
include SelectionPrompt
|
|
77
78
|
include QuestionPrompt
|
|
79
|
+
include ApprovalPrompt
|
|
78
80
|
include GitPrompt
|
|
79
81
|
include OverlayRenderer
|
|
80
82
|
include EditorRenderer
|
|
@@ -362,6 +364,24 @@ module Kward
|
|
|
362
364
|
run_editor
|
|
363
365
|
end
|
|
364
366
|
|
|
367
|
+
# Opens an in-memory document editor. The callback receives edited content
|
|
368
|
+
# when the user saves; return an error message to keep the editor open.
|
|
369
|
+
def review_document(title:, content:, &on_save)
|
|
370
|
+
raise ArgumentError, "review_document requires a save callback" unless on_save
|
|
371
|
+
|
|
372
|
+
start(render: false)
|
|
373
|
+
opened = @mutex.synchronize do
|
|
374
|
+
@editor_save_callback = on_save
|
|
375
|
+
open_scratchpad(:markdown, content: content).tap do
|
|
376
|
+
@editor_state.status = "#{title} · Ctrl+S save · Ctrl+Q cancel"
|
|
377
|
+
render_prompt_locked
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
return false unless opened
|
|
381
|
+
|
|
382
|
+
run_editor
|
|
383
|
+
end
|
|
384
|
+
|
|
365
385
|
def run_editor
|
|
366
386
|
loop do
|
|
367
387
|
key = read_key(nonblock: true)
|
|
@@ -33,7 +33,9 @@ module Kward
|
|
|
33
33
|
{ name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
|
|
34
34
|
{ name: "status", description: "Show the current status message.", argument_hint: "" },
|
|
35
35
|
{ name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
|
|
36
|
+
{ name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
|
|
36
37
|
{ name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
|
|
38
|
+
{ name: "skill", description: "Activate a skill or capture one from a saved session.", argument_hint: "<name>|capture" },
|
|
37
39
|
{ name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
|
|
38
40
|
].freeze
|
|
39
41
|
BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -38,6 +38,8 @@ module Kward
|
|
|
38
38
|
# @return [Array<Hash>] section hashes with label, content, and optional source
|
|
39
39
|
# @api public
|
|
40
40
|
def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
41
|
+
return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
|
|
42
|
+
|
|
41
43
|
sections = [prompt_section("Built-in system prompt", base_prompt)]
|
|
42
44
|
sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
|
|
43
45
|
sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
|
|
@@ -53,11 +55,11 @@ module Kward
|
|
|
53
55
|
|
|
54
56
|
def base_prompt
|
|
55
57
|
<<~PROMPT.strip
|
|
56
|
-
You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
|
|
58
|
+
You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Only call tools advertised for the current turn. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
When web tools are available, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on. Use code_search for package, GitHub repository, and source-code research.
|
|
59
61
|
|
|
60
|
-
Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. Use context_budget_stats when asked about context savings.
|
|
62
|
+
Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
|
|
61
63
|
PROMPT
|
|
62
64
|
end
|
|
63
65
|
|
|
@@ -73,6 +75,7 @@ module Kward
|
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
def config_agents_prompt_source
|
|
78
|
+
return nil unless ConfigFiles.include_config_principles?
|
|
76
79
|
return ConfigFiles.config_principles_path if File.exist?(ConfigFiles.config_principles_path)
|
|
77
80
|
return ConfigFiles.config_agents_path if File.exist?(ConfigFiles.config_agents_path)
|
|
78
81
|
|
|
@@ -112,6 +115,14 @@ module Kward
|
|
|
112
115
|
"Workspace AGENTS.md hint"
|
|
113
116
|
end
|
|
114
117
|
|
|
118
|
+
def replacement_prompt_sections
|
|
119
|
+
path = ConfigFiles.system_prompt_file_path
|
|
120
|
+
content = ConfigFiles.system_prompt_file
|
|
121
|
+
return [] if content.to_s.empty?
|
|
122
|
+
|
|
123
|
+
[prompt_section("Custom system prompt (replacement)", content, source: path)]
|
|
124
|
+
end
|
|
125
|
+
|
|
115
126
|
def prompt_section(label, content, source: nil)
|
|
116
127
|
return nil if content.to_s.empty?
|
|
117
128
|
|
|
@@ -123,8 +134,8 @@ module Kward
|
|
|
123
134
|
|
|
124
135
|
lines = [
|
|
125
136
|
"Agent Skills are available.",
|
|
126
|
-
"When a task matches a skill's description, use
|
|
127
|
-
"
|
|
137
|
+
"When read_skill is available and a task matches a skill's description, use it to load the instructions before proceeding.",
|
|
138
|
+
"When using read_skill, use a relative path to read referenced files inside a skill directory.",
|
|
128
139
|
"Available skills:"
|
|
129
140
|
]
|
|
130
141
|
skills.each do |skill|
|