kward 0.73.1 → 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 +51 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +70 -4
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/mcp.md +72 -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 -15
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +14 -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 +12 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +9 -3
- 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 +32 -15
- data/lib/kward/cli.rb +64 -4
- 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 +152 -27
- 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 +56 -0
- data/lib/kward/mcp/server_config.rb +59 -0
- data/lib/kward/mcp/stdio_transport.rb +112 -0
- 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 +124 -4
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +72 -13
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -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 -13
- 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 +122 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +297 -9
- 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 +30 -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
|
|
|
@@ -22,6 +25,17 @@ module Kward
|
|
|
22
25
|
# config, prompt, skill, plugin, cache, memory, and session locations instead of
|
|
23
26
|
# reconstructing `~/.kward` paths independently.
|
|
24
27
|
module ConfigFiles
|
|
28
|
+
class ConfigError < StandardError
|
|
29
|
+
attr_reader :path, :format, :detail
|
|
30
|
+
|
|
31
|
+
def initialize(path:, format:, detail:)
|
|
32
|
+
@path = path
|
|
33
|
+
@format = format
|
|
34
|
+
@detail = detail
|
|
35
|
+
super("Invalid Kward config #{format}: #{path}: #{detail}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
MAX_SKILL_FILE_BYTES = 100_000
|
|
26
40
|
MAX_PROMPT_FILE_BYTES = 32 * 1024
|
|
27
41
|
DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
|
|
@@ -30,7 +44,7 @@ module Kward
|
|
|
30
44
|
{
|
|
31
45
|
"key" => "kward",
|
|
32
46
|
"label" => "Kward",
|
|
33
|
-
"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."
|
|
34
48
|
}
|
|
35
49
|
],
|
|
36
50
|
"default" => "kward"
|
|
@@ -38,15 +52,25 @@ module Kward
|
|
|
38
52
|
OVERLAY_ALIGNMENTS = %w[left center right].freeze
|
|
39
53
|
OVERLAY_WIDTHS = %w[capped maximum].freeze
|
|
40
54
|
|
|
41
|
-
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)
|
|
42
56
|
PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
|
|
43
57
|
def expand(arguments)
|
|
44
58
|
body.gsub("$ARGUMENTS", arguments.to_s)
|
|
45
59
|
end
|
|
46
60
|
end
|
|
47
61
|
|
|
62
|
+
@skip_config = false
|
|
63
|
+
|
|
48
64
|
module_function
|
|
49
65
|
|
|
66
|
+
def skip_config=(value)
|
|
67
|
+
@skip_config = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def skip_config?
|
|
71
|
+
@skip_config == true
|
|
72
|
+
end
|
|
73
|
+
|
|
50
74
|
# Directory that contains Kward's user config and adjacent prompt/skill
|
|
51
75
|
# data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
|
|
52
76
|
#
|
|
@@ -71,6 +95,14 @@ module Kward
|
|
|
71
95
|
File.join(config_dir, "ekwsh.yml")
|
|
72
96
|
end
|
|
73
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
|
+
|
|
74
106
|
def default_config
|
|
75
107
|
{
|
|
76
108
|
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
@@ -88,12 +120,23 @@ module Kward
|
|
|
88
120
|
"auto_close_pairs" => true,
|
|
89
121
|
"soft_wrap" => true,
|
|
90
122
|
"bar_cursor" => true,
|
|
91
|
-
"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
|
|
92
131
|
},
|
|
93
132
|
"sessions" => {
|
|
94
133
|
"auto_resume" => false
|
|
95
134
|
},
|
|
135
|
+
"skills" => {
|
|
136
|
+
"trust_project" => false
|
|
137
|
+
},
|
|
96
138
|
"enforce_workspace_agents_file" => false,
|
|
139
|
+
"mcpServers" => {},
|
|
97
140
|
"tools" => {
|
|
98
141
|
"workspace_guardrails" => true
|
|
99
142
|
}
|
|
@@ -103,6 +146,7 @@ module Kward
|
|
|
103
146
|
# Performs ensure default config for configuration file and path handling.
|
|
104
147
|
def ensure_default_config!(path = config_path)
|
|
105
148
|
path = File.expand_path(path)
|
|
149
|
+
return false if skip_config? && path == config_path
|
|
106
150
|
return false if File.exist?(path)
|
|
107
151
|
|
|
108
152
|
write_config(default_config, path)
|
|
@@ -117,6 +161,10 @@ module Kward
|
|
|
117
161
|
File.join(cache_dir, "openrouter_models.json")
|
|
118
162
|
end
|
|
119
163
|
|
|
164
|
+
def update_check_cache_path
|
|
165
|
+
File.join(cache_dir, "update_check.json")
|
|
166
|
+
end
|
|
167
|
+
|
|
120
168
|
def project_browser_state_path
|
|
121
169
|
File.join(cache_dir, "project_browser_state.json")
|
|
122
170
|
end
|
|
@@ -156,11 +204,12 @@ module Kward
|
|
|
156
204
|
# @return [Hash] parsed config object
|
|
157
205
|
def read_config(path = config_path)
|
|
158
206
|
path = File.expand_path(path)
|
|
207
|
+
return {} if skip_config? && path == config_path
|
|
159
208
|
return {} unless File.exist?(path)
|
|
160
209
|
|
|
161
210
|
JSON.parse(File.read(path))
|
|
162
|
-
rescue JSON::ParserError
|
|
163
|
-
raise
|
|
211
|
+
rescue JSON::ParserError => e
|
|
212
|
+
raise ConfigError.new(path: path, format: "JSON", detail: e.message)
|
|
164
213
|
end
|
|
165
214
|
|
|
166
215
|
# Writes config JSON using private file permissions.
|
|
@@ -168,9 +217,78 @@ module Kward
|
|
|
168
217
|
# @param config [Hash] config object to persist
|
|
169
218
|
# @param path [String] config file path
|
|
170
219
|
def write_config(config, path = config_path)
|
|
220
|
+
path = File.expand_path(path)
|
|
221
|
+
raise "Cannot write Kward config while --skip-config is active: #{path}" if skip_config? && path == config_path
|
|
222
|
+
|
|
171
223
|
PrivateFile.write_json(path, config)
|
|
172
224
|
end
|
|
173
225
|
|
|
226
|
+
def lifecycle_hooks_config(workspace_root = Dir.pwd)
|
|
227
|
+
config = read_config
|
|
228
|
+
workspace_config = read_trusted_workspace_hooks_config(workspace_root)
|
|
229
|
+
return config if workspace_config.empty?
|
|
230
|
+
|
|
231
|
+
merge_hooks_config(config, workspace_config)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
|
|
235
|
+
path = workspace_hooks_path(workspace_root)
|
|
236
|
+
return {} unless workspace_hooks_trusted?(workspace_root)
|
|
237
|
+
|
|
238
|
+
read_config(path)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def workspace_hooks_trusted?(workspace_root = Dir.pwd)
|
|
242
|
+
path = workspace_hooks_path(workspace_root)
|
|
243
|
+
return false unless File.file?(path)
|
|
244
|
+
|
|
245
|
+
trusted = read_trusted_workspace_hooks
|
|
246
|
+
trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def trust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
250
|
+
path = workspace_hooks_path(workspace_root)
|
|
251
|
+
raise "No workspace hook config found: #{path}" unless File.file?(path)
|
|
252
|
+
|
|
253
|
+
trusted = read_trusted_workspace_hooks
|
|
254
|
+
trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
|
|
255
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def untrust_workspace_hooks!(workspace_root = Dir.pwd)
|
|
259
|
+
path = File.expand_path(workspace_hooks_path(workspace_root))
|
|
260
|
+
trusted = read_trusted_workspace_hooks
|
|
261
|
+
trusted.delete(path)
|
|
262
|
+
PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def workspace_hooks_digest(workspace_root = Dir.pwd)
|
|
266
|
+
Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def read_trusted_workspace_hooks
|
|
270
|
+
config = read_config(trusted_workspace_hooks_path)
|
|
271
|
+
config.is_a?(Hash) ? config : {}
|
|
272
|
+
rescue ConfigError
|
|
273
|
+
{}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def merge_hooks_config(config, workspace_config)
|
|
277
|
+
merged = JSON.parse(JSON.generate(config || {}))
|
|
278
|
+
merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
|
|
279
|
+
merged
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def merge_hook_maps(left, right)
|
|
283
|
+
left = left.is_a?(Hash) ? left : {}
|
|
284
|
+
right = right.is_a?(Hash) ? right : {}
|
|
285
|
+
merged = JSON.parse(JSON.generate(left))
|
|
286
|
+
right.each do |event, entries|
|
|
287
|
+
merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
|
|
288
|
+
end
|
|
289
|
+
merged
|
|
290
|
+
end
|
|
291
|
+
|
|
174
292
|
def read_ekwsh_config(path = ekwsh_config_path)
|
|
175
293
|
path = File.expand_path(path)
|
|
176
294
|
return normalize_ekwsh_config(nil) unless File.exist?(path)
|
|
@@ -337,12 +455,24 @@ module Kward
|
|
|
337
455
|
EditorMode.normalize_line_numbers(editor["line_numbers"])
|
|
338
456
|
end
|
|
339
457
|
|
|
458
|
+
# Returns the integrated diff viewer display mode.
|
|
459
|
+
def diff_view(config = read_config)
|
|
460
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
461
|
+
DiffViewMode.normalize(editor["diff_view"])
|
|
462
|
+
end
|
|
463
|
+
|
|
340
464
|
# Returns whether file tools must stay inside the active workspace root.
|
|
341
465
|
def workspace_guardrails_enabled?(config = read_config)
|
|
342
466
|
tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
|
|
343
467
|
tools["workspace_guardrails"] != false
|
|
344
468
|
end
|
|
345
469
|
|
|
470
|
+
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
471
|
+
def project_skills_trusted?(config = read_config)
|
|
472
|
+
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|
|
473
|
+
skills["trust_project"] == true
|
|
474
|
+
end
|
|
475
|
+
|
|
346
476
|
# Returns whether new frontends should resume the last active session automatically.
|
|
347
477
|
def session_auto_resume_enabled?(config = read_config)
|
|
348
478
|
sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
|
|
@@ -361,12 +491,18 @@ module Kward
|
|
|
361
491
|
value.is_a?(Hash) ? value : {}
|
|
362
492
|
end
|
|
363
493
|
|
|
494
|
+
# Returns configured MCP stdio servers, or an empty config when absent.
|
|
495
|
+
def mcp_servers(config = read_config)
|
|
496
|
+
value = config["mcpServers"] || config.dig("mcp", "servers")
|
|
497
|
+
value.is_a?(Hash) ? value : {}
|
|
498
|
+
end
|
|
499
|
+
|
|
364
500
|
# Validates and persists terminal overlay settings.
|
|
365
501
|
def update_overlay_settings(values)
|
|
366
502
|
raise "Overlay settings must be an object" unless values.is_a?(Hash)
|
|
367
503
|
|
|
368
504
|
config = read_config
|
|
369
|
-
overlay = config
|
|
505
|
+
overlay = overlay_settings(config)
|
|
370
506
|
values.each do |key, value|
|
|
371
507
|
key = key.to_s
|
|
372
508
|
value = value.to_s
|
|
@@ -641,8 +777,8 @@ module Kward
|
|
|
641
777
|
# Lists configured skills discovered under the config directory.
|
|
642
778
|
#
|
|
643
779
|
# @return [Array<Skill>] skill metadata available to the model
|
|
644
|
-
def skills
|
|
645
|
-
skills_registry.skills
|
|
780
|
+
def skills(workspace_root: Dir.pwd)
|
|
781
|
+
skills_registry(workspace_root: workspace_root).skills
|
|
646
782
|
end
|
|
647
783
|
|
|
648
784
|
# @return [String] trusted user plugin directory
|
|
@@ -679,16 +815,18 @@ module Kward
|
|
|
679
815
|
# @param name [String] configured skill name
|
|
680
816
|
# @param relative_path [String, nil] path inside the skill directory
|
|
681
817
|
# @return [String] file contents or an error string
|
|
682
|
-
def read_skill_file(name, relative_path = nil)
|
|
683
|
-
skills_registry.read_skill_file(name, relative_path)
|
|
818
|
+
def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
|
|
819
|
+
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
684
820
|
end
|
|
685
821
|
|
|
686
|
-
def skills_registry
|
|
822
|
+
def skills_registry(workspace_root: Dir.pwd)
|
|
687
823
|
Skills::Registry.new(
|
|
688
824
|
config_dir: config_dir,
|
|
825
|
+
workspace_root: workspace_root,
|
|
826
|
+
project_skills_trusted: project_skills_trusted?,
|
|
689
827
|
skill_class: Skill,
|
|
690
828
|
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
691
|
-
markdown_parser:
|
|
829
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
692
830
|
inside_directory: method(:inside_directory?)
|
|
693
831
|
)
|
|
694
832
|
end
|
|
@@ -697,25 +835,12 @@ module Kward
|
|
|
697
835
|
Prompts::Templates.new(
|
|
698
836
|
config_dir: config_dir,
|
|
699
837
|
template_class: PromptTemplate,
|
|
700
|
-
markdown_parser:
|
|
838
|
+
markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
|
|
701
839
|
)
|
|
702
840
|
end
|
|
703
841
|
|
|
704
|
-
def markdown_parts(path)
|
|
705
|
-
content = File.read(path)
|
|
706
|
-
return [{}, content] unless content.start_with?("---\n", "---\r\n")
|
|
707
|
-
|
|
708
|
-
_opening, rest = content.split(/\A---\r?\n/, 2)
|
|
709
|
-
yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
|
|
710
|
-
raise "missing frontmatter closing delimiter" if body.nil?
|
|
711
|
-
|
|
712
|
-
data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
|
|
713
|
-
frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
714
|
-
[frontmatter, body]
|
|
715
|
-
end
|
|
716
|
-
|
|
717
842
|
def inside_directory?(path, base)
|
|
718
|
-
|
|
843
|
+
PathGuard.inside?(path, base)
|
|
719
844
|
end
|
|
720
845
|
|
|
721
846
|
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
|
@@ -9,6 +9,7 @@ module Kward
|
|
|
9
9
|
Steering = Struct.new(:input, :created_at, keyword_init: true)
|
|
10
10
|
SteeringApplied = Struct.new(:count, keyword_init: true)
|
|
11
11
|
ToolCall = Struct.new(:tool_call, keyword_init: true)
|
|
12
|
+
ToolUpdate = Struct.new(:tool_call, :content, :elapsed_ms, keyword_init: true)
|
|
12
13
|
ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
|
|
13
14
|
Answer = Struct.new(:content, keyword_init: true)
|
|
14
15
|
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
|