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/cli/git.rb
CHANGED
|
@@ -14,6 +14,8 @@ module Kward
|
|
|
14
14
|
return
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
previous_git_hook_conversation = @git_hook_conversation
|
|
18
|
+
@git_hook_conversation = agent.conversation
|
|
17
19
|
root = interactive_workspace_root(agent)
|
|
18
20
|
git_root = git_repository_root(root)
|
|
19
21
|
if git_root.empty?
|
|
@@ -33,6 +35,8 @@ module Kward
|
|
|
33
35
|
git_commit(git_root, message)
|
|
34
36
|
end
|
|
35
37
|
print_git_commit_result(result)
|
|
38
|
+
ensure
|
|
39
|
+
@git_hook_conversation = previous_git_hook_conversation
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
def git_repository_root(root)
|
|
@@ -48,7 +52,9 @@ module Kward
|
|
|
48
52
|
output, status = Open3.capture2e("git", "status", "--short", "--untracked-files=normal", chdir: root.to_s)
|
|
49
53
|
return ["Unable to read Git status: #{output.strip}"] unless status.success?
|
|
50
54
|
|
|
51
|
-
output.lines.map(&:chomp)
|
|
55
|
+
lines = output.lines.map(&:chomp)
|
|
56
|
+
git_lifecycle_hook("git_status_after", root: root, payload: { status_count: lines.length })
|
|
57
|
+
lines
|
|
52
58
|
rescue StandardError => e
|
|
53
59
|
["Unable to read Git status: #{e.message}"]
|
|
54
60
|
end
|
|
@@ -70,7 +76,13 @@ module Kward
|
|
|
70
76
|
entry = parse_git_status_line(status_line)
|
|
71
77
|
return { path: "Git diff", content: "Unable to read Git status entry.\n" } if entry.nil?
|
|
72
78
|
|
|
79
|
+
before = git_lifecycle_hook("git_diff_before", root: root, payload: { path: entry[:path], untracked: entry[:untracked] })
|
|
80
|
+
if before.denied? || before.approval_required?
|
|
81
|
+
return { path: entry[:path], content: "Declined: #{before.decision.message || "git diff denied"}\n" }
|
|
82
|
+
end
|
|
83
|
+
|
|
73
84
|
output = entry[:untracked] ? git_untracked_file_diff(root, entry[:path]) : git_tracked_file_diff(root, entry[:path])
|
|
85
|
+
git_lifecycle_hook("git_diff_after", root: root, payload: { path: entry[:path], untracked: entry[:untracked], bytes: output.bytesize })
|
|
74
86
|
{ path: entry[:path], content: output.empty? ? "No diff for #{entry[:path]}\n" : output }
|
|
75
87
|
end
|
|
76
88
|
|
|
@@ -96,8 +108,13 @@ module Kward
|
|
|
96
108
|
entry = parse_git_status_line(status_line)
|
|
97
109
|
return if entry.nil?
|
|
98
110
|
|
|
111
|
+
action = entry[:staged] ? "unstage" : "stage"
|
|
112
|
+
before = git_lifecycle_hook("git_stage_before", root: root, payload: { path: entry[:path], action: action })
|
|
113
|
+
return if before.denied? || before.approval_required?
|
|
114
|
+
|
|
99
115
|
command = entry[:staged] ? ["restore", "--staged", "--", entry[:path]] : ["add", "--", entry[:path]]
|
|
100
|
-
Open3.capture2e("git", *command, chdir: root.to_s)
|
|
116
|
+
output, status = Open3.capture2e("git", *command, chdir: root.to_s)
|
|
117
|
+
git_lifecycle_hook("git_stage_after", root: root, payload: { path: entry[:path], action: action, success: status.success?, output: output })
|
|
101
118
|
rescue StandardError
|
|
102
119
|
nil
|
|
103
120
|
end
|
|
@@ -115,6 +132,9 @@ module Kward
|
|
|
115
132
|
end
|
|
116
133
|
|
|
117
134
|
def git_commit(root, message)
|
|
135
|
+
before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
|
|
136
|
+
return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
|
|
137
|
+
|
|
118
138
|
return git_commit_staged(root, message) if git_staged_changes?(root)
|
|
119
139
|
|
|
120
140
|
add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
|
|
@@ -134,11 +154,18 @@ module Kward
|
|
|
134
154
|
|
|
135
155
|
def git_commit_staged(root, message)
|
|
136
156
|
commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
|
|
137
|
-
{ success: commit_status.success?, output: commit_output }
|
|
157
|
+
result = { success: commit_status.success?, output: commit_output }
|
|
158
|
+
git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
|
|
159
|
+
result
|
|
138
160
|
rescue StandardError => e
|
|
139
161
|
{ success: false, output: e.message }
|
|
140
162
|
end
|
|
141
163
|
|
|
164
|
+
def git_lifecycle_hook(name, root:, payload: {})
|
|
165
|
+
conversation = @git_hook_conversation || new_conversation(workspace_root: root.to_s)
|
|
166
|
+
run_lifecycle_hook(name, conversation: conversation, payload: { root: root.to_s }.merge(payload))
|
|
167
|
+
end
|
|
168
|
+
|
|
142
169
|
def print_git_commit_result(result)
|
|
143
170
|
output = result[:output].to_s.strip
|
|
144
171
|
output = result[:success] ? "Commit created." : "Git commit failed." if output.empty?
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
7
|
+
class CLI
|
|
8
|
+
# Interactive lifecycle hook inspection commands.
|
|
9
|
+
module HookCommands
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def handle_hooks_command(argument)
|
|
13
|
+
subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
|
|
14
|
+
subcommand = "list" if subcommand.to_s.empty?
|
|
15
|
+
|
|
16
|
+
case subcommand
|
|
17
|
+
when "list"
|
|
18
|
+
print_hooks_list
|
|
19
|
+
when "events"
|
|
20
|
+
print_hooks_events
|
|
21
|
+
when "logs"
|
|
22
|
+
print_hooks_logs(rest)
|
|
23
|
+
when "doctor"
|
|
24
|
+
print_hooks_doctor
|
|
25
|
+
when "trust"
|
|
26
|
+
trust_workspace_hooks
|
|
27
|
+
when "untrust"
|
|
28
|
+
untrust_workspace_hooks
|
|
29
|
+
else
|
|
30
|
+
runtime_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def print_hooks_list
|
|
35
|
+
lines = ["Lifecycle hooks"]
|
|
36
|
+
entries = configured_hook_entries + workspace_hook_entries + plugin_hook_entries
|
|
37
|
+
if entries.empty?
|
|
38
|
+
lines << "No lifecycle hooks are configured."
|
|
39
|
+
else
|
|
40
|
+
entries.each do |entry|
|
|
41
|
+
details = [entry.fetch(:id), entry.fetch(:event)]
|
|
42
|
+
details << "source=#{entry.fetch(:source)}"
|
|
43
|
+
details << "order=#{entry.fetch(:order)}"
|
|
44
|
+
details << "failure_policy=#{entry[:failure_policy]}" if entry[:failure_policy]
|
|
45
|
+
details << "disabled" if entry[:disabled]
|
|
46
|
+
lines << "- #{details.join(' ')}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
runtime_output(lines.join("\n"))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def print_hooks_events
|
|
53
|
+
lines = ["Lifecycle hook events"]
|
|
54
|
+
Hooks::Catalog.event_names.each do |event_name|
|
|
55
|
+
definition = Hooks::Catalog.definition(event_name)
|
|
56
|
+
fields = Array(definition&.modifiable_fields)
|
|
57
|
+
suffix = fields.empty? ? "" : " modifies=#{fields.join(',')}"
|
|
58
|
+
lines << "- #{event_name} failure_policy=#{Hooks::Catalog.failure_policy(event_name)}#{suffix}"
|
|
59
|
+
end
|
|
60
|
+
runtime_output(lines.join("\n"))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_hooks_logs(argument)
|
|
64
|
+
count = argument.to_s.strip.empty? ? 20 : argument.to_i
|
|
65
|
+
count = 20 unless count.positive?
|
|
66
|
+
path = hooks_log_path
|
|
67
|
+
unless File.file?(path)
|
|
68
|
+
runtime_output("No lifecycle hook audit log found at #{path}.")
|
|
69
|
+
return
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
records = File.readlines(path, chomp: true).last(count).filter_map do |line|
|
|
73
|
+
JSON.parse(line)
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
if records.empty?
|
|
78
|
+
runtime_output("No readable lifecycle hook audit records found at #{path}.")
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
lines = ["Lifecycle hook audit log: #{path}"]
|
|
83
|
+
records.each do |record|
|
|
84
|
+
lines << format_hook_log_record(record)
|
|
85
|
+
end
|
|
86
|
+
runtime_output(lines.join("\n"))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def print_hooks_doctor
|
|
90
|
+
lines = ["Lifecycle hook diagnostics"]
|
|
91
|
+
begin
|
|
92
|
+
diagnostics = hook_config_diagnostics(ConfigFiles.read_config, "config") + hook_config_diagnostics(ConfigFiles.read_trusted_workspace_hooks_config(current_workspace_root), "workspace")
|
|
93
|
+
manager = diagnostics.empty? ? Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(current_workspace_root)).manager : nil
|
|
94
|
+
lines << "Config and trusted workspace hooks: #{manager ? manager.handlers.length : "not loaded due diagnostics"}"
|
|
95
|
+
lines << "Plugin hooks: #{plugin_registry.hook_handlers.length}"
|
|
96
|
+
lines << "Audit log: #{hooks_log_path}"
|
|
97
|
+
lines << "Workspace hook config: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}"
|
|
98
|
+
lines << "Workspace hooks trusted: #{ConfigFiles.workspace_hooks_trusted?(current_workspace_root)}"
|
|
99
|
+
lines << "Known events: #{Hooks::Catalog.event_names.length}"
|
|
100
|
+
if diagnostics.empty?
|
|
101
|
+
lines << "OK"
|
|
102
|
+
else
|
|
103
|
+
lines.concat(diagnostics.map { |diagnostic| "Warning: #{diagnostic}" })
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
lines << "Error: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
runtime_output(lines.join("\n"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def configured_hook_entries
|
|
112
|
+
hook_entries_from_config(ConfigFiles.read_config, "config")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def workspace_hook_entries
|
|
116
|
+
hook_entries_from_config(ConfigFiles.read_trusted_workspace_hooks_config(current_workspace_root), "workspace")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def plugin_hook_entries
|
|
120
|
+
plugin_registry.hook_handlers.map do |hook|
|
|
121
|
+
{
|
|
122
|
+
id: hook.id,
|
|
123
|
+
event: hook.event,
|
|
124
|
+
source: hook.path || "plugin",
|
|
125
|
+
order: hook.order,
|
|
126
|
+
failure_policy: hook.failure_policy,
|
|
127
|
+
disabled: false
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def hook_entries_from_config(config, source)
|
|
133
|
+
hooks = config["hooks"] || config[:hooks]
|
|
134
|
+
return [] unless hooks.is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
hooks.flat_map do |event, entries|
|
|
137
|
+
Array(entries).each_with_index.map do |entry, index|
|
|
138
|
+
entry = entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { "command" => entry.to_s }
|
|
139
|
+
{
|
|
140
|
+
id: entry["id"] || "#{source}:#{event}:#{index + 1}",
|
|
141
|
+
event: event.to_s,
|
|
142
|
+
source: source,
|
|
143
|
+
order: entry.fetch("order", 100),
|
|
144
|
+
failure_policy: entry["failure_policy"],
|
|
145
|
+
disabled: truthy_hook_value?(entry["disabled"])
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def hook_config_diagnostics(config, source)
|
|
152
|
+
hooks = config["hooks"] || config[:hooks]
|
|
153
|
+
return [] unless hooks.is_a?(Hash)
|
|
154
|
+
|
|
155
|
+
hooks.flat_map do |event, entries|
|
|
156
|
+
Array(entries).each_with_index.flat_map do |entry, index|
|
|
157
|
+
validate_hook_entry(event.to_s, normalize_hook_entry(entry), source, index + 1)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_hook_entry(entry)
|
|
163
|
+
entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { "type" => "command", "command" => entry.to_s }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_hook_entry(event, entry, source, number)
|
|
167
|
+
label = entry["id"] || "#{source}:#{event}:#{number}"
|
|
168
|
+
diagnostics = []
|
|
169
|
+
diagnostics << "#{label}: unknown event #{event}" unless Hooks::Catalog.known?(event)
|
|
170
|
+
diagnostics.concat(validate_hook_failure_policy(label, entry))
|
|
171
|
+
diagnostics.concat(validate_hook_timeout(label, entry))
|
|
172
|
+
diagnostics.concat(validate_hook_type(label, entry))
|
|
173
|
+
diagnostics
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_hook_failure_policy(label, entry)
|
|
177
|
+
policy = entry["failure_policy"]
|
|
178
|
+
return [] if policy.to_s.empty? || Hooks::Catalog::VALID_FAILURE_POLICIES.include?(policy.to_s)
|
|
179
|
+
|
|
180
|
+
["#{label}: unknown failure_policy #{policy}"]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_hook_timeout(label, entry)
|
|
184
|
+
value = entry["timeout_seconds"]
|
|
185
|
+
return [] if value.nil? || value.to_f.positive?
|
|
186
|
+
|
|
187
|
+
["#{label}: timeout_seconds must be positive"]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_hook_type(label, entry)
|
|
191
|
+
type = (entry["type"] || "command").to_s
|
|
192
|
+
case type
|
|
193
|
+
when "command"
|
|
194
|
+
validate_command_hook(label, entry)
|
|
195
|
+
when "http"
|
|
196
|
+
validate_http_hook(label, entry)
|
|
197
|
+
else
|
|
198
|
+
["#{label}: unsupported hook type #{type}"]
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_command_hook(label, entry)
|
|
203
|
+
command = entry["command"].to_s.strip
|
|
204
|
+
return ["#{label}: command is required"] if command.empty?
|
|
205
|
+
|
|
206
|
+
executable = command.split(/\s+/, 2).first
|
|
207
|
+
return [] if executable.include?(File::SEPARATOR) ? File.executable?(File.expand_path(executable)) : system_command_available?(executable)
|
|
208
|
+
|
|
209
|
+
["#{label}: command executable not found: #{executable}"]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def validate_http_hook(label, entry)
|
|
213
|
+
uri = URI.parse(entry["url"].to_s)
|
|
214
|
+
return [] if %w[http https].include?(uri.scheme) && uri.host
|
|
215
|
+
|
|
216
|
+
["#{label}: url must be http or https"]
|
|
217
|
+
rescue URI::InvalidURIError
|
|
218
|
+
["#{label}: url must be http or https"]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def system_command_available?(name)
|
|
222
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
223
|
+
File.executable?(File.join(dir, name))
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def trust_workspace_hooks
|
|
228
|
+
ConfigFiles.trust_workspace_hooks!(current_workspace_root)
|
|
229
|
+
runtime_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
runtime_output("Workspace hook trust error: #{e.message}")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def untrust_workspace_hooks
|
|
235
|
+
ConfigFiles.untrust_workspace_hooks!(current_workspace_root)
|
|
236
|
+
runtime_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
runtime_output("Workspace hook trust error: #{e.message}")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def hooks_log_path
|
|
242
|
+
File.join(ConfigFiles.config_dir, "logs", "hooks.jsonl")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def format_hook_log_record(record)
|
|
246
|
+
parts = [record["timestamp"], record["kind"], record["event"]].compact
|
|
247
|
+
parts << "hook=#{record['hook_id']}" if record["hook_id"]
|
|
248
|
+
parts << "decision=#{record['decision']}" if record["decision"]
|
|
249
|
+
parts << record["message"] if record["message"]
|
|
250
|
+
"- #{parts.join(' ')}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def truthy_hook_value?(value)
|
|
254
|
+
value == true || value.to_s == "true"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -102,11 +102,7 @@ module Kward
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def prepare_memory_context(conversation, input)
|
|
105
|
-
|
|
106
|
-
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
107
|
-
conversation.last_memory_retrieval = retrieval
|
|
108
|
-
conversation.memory_context = manager.memory_block(retrieval)
|
|
109
|
-
conversation.refresh_system_message!
|
|
105
|
+
Memory::TurnContext.apply(conversation: conversation, input: input)
|
|
110
106
|
rescue StandardError => e
|
|
111
107
|
warn "Memory retrieval failed: #{e.message}"
|
|
112
108
|
nil
|
data/lib/kward/cli/plugins.rb
CHANGED
|
@@ -37,6 +37,39 @@ module Kward
|
|
|
37
37
|
runtime_output("Plugins reloaded.")
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def lifecycle_hook_manager(conversation)
|
|
41
|
+
manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(conversation.workspace_root)).manager
|
|
42
|
+
plugin_registry.hook_handlers.each do |hook|
|
|
43
|
+
manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
|
|
44
|
+
hook.handler.call(event, context)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
manager
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def lifecycle_hook_context(conversation)
|
|
51
|
+
plugin_context(conversation, "")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_lifecycle_hook(name, conversation:, payload: {}, session: @active_session)
|
|
55
|
+
lifecycle_hook_manager(conversation).run(Hooks::Event.new(
|
|
56
|
+
name: name,
|
|
57
|
+
session: session_payload(session),
|
|
58
|
+
workspace: { root: conversation.respond_to?(:workspace_root) ? conversation.workspace_root : current_workspace_root },
|
|
59
|
+
payload: payload
|
|
60
|
+
), context: lifecycle_hook_context(conversation))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def session_payload(session)
|
|
64
|
+
return {} unless session
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
id: session.respond_to?(:id) ? session.id : nil,
|
|
68
|
+
name: session.respond_to?(:name) ? session.name : nil,
|
|
69
|
+
path: session.respond_to?(:path) ? session.path : nil
|
|
70
|
+
}.compact
|
|
71
|
+
end
|
|
72
|
+
|
|
40
73
|
def reserved_slash_command_names
|
|
41
74
|
builtin_slash_command_names + prompt_templates.map(&:command)
|
|
42
75
|
end
|
|
@@ -76,9 +109,16 @@ module Kward
|
|
|
76
109
|
argument_hint: template.argument_hint
|
|
77
110
|
}
|
|
78
111
|
end
|
|
112
|
+
skill_entries = ConfigFiles.skills.map do |skill|
|
|
113
|
+
{
|
|
114
|
+
name: "skill:#{skill.name}",
|
|
115
|
+
description: skill.description,
|
|
116
|
+
argument_hint: ""
|
|
117
|
+
}
|
|
118
|
+
end
|
|
79
119
|
plugin_entries = plugin_commands.map(&:entry)
|
|
80
120
|
interactive_entries = interactive_commands.map(&:entry)
|
|
81
|
-
builtin_slash_commands + prompt_entries + plugin_entries + interactive_entries
|
|
121
|
+
builtin_slash_commands + prompt_entries + skill_entries + plugin_entries + interactive_entries
|
|
82
122
|
end
|
|
83
123
|
|
|
84
124
|
def prompt_template_for(command)
|
|
@@ -37,7 +37,9 @@ module Kward
|
|
|
37
37
|
editor_bar_cursor: ConfigFiles.editor_bar_cursor?,
|
|
38
38
|
editor_bar_cursor_source: -> { ConfigFiles.editor_bar_cursor? },
|
|
39
39
|
editor_line_numbers: ConfigFiles.editor_line_numbers,
|
|
40
|
-
editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers }
|
|
40
|
+
editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
|
|
41
|
+
diff_view: ConfigFiles.diff_view,
|
|
42
|
+
diff_view_source: -> { ConfigFiles.diff_view }
|
|
41
43
|
)
|
|
42
44
|
if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
|
|
43
45
|
@prompt.start(render: false)
|
|
@@ -69,11 +71,13 @@ module Kward
|
|
|
69
71
|
return unless @prompt.respond_to?(:print_visual_banner)
|
|
70
72
|
|
|
71
73
|
@prompt.print_visual_banner(startup_info_screen)
|
|
74
|
+
refresh_startup_update_check if prompt_interface?
|
|
72
75
|
end
|
|
73
76
|
|
|
74
77
|
def startup_info_screen
|
|
75
78
|
[
|
|
76
79
|
startup_status_line,
|
|
80
|
+
*startup_update_notice_lines,
|
|
77
81
|
"",
|
|
78
82
|
startup_info_line("Workspace", startup_workspace_label),
|
|
79
83
|
startup_info_line("Branch", startup_branch_value),
|
|
@@ -116,7 +120,32 @@ module Kward
|
|
|
116
120
|
end
|
|
117
121
|
|
|
118
122
|
def startup_status_line
|
|
119
|
-
|
|
123
|
+
color = startup_update_notice ? :yellow : :green
|
|
124
|
+
"#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def startup_update_notice_lines
|
|
128
|
+
notice = startup_update_notice
|
|
129
|
+
return [] unless notice
|
|
130
|
+
|
|
131
|
+
[
|
|
132
|
+
" New version available: #{notice.latest_version}",
|
|
133
|
+
" Run: gem update kward"
|
|
134
|
+
]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def startup_update_notice
|
|
138
|
+
return @startup_update_notice if defined?(@startup_update_notice)
|
|
139
|
+
|
|
140
|
+
@startup_update_check = UpdateCheck.new(current_version: Kward::VERSION)
|
|
141
|
+
@startup_update_notice = @startup_update_check.notice
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def refresh_startup_update_check
|
|
145
|
+
checker = @startup_update_check || UpdateCheck.new(current_version: Kward::VERSION)
|
|
146
|
+
return unless checker.enabled?
|
|
147
|
+
|
|
148
|
+
Thread.new { checker.refresh_if_stale }
|
|
120
149
|
end
|
|
121
150
|
|
|
122
151
|
def startup_info_line(label, value)
|
|
@@ -218,7 +247,7 @@ module Kward
|
|
|
218
247
|
def composer_context_usage(provider, model)
|
|
219
248
|
context_window = composer_context_window(provider, model)
|
|
220
249
|
context_parts = if @client.respond_to?(:current_context_parts)
|
|
221
|
-
@client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas)
|
|
250
|
+
@client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas, provider: provider, model: model)
|
|
222
251
|
else
|
|
223
252
|
{ provider: provider, model: model, messages: current_footer_conversation.context_messages, tools: footer_tool_schemas }
|
|
224
253
|
end
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -52,19 +52,25 @@ module Kward
|
|
|
52
52
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
53
53
|
workspace = configured_workspace(root: conversation.workspace_root)
|
|
54
54
|
writer_id = worker_writer_id(role)
|
|
55
|
+
hook_manager = lifecycle_hook_manager(conversation)
|
|
56
|
+
hook_context = lifecycle_hook_context(conversation)
|
|
55
57
|
tool_registry = ToolRegistry.new(
|
|
56
58
|
workspace: workspace,
|
|
57
59
|
prompt: @prompt,
|
|
58
60
|
allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
|
|
59
61
|
write_lock: @worker_write_lock,
|
|
60
|
-
writer_id: writer_id
|
|
62
|
+
writer_id: writer_id,
|
|
63
|
+
hook_manager: hook_manager,
|
|
64
|
+
hook_context: hook_context
|
|
61
65
|
)
|
|
62
66
|
@footer_conversation = conversation
|
|
63
67
|
@footer_tool_registry = tool_registry
|
|
64
68
|
Agent.new(
|
|
65
69
|
client: @client,
|
|
66
70
|
tool_registry: tool_registry,
|
|
67
|
-
conversation: conversation
|
|
71
|
+
conversation: conversation,
|
|
72
|
+
hook_manager: hook_manager,
|
|
73
|
+
hook_context: hook_context
|
|
68
74
|
)
|
|
69
75
|
end
|
|
70
76
|
|
|
@@ -356,7 +362,7 @@ module Kward
|
|
|
356
362
|
queued_inputs = []
|
|
357
363
|
result = nil
|
|
358
364
|
error = nil
|
|
359
|
-
|
|
365
|
+
begin_local_busy_command(activity)
|
|
360
366
|
|
|
361
367
|
worker = Thread.new do
|
|
362
368
|
result = yield
|
|
@@ -365,7 +371,8 @@ module Kward
|
|
|
365
371
|
end
|
|
366
372
|
|
|
367
373
|
while worker.alive?
|
|
368
|
-
collect_queued_input(queued_inputs)
|
|
374
|
+
poll_result = collect_queued_input(queued_inputs)
|
|
375
|
+
handle_busy_local_tab_action(poll_result, activity: activity) if busy_local_tab_action?(poll_result)
|
|
369
376
|
sleep 0.02
|
|
370
377
|
end
|
|
371
378
|
worker.join
|
|
@@ -374,7 +381,7 @@ module Kward
|
|
|
374
381
|
|
|
375
382
|
[result, queued_inputs]
|
|
376
383
|
ensure
|
|
377
|
-
|
|
384
|
+
finish_local_busy_command(activity)
|
|
378
385
|
end
|
|
379
386
|
|
|
380
387
|
def run_busy_local_command_and_requeue(activity: "loading")
|
|
@@ -385,6 +392,38 @@ module Kward
|
|
|
385
392
|
result
|
|
386
393
|
end
|
|
387
394
|
|
|
395
|
+
def begin_local_busy_command(activity)
|
|
396
|
+
active_tab.local_busy_activity = activity if active_tab
|
|
397
|
+
@prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
|
|
398
|
+
update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def finish_local_busy_command(activity)
|
|
402
|
+
tab = local_busy_tab(activity)
|
|
403
|
+
tab.local_busy_activity = nil if tab
|
|
404
|
+
update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
|
|
405
|
+
@prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input) && (!active_tab || active_tab == tab)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def local_busy_tab(activity)
|
|
409
|
+
Array(@tabs).find { |tab| tab.local_busy_activity.to_s == activity.to_s }
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def busy_local_tab_action?(poll_result)
|
|
413
|
+
poll_result.is_a?(Hash) && poll_result[:tab_action]
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def handle_busy_local_tab_action(action, activity:)
|
|
417
|
+
return unless respond_to?(:handle_tab_action, true)
|
|
418
|
+
|
|
419
|
+
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
420
|
+
handle_tab_action(action, @session_store)
|
|
421
|
+
tab = active_tab
|
|
422
|
+
return unless tab&.local_busy?
|
|
423
|
+
|
|
424
|
+
@prompt.begin_busy_input("You>", activity: tab.local_busy_activity) if @prompt.respond_to?(:begin_busy_input)
|
|
425
|
+
end
|
|
426
|
+
|
|
388
427
|
def current_workspace_root
|
|
389
428
|
return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
|
|
390
429
|
return @working_directory if @working_directory
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -49,6 +49,7 @@ module Kward
|
|
|
49
49
|
reset_session_diff
|
|
50
50
|
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
51
51
|
@active_session.attach(conversation)
|
|
52
|
+
run_lifecycle_hook("session_create", conversation: conversation, payload: { action: "create" })
|
|
52
53
|
build_interactive_agent(conversation)
|
|
53
54
|
end
|
|
54
55
|
|
|
@@ -95,6 +96,7 @@ module Kward
|
|
|
95
96
|
cleanup_replaced_session(previous_session)
|
|
96
97
|
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
97
98
|
@active_session.attach(conversation)
|
|
99
|
+
run_lifecycle_hook("session_create", conversation: conversation, payload: { action: "create" })
|
|
98
100
|
update_assistant_prompt(conversation)
|
|
99
101
|
clear_prompt_transcript
|
|
100
102
|
print_visual_banner
|
|
@@ -125,6 +127,7 @@ module Kward
|
|
|
125
127
|
runtime_output("#{message}: #{@active_session.path}") if message
|
|
126
128
|
render_conversation_transcript(conversation)
|
|
127
129
|
end
|
|
130
|
+
run_lifecycle_hook("session_resume", conversation: conversation, payload: { action: "resume", path: @active_session.path })
|
|
128
131
|
agent = build_interactive_agent(conversation)
|
|
129
132
|
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
130
133
|
agent
|
|
@@ -413,7 +416,9 @@ module Kward
|
|
|
413
416
|
return
|
|
414
417
|
end
|
|
415
418
|
|
|
419
|
+
old_name = @active_session.name
|
|
416
420
|
@active_session.rename(name)
|
|
421
|
+
run_lifecycle_hook("session_rename", conversation: new_conversation(workspace_root: current_workspace_root), session: @active_session, payload: { old_name: old_name, new_name: @active_session.name })
|
|
417
422
|
label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
|
|
418
423
|
runtime_output(label)
|
|
419
424
|
end
|
|
@@ -425,6 +430,7 @@ module Kward
|
|
|
425
430
|
@active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
|
|
426
431
|
reset_session_diff(@active_session.path)
|
|
427
432
|
cleanup_replaced_session(previous_session)
|
|
433
|
+
run_lifecycle_hook("session_clone", conversation: agent.conversation, payload: { source_path: previous_session&.path, path: @active_session.path })
|
|
428
434
|
runtime_output("Cloned session: #{@active_session.path}")
|
|
429
435
|
render_conversation_transcript(agent.conversation)
|
|
430
436
|
agent
|
|
@@ -497,6 +503,7 @@ module Kward
|
|
|
497
503
|
reset_session_diff(@active_session.path)
|
|
498
504
|
cleanup_replaced_session(previous_session)
|
|
499
505
|
update_assistant_prompt(conversation)
|
|
506
|
+
run_lifecycle_hook("session_fork", conversation: conversation, payload: { source_path: previous_session.path, path: @active_session.path, entry_id: point.dig(:entry, "id") })
|
|
500
507
|
restore_prompt_transcript do
|
|
501
508
|
runtime_output("Forked session: #{@active_session.path}")
|
|
502
509
|
render_conversation_transcript(conversation)
|
|
@@ -679,7 +686,13 @@ module Kward
|
|
|
679
686
|
|
|
680
687
|
def export_session(conversation, argument)
|
|
681
688
|
path = export_path(argument)
|
|
689
|
+
before = run_lifecycle_hook("session_export_before", conversation: conversation, payload: { path: path })
|
|
690
|
+
if before.denied? || before.approval_required?
|
|
691
|
+
runtime_output("Declined: #{before.decision.message || "session export denied"}")
|
|
692
|
+
return
|
|
693
|
+
end
|
|
682
694
|
File.write(path, markdown_transcript(conversation))
|
|
695
|
+
run_lifecycle_hook("session_export_after", conversation: conversation, payload: { path: path })
|
|
683
696
|
runtime_output("Exported session: #{path}")
|
|
684
697
|
rescue StandardError => e
|
|
685
698
|
runtime_output("Error: #{e.message}")
|