kward 0.74.0 → 0.76.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 +47 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +75 -10
- 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 +2 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +111 -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 +77 -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/interactive_turn.rb +3 -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 +28 -7
- data/lib/kward/cli/rendering.rb +8 -4
- 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 +21 -18
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +120 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +2 -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 +23 -52
- 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 +38 -5
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +183 -18
- data/lib/kward/pan/index.html.erb +1 -0
- data/lib/kward/pan/server.rb +53 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +88 -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/transcript_renderer.rb +1 -1
- 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 +95 -15
- data/lib/kward/rpc/session_manager.rb +260 -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 +19 -20
- 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 +122 -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/compactor.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "model/chat_invocation"
|
|
3
3
|
require_relative "compaction/file_operation_tracker"
|
|
4
|
+
require_relative "compaction/token_estimator"
|
|
4
5
|
require_relative "config_files"
|
|
5
6
|
require_relative "message_access"
|
|
6
7
|
require_relative "prompts"
|
|
@@ -74,111 +75,6 @@ module Kward
|
|
|
74
75
|
end
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
# Compaction support object used by conversation summarization.
|
|
78
|
-
class TokenEstimator
|
|
79
|
-
def estimate_tokens(text)
|
|
80
|
-
(text.to_s.length / 4.0).ceil
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def messages_tokens(messages)
|
|
84
|
-
Array(messages).sum { |message| message_tokens(message) }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def context_tokens(messages)
|
|
88
|
-
messages = Array(messages)
|
|
89
|
-
usage_info = last_assistant_usage_info(messages)
|
|
90
|
-
return messages_tokens(messages) unless usage_info
|
|
91
|
-
|
|
92
|
-
usage_tokens = usage_tokens(usage_info[:usage])
|
|
93
|
-
trailing_tokens = messages[(usage_info[:index] + 1)..].to_a.sum { |message| message_tokens(message) }
|
|
94
|
-
usage_tokens + trailing_tokens
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def message_tokens(message)
|
|
98
|
-
role = value(message, :role)
|
|
99
|
-
parts = [role]
|
|
100
|
-
if role.to_s == "compactionSummary"
|
|
101
|
-
parts << value(message, :summary)
|
|
102
|
-
else
|
|
103
|
-
parts << content_text(value(message, :content))
|
|
104
|
-
end
|
|
105
|
-
parts << value(message, :reasoning_summary)
|
|
106
|
-
tool_calls(message).each do |tool_call|
|
|
107
|
-
parts << tool_call_name(tool_call)
|
|
108
|
-
parts << tool_call_arguments(tool_call)
|
|
109
|
-
end
|
|
110
|
-
parts << value(message, :tool_call_id)
|
|
111
|
-
parts << value(message, :name)
|
|
112
|
-
estimate_tokens(parts.compact.join("\n"))
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
private
|
|
116
|
-
|
|
117
|
-
def content_text(content)
|
|
118
|
-
return content.to_s unless content.is_a?(Array)
|
|
119
|
-
|
|
120
|
-
content.filter_map do |part|
|
|
121
|
-
type = value(part, :type)
|
|
122
|
-
if type == "text"
|
|
123
|
-
value(part, :text)
|
|
124
|
-
elsif type == "image"
|
|
125
|
-
path = value(part, :path)
|
|
126
|
-
media_type = value(part, :media_type) || value(part, :mimeType) || "image"
|
|
127
|
-
"[#{media_type}#{path ? ": #{path}" : ""}]"
|
|
128
|
-
end
|
|
129
|
-
end.join("\n")
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def tool_calls(message)
|
|
133
|
-
MessageAccess.tool_calls(message)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def tool_call_name(tool_call)
|
|
137
|
-
function = value(tool_call, :function) || {}
|
|
138
|
-
value(function, :name)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def tool_call_arguments(tool_call)
|
|
142
|
-
function = value(tool_call, :function) || {}
|
|
143
|
-
arguments = value(function, :arguments)
|
|
144
|
-
arguments.is_a?(Hash) ? JSON.dump(arguments) : arguments.to_s
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def last_assistant_usage_info(messages)
|
|
148
|
-
messages.each_with_index.reverse_each do |message, index|
|
|
149
|
-
next unless value(message, :role).to_s == "assistant"
|
|
150
|
-
|
|
151
|
-
usage = value(message, :usage)
|
|
152
|
-
tokens = usage_tokens(usage)
|
|
153
|
-
return { usage: usage, index: index } if tokens.positive?
|
|
154
|
-
end
|
|
155
|
-
nil
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def usage_tokens(usage)
|
|
159
|
-
return 0 unless usage.respond_to?(:key?)
|
|
160
|
-
|
|
161
|
-
total = usage_value(usage, :total_tokens, "totalTokens")
|
|
162
|
-
return total if total.positive?
|
|
163
|
-
|
|
164
|
-
usage_value(usage, :input_tokens, "input", "prompt_tokens") +
|
|
165
|
-
usage_value(usage, :output_tokens, "output", "completion_tokens") +
|
|
166
|
-
usage_value(usage, :cache_read_tokens, "cacheRead", "cacheReadTokens", "cache_read", "cached_tokens") +
|
|
167
|
-
usage_value(usage, :cache_write_tokens, "cacheWrite", "cacheWriteTokens", "cache_write")
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def usage_value(usage, *keys)
|
|
171
|
-
key = keys.find { |candidate| usage.key?(candidate) || usage.key?(candidate.to_s) }
|
|
172
|
-
return 0 unless key
|
|
173
|
-
|
|
174
|
-
(usage[key] || usage[key.to_s]).to_i
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def value(object, key)
|
|
178
|
-
ToolCall.value(object, key)
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
78
|
# Compaction support object used by conversation summarization.
|
|
183
79
|
class ConversationSerializer
|
|
184
80
|
TOOL_RESULT_LIMIT = 2_000
|
|
@@ -324,17 +220,15 @@ module Kward
|
|
|
324
220
|
end
|
|
325
221
|
|
|
326
222
|
def tool_call_id(tool_call)
|
|
327
|
-
|
|
223
|
+
ToolCall.id(tool_call)
|
|
328
224
|
end
|
|
329
225
|
|
|
330
226
|
def tool_call_name(tool_call)
|
|
331
|
-
|
|
332
|
-
function["name"] || function[:name] || "unknown_tool"
|
|
227
|
+
ToolCall.name(tool_call) || "unknown_tool"
|
|
333
228
|
end
|
|
334
229
|
|
|
335
230
|
def tool_call_args(tool_call)
|
|
336
|
-
|
|
337
|
-
ToolCall.parse_arguments(function["arguments"] || function[:arguments])
|
|
231
|
+
ToolCall.arguments(tool_call)
|
|
338
232
|
end
|
|
339
233
|
|
|
340
234
|
def tool_command(tool_call)
|
|
@@ -449,7 +343,7 @@ module Kward
|
|
|
449
343
|
def call
|
|
450
344
|
branch_entries = entry_messages(@conversation.messages)
|
|
451
345
|
raise NothingToCompact, "Nothing to compact" if branch_entries.empty?
|
|
452
|
-
raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last)
|
|
346
|
+
raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last)
|
|
453
347
|
|
|
454
348
|
previous_index = latest_previous_compaction_index(branch_entries)
|
|
455
349
|
previous_entry = previous_index ? branch_entries[previous_index] : nil
|
|
@@ -464,7 +358,8 @@ module Kward
|
|
|
464
358
|
first_kept_entry_id = entry_id(branch_entries[first_kept_index], first_kept_index)
|
|
465
359
|
summarized_for_file_ops = cut.messages_to_summarize + cut.turn_prefix_messages
|
|
466
360
|
file_ops = @file_operation_tracker.call(summarized_for_file_ops, previous_details: compaction_details(previous_entry))
|
|
467
|
-
|
|
361
|
+
preserved_skill_messages = activated_skill_messages(summarized_for_file_ops)
|
|
362
|
+
kept_messages = preserved_skill_messages + Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
|
|
468
363
|
|
|
469
364
|
PreparationResult.new(
|
|
470
365
|
first_kept_entry_id: first_kept_entry_id,
|
|
@@ -485,10 +380,6 @@ module Kward
|
|
|
485
380
|
Array(messages).reject { |message| message_role(message) == "system" }
|
|
486
381
|
end
|
|
487
382
|
|
|
488
|
-
def already_compacted?
|
|
489
|
-
@conversation.respond_to?(:last_entry_compaction?) && @conversation.last_entry_compaction?
|
|
490
|
-
end
|
|
491
|
-
|
|
492
383
|
def latest_previous_compaction_index(entries)
|
|
493
384
|
(0...entries.length).to_a.reverse.find { |index| compaction_entry?(entries[index]) }
|
|
494
385
|
end
|
|
@@ -503,6 +394,41 @@ module Kward
|
|
|
503
394
|
previous_index + 1
|
|
504
395
|
end
|
|
505
396
|
|
|
397
|
+
def activated_skill_messages(messages)
|
|
398
|
+
Array(messages).each_with_index.flat_map do |message, index|
|
|
399
|
+
next [] unless read_skill_tool_message?(message)
|
|
400
|
+
|
|
401
|
+
tool_call_id = MessageAccess.tool_call_id(message).to_s
|
|
402
|
+
tool_call_id = "preserved_read_skill_#{index}" if tool_call_id.empty?
|
|
403
|
+
skill_name = skill_name_from_content(MessageAccess.content(message)) || "unknown"
|
|
404
|
+
[
|
|
405
|
+
{
|
|
406
|
+
"role" => "assistant",
|
|
407
|
+
"content" => nil,
|
|
408
|
+
"tool_calls" => [
|
|
409
|
+
{
|
|
410
|
+
"id" => tool_call_id,
|
|
411
|
+
"type" => "function",
|
|
412
|
+
"function" => {
|
|
413
|
+
"name" => "read_skill",
|
|
414
|
+
"arguments" => JSON.generate(name: skill_name)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
},
|
|
419
|
+
message
|
|
420
|
+
]
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def read_skill_tool_message?(message)
|
|
425
|
+
message_role(message) == "tool" && MessageAccess.tool_name(message) == "read_skill" && MessageAccess.content(message).to_s.include?("<skill_content")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def skill_name_from_content(content)
|
|
429
|
+
content.to_s[/<skill_content name="([^"]+)"/, 1]
|
|
430
|
+
end
|
|
431
|
+
|
|
506
432
|
def compaction_entry?(message)
|
|
507
433
|
message_role(message) == "compactionSummary"
|
|
508
434
|
end
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -2,9 +2,12 @@ require "digest"
|
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
|
+
require_relative "frontmatter"
|
|
5
6
|
require_relative "private_file"
|
|
7
|
+
require_relative "path_guard"
|
|
6
8
|
require_relative "ekwsh"
|
|
7
9
|
require_relative "editor_mode"
|
|
10
|
+
require_relative "diff_view_mode"
|
|
8
11
|
require_relative "prompts/templates"
|
|
9
12
|
require_relative "skills/registry"
|
|
10
13
|
|
|
@@ -41,7 +44,7 @@ module Kward
|
|
|
41
44
|
{
|
|
42
45
|
"key" => "kward",
|
|
43
46
|
"label" => "Kward",
|
|
44
|
-
"instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the
|
|
47
|
+
"instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forest of Code, protecting the nature of good engineering principles. Speak like an old druid, be suspicious of everyone, but with good intent."
|
|
45
48
|
}
|
|
46
49
|
],
|
|
47
50
|
"default" => "kward"
|
|
@@ -49,7 +52,7 @@ module Kward
|
|
|
49
52
|
OVERLAY_ALIGNMENTS = %w[left center right].freeze
|
|
50
53
|
OVERLAY_WIDTHS = %w[capped maximum].freeze
|
|
51
54
|
|
|
52
|
-
Skill = Struct.new(:name, :description, :folder, :path, keyword_init: true)
|
|
55
|
+
Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
|
|
53
56
|
PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
|
|
54
57
|
def expand(arguments)
|
|
55
58
|
body.gsub("$ARGUMENTS", arguments.to_s)
|
|
@@ -92,6 +95,14 @@ module Kward
|
|
|
92
95
|
File.join(config_dir, "ekwsh.yml")
|
|
93
96
|
end
|
|
94
97
|
|
|
98
|
+
def workspace_hooks_path(root = Dir.pwd)
|
|
99
|
+
File.join(File.expand_path(root), ".kward", "hooks.json")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def trusted_workspace_hooks_path
|
|
103
|
+
File.join(config_dir, "trusted_workspace_hooks.json")
|
|
104
|
+
end
|
|
105
|
+
|
|
95
106
|
def default_config
|
|
96
107
|
{
|
|
97
108
|
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
@@ -109,11 +120,24 @@ module Kward
|
|
|
109
120
|
"auto_close_pairs" => true,
|
|
110
121
|
"soft_wrap" => true,
|
|
111
122
|
"bar_cursor" => true,
|
|
112
|
-
"line_numbers" => "absolute"
|
|
123
|
+
"line_numbers" => "absolute",
|
|
124
|
+
"diff_view" => "auto"
|
|
125
|
+
},
|
|
126
|
+
"overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
|
|
127
|
+
"web_search" => {
|
|
128
|
+
"enabled" => true,
|
|
129
|
+
"provider" => "auto",
|
|
130
|
+
"allow_model_providers" => false
|
|
131
|
+
},
|
|
132
|
+
"updates" => {
|
|
133
|
+
"check" => true
|
|
113
134
|
},
|
|
114
135
|
"sessions" => {
|
|
115
136
|
"auto_resume" => false
|
|
116
137
|
},
|
|
138
|
+
"skills" => {
|
|
139
|
+
"trust_project" => false
|
|
140
|
+
},
|
|
117
141
|
"enforce_workspace_agents_file" => false,
|
|
118
142
|
"mcpServers" => {},
|
|
119
143
|
"tools" => {
|
|
@@ -140,6 +164,10 @@ module Kward
|
|
|
140
164
|
File.join(cache_dir, "openrouter_models.json")
|
|
141
165
|
end
|
|
142
166
|
|
|
167
|
+
def update_check_cache_path
|
|
168
|
+
File.join(cache_dir, "update_check.json")
|
|
169
|
+
end
|
|
170
|
+
|
|
143
171
|
def project_browser_state_path
|
|
144
172
|
File.join(cache_dir, "project_browser_state.json")
|
|
145
173
|
end
|
|
@@ -198,6 +226,72 @@ module Kward
|
|
|
198
226
|
PrivateFile.write_json(path, config)
|
|
199
227
|
end
|
|
200
228
|
|
|
229
|
+
def lifecycle_hooks_config(workspace_root = Dir.pwd)
|
|
230
|
+
config = read_config
|
|
231
|
+
workspace_config = read_trusted_workspace_hooks_config(workspace_root)
|
|
232
|
+
return config if workspace_config.empty?
|
|
233
|
+
|
|
234
|
+
merge_hooks_config(config, workspace_config)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
|
|
238
|
+
path = workspace_hooks_path(workspace_root)
|
|
239
|
+
return {} unless workspace_hooks_trusted?(workspace_root)
|
|
240
|
+
|
|
241
|
+
read_config(path)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def workspace_hooks_trusted?(workspace_root = Dir.pwd)
|
|
245
|
+
path = workspace_hooks_path(workspace_root)
|
|
246
|
+
return false unless File.file?(path)
|
|
247
|
+
|
|
248
|
+
trusted = read_trusted_workspace_hooks
|
|
249
|
+
trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def trust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
253
|
+
path = workspace_hooks_path(workspace_root)
|
|
254
|
+
raise "No workspace hook config found: #{path}" unless File.file?(path)
|
|
255
|
+
|
|
256
|
+
trusted = read_trusted_workspace_hooks
|
|
257
|
+
trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
|
|
258
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def untrust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
262
|
+
path = File.expand_path(workspace_hooks_path(workspace_root))
|
|
263
|
+
trusted = read_trusted_workspace_hooks
|
|
264
|
+
trusted.delete(path)
|
|
265
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def workspace_hooks_digest(workspace_root = Dir.pwd)
|
|
269
|
+
Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def read_trusted_workspace_hooks
|
|
273
|
+
config = read_config(trusted_workspace_hooks_path)
|
|
274
|
+
config.is_a?(Hash) ? config : {}
|
|
275
|
+
rescue ConfigError
|
|
276
|
+
{}
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def merge_hooks_config(config, workspace_config)
|
|
280
|
+
merged = JSON.parse(JSON.generate(config || {}))
|
|
281
|
+
merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
|
|
282
|
+
merged
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def merge_hook_maps(left, right)
|
|
286
|
+
left = left.is_a?(Hash) ? left : {}
|
|
287
|
+
right = right.is_a?(Hash) ? right : {}
|
|
288
|
+
merged = JSON.parse(JSON.generate(left))
|
|
289
|
+
right.each do |event, entries|
|
|
290
|
+
merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
|
|
291
|
+
end
|
|
292
|
+
merged
|
|
293
|
+
end
|
|
294
|
+
|
|
201
295
|
def read_ekwsh_config(path = ekwsh_config_path)
|
|
202
296
|
path = File.expand_path(path)
|
|
203
297
|
return normalize_ekwsh_config(nil) unless File.exist?(path)
|
|
@@ -364,12 +458,24 @@ module Kward
|
|
|
364
458
|
EditorMode.normalize_line_numbers(editor["line_numbers"])
|
|
365
459
|
end
|
|
366
460
|
|
|
461
|
+
# Returns the integrated diff viewer display mode.
|
|
462
|
+
def diff_view(config = read_config)
|
|
463
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
464
|
+
DiffViewMode.normalize(editor["diff_view"])
|
|
465
|
+
end
|
|
466
|
+
|
|
367
467
|
# Returns whether file tools must stay inside the active workspace root.
|
|
368
468
|
def workspace_guardrails_enabled?(config = read_config)
|
|
369
469
|
tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
|
|
370
470
|
tools["workspace_guardrails"] != false
|
|
371
471
|
end
|
|
372
472
|
|
|
473
|
+
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
474
|
+
def project_skills_trusted?(config = read_config)
|
|
475
|
+
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|
|
476
|
+
skills["trust_project"] == true
|
|
477
|
+
end
|
|
478
|
+
|
|
373
479
|
# Returns whether new frontends should resume the last active session automatically.
|
|
374
480
|
def session_auto_resume_enabled?(config = read_config)
|
|
375
481
|
sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
|
|
@@ -399,7 +505,7 @@ module Kward
|
|
|
399
505
|
raise "Overlay settings must be an object" unless values.is_a?(Hash)
|
|
400
506
|
|
|
401
507
|
config = read_config
|
|
402
|
-
overlay = config
|
|
508
|
+
overlay = overlay_settings(config)
|
|
403
509
|
values.each do |key, value|
|
|
404
510
|
key = key.to_s
|
|
405
511
|
value = value.to_s
|
|
@@ -674,8 +780,8 @@ module Kward
|
|
|
674
780
|
# Lists configured skills discovered under the config directory.
|
|
675
781
|
#
|
|
676
782
|
# @return [Array<Skill>] skill metadata available to the model
|
|
677
|
-
def skills
|
|
678
|
-
skills_registry.skills
|
|
783
|
+
def skills(workspace_root: Dir.pwd)
|
|
784
|
+
skills_registry(workspace_root: workspace_root).skills
|
|
679
785
|
end
|
|
680
786
|
|
|
681
787
|
# @return [String] trusted user plugin directory
|
|
@@ -712,16 +818,18 @@ module Kward
|
|
|
712
818
|
# @param name [String] configured skill name
|
|
713
819
|
# @param relative_path [String, nil] path inside the skill directory
|
|
714
820
|
# @return [String] file contents or an error string
|
|
715
|
-
def read_skill_file(name, relative_path = nil)
|
|
716
|
-
skills_registry.read_skill_file(name, relative_path)
|
|
821
|
+
def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
|
|
822
|
+
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
717
823
|
end
|
|
718
824
|
|
|
719
|
-
def skills_registry
|
|
825
|
+
def skills_registry(workspace_root: Dir.pwd)
|
|
720
826
|
Skills::Registry.new(
|
|
721
827
|
config_dir: config_dir,
|
|
828
|
+
workspace_root: workspace_root,
|
|
829
|
+
project_skills_trusted: project_skills_trusted?,
|
|
722
830
|
skill_class: Skill,
|
|
723
831
|
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
724
|
-
markdown_parser:
|
|
832
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
725
833
|
inside_directory: method(:inside_directory?)
|
|
726
834
|
)
|
|
727
835
|
end
|
|
@@ -730,25 +838,12 @@ module Kward
|
|
|
730
838
|
Prompts::Templates.new(
|
|
731
839
|
config_dir: config_dir,
|
|
732
840
|
template_class: PromptTemplate,
|
|
733
|
-
markdown_parser:
|
|
841
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
|
|
734
842
|
)
|
|
735
843
|
end
|
|
736
844
|
|
|
737
|
-
def markdown_parts(path)
|
|
738
|
-
content = File.read(path)
|
|
739
|
-
return [{}, content] unless content.start_with?("---\n", "---\r\n")
|
|
740
|
-
|
|
741
|
-
_opening, rest = content.split(/\A---\r?\n/, 2)
|
|
742
|
-
yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
|
|
743
|
-
raise "missing frontmatter closing delimiter" if body.nil?
|
|
744
|
-
|
|
745
|
-
data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
|
|
746
|
-
frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
747
|
-
[frontmatter, body]
|
|
748
|
-
end
|
|
749
|
-
|
|
750
845
|
def inside_directory?(path, base)
|
|
751
|
-
|
|
846
|
+
PathGuard.inside?(path, base)
|
|
752
847
|
end
|
|
753
848
|
|
|
754
849
|
def presence(value)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Small recursive copy/freeze helpers for plain Hash/Array payload objects.
|
|
4
|
+
module DeepCopy
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def dup(value)
|
|
8
|
+
case value
|
|
9
|
+
when Hash
|
|
10
|
+
value.each_with_object({}) { |(key, item), result| result[key] = dup(item) }
|
|
11
|
+
when Array
|
|
12
|
+
value.map { |item| dup(item) }
|
|
13
|
+
else
|
|
14
|
+
value.dup
|
|
15
|
+
end
|
|
16
|
+
rescue TypeError
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def freeze(value)
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
value.each_value { |item| freeze(item) }
|
|
24
|
+
when Array
|
|
25
|
+
value.each { |item| freeze(item) }
|
|
26
|
+
end
|
|
27
|
+
value.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def merge(left, right)
|
|
31
|
+
left = dup(left)
|
|
32
|
+
right.each do |key, value|
|
|
33
|
+
left[key] = if left[key].is_a?(Hash) && value.is_a?(Hash)
|
|
34
|
+
merge(left[key], value)
|
|
35
|
+
else
|
|
36
|
+
dup(value)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
left
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Normalizes and resolves the integrated diff viewer display mode.
|
|
4
|
+
module DiffViewMode
|
|
5
|
+
AUTO = "auto"
|
|
6
|
+
UNIFIED = "unified"
|
|
7
|
+
SIDE_BY_SIDE = "side_by_side"
|
|
8
|
+
MODES = [AUTO, UNIFIED, SIDE_BY_SIDE].freeze
|
|
9
|
+
SIDE_BY_SIDE_MIN_WIDTH = 120
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def normalize(value)
|
|
14
|
+
text = value.to_s.downcase.tr("-", "_")
|
|
15
|
+
MODES.include?(text) ? text : AUTO
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def label(value)
|
|
19
|
+
case normalize(value)
|
|
20
|
+
when SIDE_BY_SIDE
|
|
21
|
+
"side-by-side"
|
|
22
|
+
when UNIFIED
|
|
23
|
+
"unified"
|
|
24
|
+
else
|
|
25
|
+
"auto"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def resolve(value, terminal_width: nil)
|
|
30
|
+
mode = normalize(value)
|
|
31
|
+
return mode unless mode == AUTO
|
|
32
|
+
|
|
33
|
+
terminal_width.to_i >= SIDE_BY_SIDE_MIN_WIDTH ? SIDE_BY_SIDE : UNIFIED
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/kward/events.rb
CHANGED
|
@@ -3,12 +3,14 @@ module Kward
|
|
|
3
3
|
# Frontend-neutral event objects emitted during agent turns.
|
|
4
4
|
module Events
|
|
5
5
|
ReasoningDelta = Struct.new(:delta, keyword_init: true)
|
|
6
|
+
ReasoningBoundary = Data.define
|
|
6
7
|
AssistantDelta = Struct.new(:delta, keyword_init: true)
|
|
7
8
|
AssistantMessage = Struct.new(:message, keyword_init: true)
|
|
8
9
|
Retry = Struct.new(:provider, :model, :attempt, :max_attempts, :delay_seconds, :error, :request_bytes, keyword_init: true)
|
|
9
10
|
Steering = Struct.new(:input, :created_at, keyword_init: true)
|
|
10
11
|
SteeringApplied = Struct.new(:count, keyword_init: true)
|
|
11
12
|
ToolCall = Struct.new(:tool_call, keyword_init: true)
|
|
13
|
+
ToolUpdate = Struct.new(:tool_call, :content, :elapsed_ms, keyword_init: true)
|
|
12
14
|
ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
|
|
13
15
|
Answer = Struct.new(:content, keyword_init: true)
|
|
14
16
|
end
|
data/lib/kward/export_path.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
+
require_relative "path_guard"
|
|
2
3
|
|
|
3
4
|
# Namespace for the Kward CLI agent runtime.
|
|
4
5
|
module Kward
|
|
@@ -11,7 +12,7 @@ module Kward
|
|
|
11
12
|
resolved = File.expand_path(explicit, workspace_root)
|
|
12
13
|
allowed_roots = [workspace_root, session_dir].compact.map { |root| Pathname.new(root).expand_path }
|
|
13
14
|
expanded = Pathname.new(resolved).expand_path
|
|
14
|
-
unless allowed_roots.any? { |root| inside?(expanded, root) }
|
|
15
|
+
unless allowed_roots.any? { |root| PathGuard.inside?(expanded, root) }
|
|
15
16
|
raise ArgumentError, "export path outside workspace or session directory: #{path}"
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -21,10 +22,5 @@ module Kward
|
|
|
21
22
|
|
|
22
23
|
resolved
|
|
23
24
|
end
|
|
24
|
-
|
|
25
|
-
def self.inside?(path, root)
|
|
26
|
-
path.to_s == root.to_s || path.to_s.start_with?("#{root}/")
|
|
27
|
-
end
|
|
28
|
-
private_class_method :inside?
|
|
29
25
|
end
|
|
30
26
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Markdown frontmatter parsing shared by prompt templates and skills.
|
|
6
|
+
module Frontmatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def markdown_parts(path, lenient: false)
|
|
10
|
+
content = File.read(path)
|
|
11
|
+
return [{}, content] unless content.start_with?("---\n", "---\r\n")
|
|
12
|
+
|
|
13
|
+
_opening, rest = content.split(/\A---\r?\n/, 2)
|
|
14
|
+
yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
|
|
15
|
+
raise "missing frontmatter closing delimiter" if body.nil?
|
|
16
|
+
|
|
17
|
+
data = yaml_text.to_s.empty? ? {} : load_frontmatter(yaml_text, lenient: lenient)
|
|
18
|
+
frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
19
|
+
[frontmatter, body]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_frontmatter(yaml_text, lenient: false)
|
|
23
|
+
YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
|
|
24
|
+
rescue Psych::SyntaxError
|
|
25
|
+
raise unless lenient
|
|
26
|
+
|
|
27
|
+
lenient_frontmatter(yaml_text)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def lenient_frontmatter(yaml_text)
|
|
31
|
+
yaml_text.each_line.each_with_object({}) do |line, result|
|
|
32
|
+
match = line.chomp.match(/\A([A-Za-z0-9_-]+):\s*(.*)\z/)
|
|
33
|
+
next unless match
|
|
34
|
+
|
|
35
|
+
key = match[1]
|
|
36
|
+
value = match[2].strip
|
|
37
|
+
next if value.empty?
|
|
38
|
+
|
|
39
|
+
result[key] = value.delete_prefix('"').delete_suffix('"')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|