kward 0.80.0 → 0.81.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 +40 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/doc/composer.md +5 -8
- data/doc/configuration.md +13 -3
- data/doc/permissions.md +1 -1
- data/doc/rpc.md +2 -1
- data/doc/sandboxing.md +4 -4
- data/doc/security.md +6 -9
- data/doc/shell.md +161 -196
- data/doc/skills.md +19 -5
- data/doc/tabs.md +1 -1
- data/doc/usage.md +6 -5
- data/lib/kward/agent.rb +11 -6
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/interactive_turn.rb +1 -1
- data/lib/kward/cli/memory_commands.rb +2 -2
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/project_skills.rb +99 -0
- data/lib/kward/cli/project_skills_commands.rb +87 -0
- data/lib/kward/cli/prompt_interface.rb +54 -4
- data/lib/kward/cli/rendering.rb +28 -2
- data/lib/kward/cli/runtime_helpers.rb +157 -19
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +1 -1
- data/lib/kward/cli/slash_commands.rb +7 -1
- data/lib/kward/cli/tabs.rb +4 -1
- data/lib/kward/cli.rb +21 -1
- data/lib/kward/compactor.rb +7 -2
- data/lib/kward/config_files.rb +30 -9
- data/lib/kward/conversation.rb +8 -5
- data/lib/kward/ekwsh.rb +37 -16
- data/lib/kward/hooks/audit_log.rb +5 -2
- data/lib/kward/interactive_pty_runner.rb +88 -22
- data/lib/kward/plugin_registry.rb +20 -13
- data/lib/kward/prompt_interface/composer_controller.rb +13 -1
- data/lib/kward/prompt_interface/editor/controller.rb +4 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +35 -10
- data/lib/kward/prompt_interface/key_handler.rb +78 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
- data/lib/kward/prompt_interface/screen.rb +5 -0
- data/lib/kward/prompt_interface.rb +93 -21
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +11 -6
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_store.rb +3 -2
- data/lib/kward/skills/registry.rb +67 -12
- data/lib/kward/skills/trust_coordinator.rb +45 -0
- data/lib/kward/skills/trust_store.rb +107 -0
- data/lib/kward/telemetry/logger.rb +5 -2
- data/lib/kward/version.rb +1 -1
- metadata +5 -1
|
@@ -4,10 +4,21 @@ module Kward
|
|
|
4
4
|
class CLI
|
|
5
5
|
# Shared runtime construction helpers for CLI conversations, workspaces, plugins, and sessions.
|
|
6
6
|
module RuntimeHelpers
|
|
7
|
+
MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES = 1_048_576
|
|
8
|
+
UNSAFE_TRANSCRIPT_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.freeze
|
|
9
|
+
FROZEN_COMPOSER_COMMANDS = /\Agit\s+(?:fetch|ls-remote|push|remote|status)(?:\s|\z)/.freeze
|
|
10
|
+
|
|
7
11
|
private
|
|
8
12
|
|
|
9
13
|
def new_conversation(workspace_root: current_workspace_root)
|
|
10
|
-
Conversation.new(
|
|
14
|
+
Conversation.new(
|
|
15
|
+
workspace_root: workspace_root,
|
|
16
|
+
provider: current_model_provider,
|
|
17
|
+
model: current_model_id,
|
|
18
|
+
reasoning_effort: current_reasoning_effort,
|
|
19
|
+
plugin_registry: plugin_registry,
|
|
20
|
+
project_skill_paths: project_skill_paths_for(workspace_root)
|
|
21
|
+
)
|
|
11
22
|
end
|
|
12
23
|
|
|
13
24
|
def update_assistant_prompt(conversation)
|
|
@@ -50,6 +61,7 @@ module Kward
|
|
|
50
61
|
tool_registry = ToolRegistry.new(
|
|
51
62
|
workspace: workspace,
|
|
52
63
|
prompt: @prompt,
|
|
64
|
+
skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths_for(conversation.workspace_root)),
|
|
53
65
|
tool_approval: interactive_tool_approval_callback,
|
|
54
66
|
hook_manager: hook_manager,
|
|
55
67
|
hook_context: hook_context
|
|
@@ -60,6 +72,7 @@ module Kward
|
|
|
60
72
|
client: @client,
|
|
61
73
|
tool_registry: tool_registry,
|
|
62
74
|
conversation: conversation,
|
|
75
|
+
warning_sink: ConfigFiles.warning_sink,
|
|
63
76
|
hook_manager: hook_manager,
|
|
64
77
|
hook_context: hook_context
|
|
65
78
|
)
|
|
@@ -85,17 +98,90 @@ module Kward
|
|
|
85
98
|
return true
|
|
86
99
|
end
|
|
87
100
|
|
|
101
|
+
shell = bang_shell(agent)
|
|
102
|
+
editor_result = shell.editor_command_result(command)
|
|
103
|
+
if editor_result
|
|
104
|
+
@prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
|
|
105
|
+
open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
|
|
106
|
+
return true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
expanded_command = shell.expand_alias(command, interactive: true)
|
|
110
|
+
run_user_interactive_pty_command(
|
|
111
|
+
expanded_command,
|
|
112
|
+
shell: Ekwsh::DEFAULT_SHELL,
|
|
113
|
+
env: interactive_pty_environment({}, preserve_git_pager: true),
|
|
114
|
+
cwd: interactive_workspace_root(agent),
|
|
115
|
+
intro: "$ #{command}\n"
|
|
116
|
+
)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_captured_shell_command(command, agent)
|
|
121
|
+
command = command.to_s.strip
|
|
122
|
+
if command.empty?
|
|
123
|
+
runtime_output("Usage: /capture <command>")
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
88
127
|
run_busy_local_command_and_requeue(activity: "running") do
|
|
89
|
-
result =
|
|
128
|
+
result = Workspace.new(root: interactive_workspace_root(agent)).run_shell_command(command)
|
|
90
129
|
@prompt.say("\n#{colored("Shell>", :cyan, :bold)} #{command}\n#{result}\n")
|
|
91
130
|
end
|
|
92
|
-
true
|
|
93
131
|
end
|
|
94
132
|
|
|
95
133
|
def shell_command_input?(input)
|
|
96
134
|
input.to_s.start_with?("!")
|
|
97
135
|
end
|
|
98
136
|
|
|
137
|
+
def complete_bang_command(input, cursor, agent)
|
|
138
|
+
value = input.to_s
|
|
139
|
+
return false unless value.start_with?("!")
|
|
140
|
+
return nil if cursor.to_i <= 1
|
|
141
|
+
|
|
142
|
+
completion = bang_shell(agent).complete(value[1..], cursor.to_i - 1)
|
|
143
|
+
return nil unless completion
|
|
144
|
+
|
|
145
|
+
Ekwsh::Completion.new(
|
|
146
|
+
range: (completion.range.begin + 1)...(completion.range.end + 1),
|
|
147
|
+
replacement: completion.replacement,
|
|
148
|
+
candidates: completion.candidates
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bang_shell(agent)
|
|
153
|
+
root = File.expand_path(interactive_workspace_root(agent).to_s)
|
|
154
|
+
aliases = ConfigFiles.read_ekwsh_config[:aliases]
|
|
155
|
+
cache_key = [root, ENV.fetch("PATH", ""), aliases.sort]
|
|
156
|
+
return @bang_shell if @bang_shell_key == cache_key
|
|
157
|
+
|
|
158
|
+
@bang_shell_key = cache_key
|
|
159
|
+
@bang_shell = Ekwsh.new(cwd: root, env: ENV.to_h, aliases: aliases)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def install_bang_completion_provider(agent)
|
|
163
|
+
return unless @prompt.respond_to?(:update_completion_provider)
|
|
164
|
+
|
|
165
|
+
@bang_completion_provider_installed = true
|
|
166
|
+
provider = lambda do |input, cursor|
|
|
167
|
+
current_agent = if respond_to?(:active_tab, true) && active_tab&.agent
|
|
168
|
+
active_tab.agent
|
|
169
|
+
else
|
|
170
|
+
agent
|
|
171
|
+
end
|
|
172
|
+
complete_bang_command(input, cursor, current_agent)
|
|
173
|
+
end
|
|
174
|
+
@prompt.update_completion_provider(provider)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def clear_bang_completion_provider
|
|
178
|
+
return unless @bang_completion_provider_installed
|
|
179
|
+
return unless @prompt.respond_to?(:update_completion_provider)
|
|
180
|
+
|
|
181
|
+
@prompt.update_completion_provider(nil)
|
|
182
|
+
@bang_completion_provider_installed = false
|
|
183
|
+
end
|
|
184
|
+
|
|
99
185
|
def run_ekwsh(agent)
|
|
100
186
|
unless @prompt.respond_to?(:ask)
|
|
101
187
|
runtime_output("The embedded shell is only available in interactive mode.")
|
|
@@ -139,29 +225,82 @@ module Kward
|
|
|
139
225
|
end
|
|
140
226
|
|
|
141
227
|
config = ConfigFiles.read_ekwsh_config
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
228
|
+
run_user_interactive_pty_command(
|
|
229
|
+
command,
|
|
230
|
+
shell: config[:shell],
|
|
231
|
+
env: interactive_pty_environment(config[:env]),
|
|
232
|
+
cwd: interactive_workspace_root(agent)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
|
|
237
|
+
intro_message = intro || "$ #{command}\n"
|
|
238
|
+
if @prompt.respond_to?(:write_transcript)
|
|
239
|
+
@prompt.write_transcript(intro_message)
|
|
240
|
+
elsif @prompt.respond_to?(:say)
|
|
241
|
+
@prompt.say(intro_message)
|
|
242
|
+
end
|
|
243
|
+
output = +"".b
|
|
244
|
+
output_truncated = false
|
|
245
|
+
handoff_options = {}
|
|
246
|
+
handoff_options[:preserve_composer] = true if frozen_composer_command?(command)
|
|
247
|
+
result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd, **handoff_options) do |chunk|
|
|
248
|
+
remaining = MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES - output.bytesize
|
|
249
|
+
if chunk.bytesize > remaining
|
|
250
|
+
output << chunk.byteslice(0, remaining) if remaining.positive?
|
|
251
|
+
output_truncated = true
|
|
252
|
+
else
|
|
253
|
+
output << chunk
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
@prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
|
|
257
|
+
transcript_output = terminal_transcript_output(output, result, truncated: output_truncated)
|
|
258
|
+
if transcript_output && @prompt.respond_to?(:record_transient_terminal_output)
|
|
259
|
+
@prompt.record_transient_terminal_output(transcript_output)
|
|
260
|
+
end
|
|
261
|
+
result
|
|
147
262
|
rescue Errno::ENOENT => e
|
|
148
263
|
runtime_output("Error: #{e.message}")
|
|
264
|
+
nil
|
|
149
265
|
end
|
|
150
266
|
|
|
151
|
-
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:)
|
|
267
|
+
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, preserve_composer: false, &block)
|
|
152
268
|
runner = InteractivePtyRunner.new
|
|
153
269
|
if @prompt.respond_to?(:with_terminal_handoff)
|
|
154
|
-
|
|
155
|
-
|
|
270
|
+
if preserve_composer
|
|
271
|
+
@prompt.with_terminal_handoff(preserve_composer: true) do |input, output|
|
|
272
|
+
runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
|
|
273
|
+
end
|
|
274
|
+
else
|
|
275
|
+
@prompt.with_terminal_handoff do |input, output|
|
|
276
|
+
runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
|
|
277
|
+
end
|
|
156
278
|
end
|
|
157
279
|
else
|
|
158
|
-
runner.run(shell, "-c", command, env: env, cwd: cwd)
|
|
280
|
+
runner.run(shell, "-c", command, env: env, cwd: cwd, &block)
|
|
159
281
|
end
|
|
160
282
|
end
|
|
161
283
|
|
|
162
|
-
def
|
|
284
|
+
def frozen_composer_command?(command)
|
|
285
|
+
FROZEN_COMPOSER_COMMANDS.match?(command.to_s.strip)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def terminal_transcript_output(output, result, truncated:)
|
|
289
|
+
return if truncated || result.input_forwarded
|
|
290
|
+
|
|
291
|
+
text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
|
|
292
|
+
return if text.empty? || text.include?("\r")
|
|
293
|
+
|
|
294
|
+
sanitized = ANSI.sanitize_transcript(text)
|
|
295
|
+
return unless sanitized == text
|
|
296
|
+
return if ANSI.strip_control_sequences(text).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
|
|
297
|
+
|
|
298
|
+
sanitized
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def interactive_pty_environment(configured_env, preserve_git_pager: false)
|
|
163
302
|
ENV.to_h.merge(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)).tap do |env|
|
|
164
|
-
env.delete("GIT_PAGER") if env["GIT_PAGER"] == "cat"
|
|
303
|
+
env.delete("GIT_PAGER") if !preserve_git_pager && env["GIT_PAGER"] == "cat"
|
|
165
304
|
env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
|
|
166
305
|
end
|
|
167
306
|
end
|
|
@@ -248,14 +387,13 @@ module Kward
|
|
|
248
387
|
end
|
|
249
388
|
|
|
250
389
|
def run_ekwsh_interactive_pty_command(shell, result)
|
|
251
|
-
|
|
252
|
-
pty_result = run_interactive_pty_with_terminal_handoff(
|
|
253
|
-
shell.command_shell,
|
|
390
|
+
run_user_interactive_pty_command(
|
|
254
391
|
result.interactive_command,
|
|
392
|
+
shell: shell.command_shell,
|
|
255
393
|
env: shell.child_env(interactive: true),
|
|
256
|
-
cwd: shell.cwd
|
|
394
|
+
cwd: shell.cwd,
|
|
395
|
+
intro: result.output
|
|
257
396
|
)
|
|
258
|
-
@prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
|
|
259
397
|
end
|
|
260
398
|
|
|
261
399
|
def run_ekwsh_command(shell, input)
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Kward
|
|
|
21
21
|
path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
|
|
22
22
|
return nil if path.to_s.empty?
|
|
23
23
|
|
|
24
|
-
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
24
|
+
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
25
25
|
reset_session_diff(@active_session.path)
|
|
26
26
|
track_session(@active_session)
|
|
27
27
|
@resumed_last_session = true
|
|
@@ -99,6 +99,7 @@ module Kward
|
|
|
99
99
|
def start_new_session(session_store)
|
|
100
100
|
return say_sessions_unavailable unless session_store
|
|
101
101
|
|
|
102
|
+
prepare_interactive_project_skills if respond_to?(:prepare_interactive_project_skills, true)
|
|
102
103
|
previous_session = @active_session
|
|
103
104
|
@active_session = track_session(session_store.create)
|
|
104
105
|
reset_session_diff
|
|
@@ -127,7 +128,7 @@ module Kward
|
|
|
127
128
|
|
|
128
129
|
def load_session(session_store, path, message: nil)
|
|
129
130
|
previous_session = @active_session
|
|
130
|
-
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
131
|
+
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
131
132
|
reset_session_diff(@active_session.path)
|
|
132
133
|
track_session(@active_session)
|
|
133
134
|
cleanup_replaced_session(previous_session)
|
|
@@ -396,7 +397,8 @@ module Kward
|
|
|
396
397
|
workspace: configured_workspace(root: session_store.cwd),
|
|
397
398
|
provider: current_model_provider,
|
|
398
399
|
model: current_model_id,
|
|
399
|
-
reasoning_effort: current_reasoning_effort
|
|
400
|
+
reasoning_effort: current_reasoning_effort,
|
|
401
|
+
project_skill_paths: project_skill_paths_for(session_store.cwd) || []
|
|
400
402
|
)
|
|
401
403
|
reset_session_diff(@active_session.path)
|
|
402
404
|
track_session(@active_session)
|
|
@@ -579,7 +581,7 @@ module Kward
|
|
|
579
581
|
end
|
|
580
582
|
|
|
581
583
|
def clone_session_file_from_path(session_store, path)
|
|
582
|
-
source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
584
|
+
source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
583
585
|
clone, = session_store.create_independent_from_conversation(source_conversation, parent_session: source_session)
|
|
584
586
|
clone.path
|
|
585
587
|
end
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -32,6 +32,9 @@ module Kward
|
|
|
32
32
|
run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
|
|
33
33
|
end
|
|
34
34
|
[true, nil]
|
|
35
|
+
when "skills"
|
|
36
|
+
run_busy_local_command_and_requeue { handle_project_skills_command(argument) }
|
|
37
|
+
[true, nil]
|
|
35
38
|
when "redraw"
|
|
36
39
|
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
37
40
|
[true, nil]
|
|
@@ -47,6 +50,9 @@ module Kward
|
|
|
47
50
|
when "shell"
|
|
48
51
|
run_ekwsh(agent)
|
|
49
52
|
[true, nil]
|
|
53
|
+
when "capture"
|
|
54
|
+
run_captured_shell_command(argument, agent)
|
|
55
|
+
[true, nil]
|
|
50
56
|
when "scratchpad"
|
|
51
57
|
open_scratchpad_command(argument)
|
|
52
58
|
[true, nil]
|
|
@@ -345,7 +351,7 @@ module Kward
|
|
|
345
351
|
percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
|
|
346
352
|
"Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
|
|
347
353
|
rescue StandardError => e
|
|
348
|
-
|
|
354
|
+
emit_warning "Auto-compaction status unavailable: #{e.message}"
|
|
349
355
|
nil
|
|
350
356
|
end
|
|
351
357
|
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -194,7 +194,7 @@ module Kward
|
|
|
194
194
|
|
|
195
195
|
def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
|
|
196
196
|
if File.file?(path)
|
|
197
|
-
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
197
|
+
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(workspace_root) || [])
|
|
198
198
|
return [track_session(session), conversation]
|
|
199
199
|
end
|
|
200
200
|
|
|
@@ -215,9 +215,11 @@ module Kward
|
|
|
215
215
|
git_committer = if strict
|
|
216
216
|
->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
|
|
217
217
|
end
|
|
218
|
+
project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
|
|
218
219
|
tool_registry = ToolRegistry.new(
|
|
219
220
|
workspace: workspace,
|
|
220
221
|
prompt: prompt,
|
|
222
|
+
skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
|
|
221
223
|
tool_approval: tab_tool_approval_callback(prompt),
|
|
222
224
|
hook_manager: hook_manager,
|
|
223
225
|
hook_context: hook_context,
|
|
@@ -230,6 +232,7 @@ module Kward
|
|
|
230
232
|
client: @client,
|
|
231
233
|
tool_registry: tool_registry,
|
|
232
234
|
conversation: conversation,
|
|
235
|
+
warning_sink: ConfigFiles.warning_sink,
|
|
233
236
|
hook_manager: hook_manager,
|
|
234
237
|
hook_context: hook_context
|
|
235
238
|
)
|
data/lib/kward/cli.rb
CHANGED
|
@@ -59,6 +59,8 @@ require_relative "cli/sysprompt"
|
|
|
59
59
|
require_relative "cli/stats"
|
|
60
60
|
require_relative "cli/openrouter_commands"
|
|
61
61
|
require_relative "cli/runtime_helpers"
|
|
62
|
+
require_relative "cli/project_skills"
|
|
63
|
+
require_relative "cli/project_skills_commands"
|
|
62
64
|
require_relative "cli/slash_commands"
|
|
63
65
|
require_relative "cli/memory_commands"
|
|
64
66
|
require_relative "cli/hook_commands"
|
|
@@ -94,6 +96,8 @@ module Kward
|
|
|
94
96
|
include CLI::Stats
|
|
95
97
|
include CLI::OpenRouterCommands
|
|
96
98
|
include CLI::RuntimeHelpers
|
|
99
|
+
include CLI::ProjectSkills
|
|
100
|
+
include CLI::ProjectSkillsCommands
|
|
97
101
|
include CLI::SlashCommands
|
|
98
102
|
include CLI::MemoryCommands
|
|
99
103
|
include CLI::HookCommands
|
|
@@ -232,6 +236,16 @@ module Kward
|
|
|
232
236
|
return
|
|
233
237
|
end
|
|
234
238
|
|
|
239
|
+
if @argv.first == "skills"
|
|
240
|
+
if help_option_arguments?(@argv[1..] || [])
|
|
241
|
+
print_command_help("skills")
|
|
242
|
+
return
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
handle_project_skills_cli_command(@argv[1..] || [])
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
235
249
|
if @argv.first == "edit"
|
|
236
250
|
if help_option_arguments?(@argv[1..] || [])
|
|
237
251
|
print_command_help("edit")
|
|
@@ -333,6 +347,7 @@ module Kward
|
|
|
333
347
|
@prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
|
|
334
348
|
ensure
|
|
335
349
|
@prompt.close if @prompt.respond_to?(:close) && prompt_interface?
|
|
350
|
+
clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
|
|
336
351
|
end
|
|
337
352
|
|
|
338
353
|
def run_prompt_or_interactive
|
|
@@ -429,7 +444,8 @@ module Kward
|
|
|
429
444
|
end
|
|
430
445
|
|
|
431
446
|
def interactive_loop(agent: nil)
|
|
432
|
-
setup_interactive_prompt
|
|
447
|
+
setup_interactive_prompt(defer_warnings: true)
|
|
448
|
+
prepare_interactive_project_skills
|
|
433
449
|
session_store = interactive_session_store(agent)
|
|
434
450
|
@resumed_last_session = false
|
|
435
451
|
if session_store && @prompt.respond_to?(:update_tabs)
|
|
@@ -449,8 +465,10 @@ module Kward
|
|
|
449
465
|
|
|
450
466
|
print_visual_banner unless @resumed_last_session || @restored_tabs
|
|
451
467
|
render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
|
|
468
|
+
enable_interactive_warnings if respond_to?(:enable_interactive_warnings, true)
|
|
452
469
|
|
|
453
470
|
@pending_inputs = []
|
|
471
|
+
install_bang_completion_provider(agent)
|
|
454
472
|
|
|
455
473
|
loop do
|
|
456
474
|
if @pending_inputs.empty? && active_tab&.shell
|
|
@@ -542,10 +560,12 @@ module Kward
|
|
|
542
560
|
runtime_output("Goodbye.")
|
|
543
561
|
active_tab&.agent&.conversation || agent&.conversation
|
|
544
562
|
ensure
|
|
563
|
+
clear_bang_completion_provider
|
|
545
564
|
begin
|
|
546
565
|
stop_tabs if respond_to?(:stop_tabs, true)
|
|
547
566
|
@prompt.close if prompt_interface?
|
|
548
567
|
ensure
|
|
568
|
+
clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
|
|
549
569
|
cleanup_unused_sessions
|
|
550
570
|
remember_active_session(session_store)
|
|
551
571
|
end
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -734,9 +734,10 @@ module Kward
|
|
|
734
734
|
AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
|
|
735
735
|
|
|
736
736
|
# Creates an object for conversation compaction.
|
|
737
|
-
def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
|
|
737
|
+
def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil, warning_sink: nil)
|
|
738
738
|
@conversation = conversation
|
|
739
739
|
@client = client
|
|
740
|
+
@warning_sink = warning_sink
|
|
740
741
|
@settings = settings || Compaction::Settings.from_config
|
|
741
742
|
@prompt_builder = Compaction::PromptBuilder.new(
|
|
742
743
|
serializer: Compaction::ConversationSerializer.new(tool_result_summarizer: tool_result_summarizer)
|
|
@@ -791,7 +792,7 @@ module Kward
|
|
|
791
792
|
rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted
|
|
792
793
|
nil
|
|
793
794
|
rescue StandardError => e
|
|
794
|
-
|
|
795
|
+
emit_warning "Auto-compaction failed: #{e.message}"
|
|
795
796
|
nil
|
|
796
797
|
end
|
|
797
798
|
|
|
@@ -814,6 +815,10 @@ module Kward
|
|
|
814
815
|
|
|
815
816
|
private
|
|
816
817
|
|
|
818
|
+
def emit_warning(message)
|
|
819
|
+
@warning_sink ? @warning_sink.call(message) : warn(message)
|
|
820
|
+
end
|
|
821
|
+
|
|
817
822
|
def prepare
|
|
818
823
|
@conversation.refresh_system_message_if_workspace_agents_changed!
|
|
819
824
|
Compaction::Preparation.new(conversation: @conversation, settings: @settings).call
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -66,6 +66,7 @@ module Kward
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
@skip_config = false
|
|
69
|
+
@warning_sink = nil
|
|
69
70
|
|
|
70
71
|
module_function
|
|
71
72
|
|
|
@@ -73,6 +74,14 @@ module Kward
|
|
|
73
74
|
@skip_config = value
|
|
74
75
|
end
|
|
75
76
|
|
|
77
|
+
def warning_sink=(sink)
|
|
78
|
+
@warning_sink = sink
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def warning_sink
|
|
82
|
+
@warning_sink
|
|
83
|
+
end
|
|
84
|
+
|
|
76
85
|
def skip_config?
|
|
77
86
|
@skip_config == true
|
|
78
87
|
end
|
|
@@ -791,13 +800,13 @@ module Kward
|
|
|
791
800
|
|
|
792
801
|
size = File.size(path)
|
|
793
802
|
if size > MAX_PROMPT_FILE_BYTES
|
|
794
|
-
|
|
803
|
+
emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
|
|
795
804
|
return nil
|
|
796
805
|
end
|
|
797
806
|
|
|
798
807
|
File.read(path)
|
|
799
808
|
rescue StandardError => e
|
|
800
|
-
|
|
809
|
+
emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
|
|
801
810
|
nil
|
|
802
811
|
end
|
|
803
812
|
|
|
@@ -908,8 +917,12 @@ module Kward
|
|
|
908
917
|
# Lists configured skills discovered under the config directory.
|
|
909
918
|
#
|
|
910
919
|
# @return [Array<Skill>] skill metadata available to the model
|
|
911
|
-
def skills(workspace_root: Dir.pwd)
|
|
912
|
-
skills_registry(workspace_root: workspace_root).skills
|
|
920
|
+
def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
921
|
+
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
|
|
925
|
+
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
|
|
913
926
|
end
|
|
914
927
|
|
|
915
928
|
# @return [String] trusted user plugin directory
|
|
@@ -923,7 +936,7 @@ module Kward
|
|
|
923
936
|
# workspace or custom `KWARD_CONFIG_PATH` directory.
|
|
924
937
|
#
|
|
925
938
|
# @return [Array<String>] sorted plugin file paths
|
|
926
|
-
def plugin_paths
|
|
939
|
+
def plugin_paths(warning_sink: nil)
|
|
927
940
|
plugins_root = plugin_dir
|
|
928
941
|
return [] unless Dir.exist?(plugins_root)
|
|
929
942
|
|
|
@@ -931,7 +944,7 @@ module Kward
|
|
|
931
944
|
paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
|
|
932
945
|
paths.sort
|
|
933
946
|
rescue StandardError => e
|
|
934
|
-
|
|
947
|
+
emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
|
|
935
948
|
[]
|
|
936
949
|
end
|
|
937
950
|
|
|
@@ -952,7 +965,7 @@ module Kward
|
|
|
952
965
|
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
953
966
|
end
|
|
954
967
|
|
|
955
|
-
def skills_registry(workspace_root: Dir.pwd)
|
|
968
|
+
def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
956
969
|
Skills::Registry.new(
|
|
957
970
|
config_dir: config_dir,
|
|
958
971
|
workspace_root: workspace_root,
|
|
@@ -960,7 +973,9 @@ module Kward
|
|
|
960
973
|
skill_class: Skill,
|
|
961
974
|
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
962
975
|
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
963
|
-
inside_directory: method(:inside_directory?)
|
|
976
|
+
inside_directory: method(:inside_directory?),
|
|
977
|
+
warning_sink: warning_sink || @warning_sink,
|
|
978
|
+
project_skill_paths: project_skill_paths
|
|
964
979
|
)
|
|
965
980
|
end
|
|
966
981
|
|
|
@@ -968,10 +983,16 @@ module Kward
|
|
|
968
983
|
Prompts::Templates.new(
|
|
969
984
|
config_dir: config_dir,
|
|
970
985
|
template_class: PromptTemplate,
|
|
971
|
-
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
|
|
986
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
|
|
987
|
+
warning_sink: @warning_sink
|
|
972
988
|
)
|
|
973
989
|
end
|
|
974
990
|
|
|
991
|
+
def emit_warning(message, warning_sink: nil)
|
|
992
|
+
sink = warning_sink || @warning_sink
|
|
993
|
+
sink ? sink.call(message) : warn(message)
|
|
994
|
+
end
|
|
995
|
+
|
|
975
996
|
def inside_directory?(path, base)
|
|
976
997
|
PathGuard.inside?(path, base)
|
|
977
998
|
end
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -63,13 +63,16 @@ module Kward
|
|
|
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
|
+
# @return [Array<String>, nil] explicitly permitted project skill paths
|
|
67
|
+
attr_reader :project_skill_paths
|
|
66
68
|
|
|
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)
|
|
69
|
+
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, project_skill_paths: nil)
|
|
68
70
|
@workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
|
|
69
71
|
@provider = provider
|
|
70
72
|
@model = model
|
|
71
73
|
@reasoning_effort = reasoning_effort
|
|
72
74
|
@plugin_registry = plugin_registry
|
|
75
|
+
@project_skill_paths = project_skill_paths
|
|
73
76
|
@execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
|
|
74
77
|
@messages = []
|
|
75
78
|
restored_system_message, transcript_messages = split_system_message(messages)
|
|
@@ -78,13 +81,13 @@ module Kward
|
|
|
78
81
|
system_message = restored_system_message
|
|
79
82
|
else
|
|
80
83
|
@last_plugin_prompt_context = 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)
|
|
84
|
+
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, project_skill_paths: @project_skill_paths)
|
|
82
85
|
end
|
|
83
86
|
end
|
|
84
87
|
@system_message = system_message
|
|
85
88
|
@system_message_enabled = !@system_message.nil?
|
|
86
89
|
if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
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
|
|
90
|
+
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, project_skill_paths: @project_skill_paths) : nil
|
|
88
91
|
end
|
|
89
92
|
@compaction_system_message = compaction_system_message
|
|
90
93
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
@@ -188,10 +191,10 @@ module Kward
|
|
|
188
191
|
return nil unless @system_message_enabled
|
|
189
192
|
|
|
190
193
|
@last_plugin_prompt_context = 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)
|
|
194
|
+
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, project_skill_paths: @project_skill_paths)
|
|
192
195
|
@system_message = replacement
|
|
193
196
|
@on_system_message_change&.call(replacement)
|
|
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)
|
|
197
|
+
@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, project_skill_paths: @project_skill_paths)
|
|
195
198
|
@workspace_agents_mtime = workspace_agents_mtime
|
|
196
199
|
@system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
|
|
197
200
|
replacement
|