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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require_relative "../message_access"
|
|
1
2
|
require_relative "../tools/tool_call"
|
|
2
3
|
require_relative "tool_metadata"
|
|
3
4
|
|
|
@@ -5,7 +6,7 @@ require_relative "tool_metadata"
|
|
|
5
6
|
module Kward
|
|
6
7
|
# JSON-RPC backend namespace used by UI clients.
|
|
7
8
|
module RPC
|
|
8
|
-
# Normalizes Kward transcript messages into
|
|
9
|
+
# Normalizes Kward transcript messages into frontend-neutral RPC payloads.
|
|
9
10
|
class TranscriptNormalizer
|
|
10
11
|
IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
|
|
11
12
|
THINKING_CONTENT_TYPES = ["thinking", "reasoning"].freeze
|
|
@@ -24,7 +25,7 @@ module Kward
|
|
|
24
25
|
def normalize_message(message)
|
|
25
26
|
return nil unless message.is_a?(Hash)
|
|
26
27
|
|
|
27
|
-
case
|
|
28
|
+
case MessageAccess.role(message).to_s
|
|
28
29
|
when "system"
|
|
29
30
|
nil
|
|
30
31
|
when "user"
|
|
@@ -45,7 +46,7 @@ module Kward
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def normalize_compaction_summary(message)
|
|
48
|
-
summary =
|
|
49
|
+
summary = MessageAccess.summary(message) || MessageAccess.content(message)
|
|
49
50
|
result = { role: "compactionSummary", summary: summary.to_s }
|
|
50
51
|
tokens_before = ToolCall.value(message, :tokensBefore) || ToolCall.value(message, :tokens_before)
|
|
51
52
|
result[:tokensBefore] = tokens_before if tokens_before
|
|
@@ -55,12 +56,12 @@ module Kward
|
|
|
55
56
|
def normalize_user_message(message)
|
|
56
57
|
{
|
|
57
58
|
role: "user",
|
|
58
|
-
content: normalize_content(
|
|
59
|
+
content: normalize_content(MessageAccess.content(message))
|
|
59
60
|
}
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
def normalize_assistant_message(message)
|
|
63
|
-
content = reasoning_first_content(normalize_content(
|
|
64
|
+
content = reasoning_first_content(normalize_content(MessageAccess.content(message), preserve_thinking: true))
|
|
64
65
|
content = response_item_content(message) if text_content_empty?(content)
|
|
65
66
|
reasoning = normalize_reasoning_summary(message)
|
|
66
67
|
content.unshift(reasoning) if reasoning && !thinking_content?(content)
|
|
@@ -83,7 +84,7 @@ module Kward
|
|
|
83
84
|
matching_call = @tool_calls_by_id[tool_call_id]
|
|
84
85
|
raw_name = ToolCall.value(message, :toolName) || ToolCall.value(message, :tool_name) || ToolCall.value(message, :name)
|
|
85
86
|
tool_name = normalize_tool_name(raw_name) || raw_name || matching_call&.dig(:name)
|
|
86
|
-
content = normalize_content(
|
|
87
|
+
content = normalize_content(MessageAccess.content(message))
|
|
87
88
|
|
|
88
89
|
result = {
|
|
89
90
|
role: "toolResult",
|
|
@@ -173,8 +174,7 @@ module Kward
|
|
|
173
174
|
end
|
|
174
175
|
|
|
175
176
|
def response_items(message)
|
|
176
|
-
|
|
177
|
-
items.is_a?(Array) ? items : []
|
|
177
|
+
MessageAccess.response_items(message)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
def reasoning_item_text(item)
|
|
@@ -276,8 +276,7 @@ module Kward
|
|
|
276
276
|
end
|
|
277
277
|
|
|
278
278
|
def tool_calls(message)
|
|
279
|
-
|
|
280
|
-
calls.is_a?(Array) ? calls : []
|
|
279
|
+
MessageAccess.tool_calls(message)
|
|
281
280
|
end
|
|
282
281
|
|
|
283
282
|
def normalize_tool_name(name)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# JSON-RPC backend namespace used by UI clients.
|
|
4
|
+
module RPC
|
|
5
|
+
# Normalizes optional client turn context and renders it for model input.
|
|
6
|
+
class TurnContext
|
|
7
|
+
def self.normalize(context)
|
|
8
|
+
new.normalize(context)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.prompt(context)
|
|
12
|
+
new.prompt(context)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def normalize(context)
|
|
16
|
+
return nil if context.nil?
|
|
17
|
+
raise ArgumentError, "turn context must be an object" unless context.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
active_file = blank_to_nil(value(context, "activeFile"))
|
|
20
|
+
open_files = array_value(context, "openFiles")
|
|
21
|
+
selection = normalize_selection(value(context, "selection"))
|
|
22
|
+
diagnostics = normalize_diagnostics(value(context, "diagnostics"))
|
|
23
|
+
normalized = { active_file: active_file, open_files: open_files, selection: selection, diagnostics: diagnostics }.compact
|
|
24
|
+
normalized.empty? ? nil : normalized
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def prompt(context)
|
|
28
|
+
lines = ["Additional client context:"]
|
|
29
|
+
lines << "- Active file: #{context[:active_file]}" if context[:active_file]
|
|
30
|
+
lines << "- Open files: #{context[:open_files].join(", ")}" if context[:open_files]&.any?
|
|
31
|
+
append_selection(lines, context[:selection]) if context[:selection]
|
|
32
|
+
append_diagnostics(lines, context[:diagnostics])
|
|
33
|
+
lines.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalize_selection(selection)
|
|
39
|
+
return nil if selection.nil?
|
|
40
|
+
raise ArgumentError, "context.selection must be an object" unless selection.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
normalized = {
|
|
43
|
+
path: blank_to_nil(value(selection, "path")),
|
|
44
|
+
start_line: integer_or_nil(value(selection, "startLine")),
|
|
45
|
+
end_line: integer_or_nil(value(selection, "endLine")),
|
|
46
|
+
text: blank_to_nil(value(selection, "text"))
|
|
47
|
+
}.compact
|
|
48
|
+
normalized.empty? ? nil : normalized
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_diagnostics(diagnostics)
|
|
52
|
+
return nil if diagnostics.nil?
|
|
53
|
+
raise ArgumentError, "context.diagnostics must be an array" unless diagnostics.is_a?(Array)
|
|
54
|
+
|
|
55
|
+
diagnostics.filter_map do |diagnostic|
|
|
56
|
+
next unless diagnostic.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
path: blank_to_nil(value(diagnostic, "path")),
|
|
60
|
+
line: integer_or_nil(value(diagnostic, "line")),
|
|
61
|
+
severity: blank_to_nil(value(diagnostic, "severity")),
|
|
62
|
+
message: blank_to_nil(value(diagnostic, "message"))
|
|
63
|
+
}.compact
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def append_selection(lines, selection)
|
|
68
|
+
location = [selection[:path], [selection[:start_line], selection[:end_line]].compact.join("-")].compact.reject(&:empty?).join(":")
|
|
69
|
+
lines << "- Selection: #{location}" unless location.empty?
|
|
70
|
+
lines << "```\n#{selection[:text]}\n```" if selection[:text]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def append_diagnostics(lines, diagnostics)
|
|
74
|
+
Array(diagnostics).each do |diagnostic|
|
|
75
|
+
next if diagnostic.empty?
|
|
76
|
+
|
|
77
|
+
location = [diagnostic[:path], diagnostic[:line]].compact.join(":")
|
|
78
|
+
severity = diagnostic[:severity] ? "#{diagnostic[:severity]} " : ""
|
|
79
|
+
lines << "- Diagnostic: #{severity}#{location} #{diagnostic[:message]}".strip
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def array_value(hash, key)
|
|
84
|
+
item = value(hash, key)
|
|
85
|
+
return nil if item.nil?
|
|
86
|
+
raise ArgumentError, "#{key} must be an array" unless item.is_a?(Array)
|
|
87
|
+
|
|
88
|
+
item.map(&:to_s).reject(&:empty?)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def integer_or_nil(item)
|
|
92
|
+
return nil if item.nil? || item.to_s.empty?
|
|
93
|
+
|
|
94
|
+
Integer(item)
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def blank_to_nil(item)
|
|
100
|
+
item = item.to_s if item.is_a?(Symbol)
|
|
101
|
+
item.to_s.empty? ? nil : item
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def value(hash, key)
|
|
105
|
+
hash[key] || hash[key.to_sym]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require_relative "config_files"
|
|
7
7
|
require_relative "conversation"
|
|
8
8
|
require_relative "message_access"
|
|
9
|
+
require_relative "message_text"
|
|
9
10
|
require_relative "private_file"
|
|
10
11
|
require_relative "rpc/tool_event_normalizer"
|
|
11
12
|
require_relative "tools/tool_call"
|
|
@@ -244,9 +245,9 @@ module Kward
|
|
|
244
245
|
def create_independent_from_messages(messages, read_paths: [], provider: nil, model: nil, reasoning_effort: nil, parent_session: nil)
|
|
245
246
|
session = create(provider: provider, model: model, reasoning_effort: reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
|
|
246
247
|
session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
|
|
247
|
-
persisted =
|
|
248
|
+
persisted = persistence_copy(messages)
|
|
248
249
|
persisted.each { |message| session.append_message(message) }
|
|
249
|
-
conversation = Conversation.new(messages:
|
|
250
|
+
conversation = Conversation.new(messages: persistence_copy(persisted), read_paths: read_paths, workspace_root: @cwd, provider: provider, model: model, reasoning_effort: reasoning_effort)
|
|
250
251
|
session.attach(conversation)
|
|
251
252
|
[session, conversation]
|
|
252
253
|
end
|
|
@@ -851,10 +852,10 @@ module Kward
|
|
|
851
852
|
end
|
|
852
853
|
|
|
853
854
|
def persisted_messages(conversation)
|
|
854
|
-
conversation.messages.reject { |message| message_role(message) == "system" }.map { |message|
|
|
855
|
+
conversation.messages.reject { |message| message_role(message) == "system" }.map { |message| persistence_copy(message) }
|
|
855
856
|
end
|
|
856
857
|
|
|
857
|
-
def
|
|
858
|
+
def persistence_copy(value)
|
|
858
859
|
JSON.parse(JSON.generate(value))
|
|
859
860
|
end
|
|
860
861
|
|
|
@@ -907,23 +908,8 @@ module Kward
|
|
|
907
908
|
MessageAccess.content(message)
|
|
908
909
|
end
|
|
909
910
|
|
|
910
|
-
def message_display_content(message)
|
|
911
|
-
MessageAccess.display_content(message)
|
|
912
|
-
end
|
|
913
|
-
|
|
914
911
|
def message_text(message)
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
display_content = message_display_content(message)
|
|
918
|
-
return display_content.to_s.gsub(/\s+/, " ").strip.slice(0, 120) unless display_content.nil?
|
|
919
|
-
|
|
920
|
-
content = message_content(message)
|
|
921
|
-
text = if content.is_a?(Array)
|
|
922
|
-
content.filter_map { |part| part["text"] || part[:text] }.join(" ")
|
|
923
|
-
else
|
|
924
|
-
content.to_s
|
|
925
|
-
end
|
|
926
|
-
text.gsub(/\s+/, " ").strip.slice(0, 120)
|
|
912
|
+
MessageText.preview(message)
|
|
927
913
|
end
|
|
928
914
|
|
|
929
915
|
def parse_time(value)
|
|
@@ -33,22 +33,6 @@ module Kward
|
|
|
33
33
|
@roots.flat_map { |root| visible_nodes(root) }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def contains_active_path?(node, active_path)
|
|
37
|
-
stack = [node]
|
|
38
|
-
seen = {}
|
|
39
|
-
until stack.empty?
|
|
40
|
-
current = stack.pop
|
|
41
|
-
next if seen[current.object_id]
|
|
42
|
-
|
|
43
|
-
seen[current.object_id] = true
|
|
44
|
-
entry_id = (current[:source]["entry"] || {})["id"].to_s
|
|
45
|
-
return true if active_path.include?(entry_id)
|
|
46
|
-
|
|
47
|
-
stack.concat(current[:children])
|
|
48
|
-
end
|
|
49
|
-
false
|
|
50
|
-
end
|
|
51
|
-
|
|
52
36
|
def tool_calls
|
|
53
37
|
@roots.each_with_object({}) do |root, calls|
|
|
54
38
|
stack = [root]
|
|
@@ -68,13 +52,109 @@ module Kward
|
|
|
68
52
|
end
|
|
69
53
|
end
|
|
70
54
|
|
|
55
|
+
def layout_rows
|
|
56
|
+
active = active_path
|
|
57
|
+
roots = visible_roots
|
|
58
|
+
active_nodes = active_node_lookup(roots, active)
|
|
59
|
+
multiple_roots = roots.length > 1
|
|
60
|
+
result = []
|
|
61
|
+
|
|
62
|
+
stack = roots.sort_by { |root| active_nodes[root.object_id] ? 0 : 1 }.each_with_index.map do |root, index|
|
|
63
|
+
[root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == roots.length - 1, [], multiple_roots]
|
|
64
|
+
end.reverse
|
|
65
|
+
|
|
66
|
+
until stack.empty?
|
|
67
|
+
node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
|
|
68
|
+
entry = node[:source]["entry"] || {}
|
|
69
|
+
entry_id = entry["id"].to_s
|
|
70
|
+
display_indent = multiple_roots ? [indent - 1, 0].max : indent
|
|
71
|
+
show_node_connector = show_connector && !virtual_root_child
|
|
72
|
+
|
|
73
|
+
result << {
|
|
74
|
+
source: node[:source],
|
|
75
|
+
entry: entry,
|
|
76
|
+
depth: display_indent,
|
|
77
|
+
is_last: is_last,
|
|
78
|
+
gutters: gutters,
|
|
79
|
+
active_path: active.include?(entry_id),
|
|
80
|
+
prefix: self.class.tree_prefix(display_indent, gutters, show_node_connector, is_last, !node[:children].empty?)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
children = node[:children].sort_by { |child| active_nodes[child.object_id] ? 0 : 1 }
|
|
84
|
+
multiple_children = children.length > 1
|
|
85
|
+
child_indent = if multiple_children
|
|
86
|
+
indent + 1
|
|
87
|
+
elsif just_branched && indent.positive?
|
|
88
|
+
indent + 1
|
|
89
|
+
else
|
|
90
|
+
indent
|
|
91
|
+
end
|
|
92
|
+
connector_position = [display_indent - 1, 0].max
|
|
93
|
+
child_gutters = show_node_connector ? gutters + [{ position: connector_position, show: !is_last }] : gutters
|
|
94
|
+
|
|
95
|
+
children.each_with_index.reverse_each do |child, index|
|
|
96
|
+
stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
|
|
104
|
+
return "" if display_indent.to_i <= 0
|
|
105
|
+
|
|
106
|
+
connector_position = show_connector ? display_indent - 1 : -1
|
|
107
|
+
(0...(display_indent * 3)).map do |index|
|
|
108
|
+
level = index / 3
|
|
109
|
+
position = index % 3
|
|
110
|
+
gutter = gutters.find { |candidate| candidate[:position] == level }
|
|
111
|
+
|
|
112
|
+
if gutter
|
|
113
|
+
position.zero? && gutter[:show] ? "│" : " "
|
|
114
|
+
elsif show_connector && level == connector_position
|
|
115
|
+
if position.zero?
|
|
116
|
+
is_last ? "└" : "├"
|
|
117
|
+
elsif position == 1
|
|
118
|
+
foldable ? "⊟" : "─"
|
|
119
|
+
else
|
|
120
|
+
" "
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
" "
|
|
124
|
+
end
|
|
125
|
+
end.join
|
|
126
|
+
end
|
|
127
|
+
|
|
71
128
|
def self.truncate_text(text)
|
|
72
|
-
|
|
73
|
-
normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
|
|
129
|
+
MessageText.truncate_preview(text)
|
|
74
130
|
end
|
|
75
131
|
|
|
76
132
|
private
|
|
77
133
|
|
|
134
|
+
def active_node_lookup(roots, active_path)
|
|
135
|
+
active_ids = active_path.to_h { |id| [id, true] }
|
|
136
|
+
lookup = {}
|
|
137
|
+
stack = roots.map { |root| [root, false] }
|
|
138
|
+
seen = {}
|
|
139
|
+
|
|
140
|
+
until stack.empty?
|
|
141
|
+
node, visited = stack.pop
|
|
142
|
+
node_key = node.object_id
|
|
143
|
+
if visited
|
|
144
|
+
entry_id = (node[:source]["entry"] || {})["id"].to_s
|
|
145
|
+
lookup[node_key] = active_ids[entry_id] || node[:children].any? { |child| lookup[child.object_id] }
|
|
146
|
+
else
|
|
147
|
+
next if seen[node_key]
|
|
148
|
+
|
|
149
|
+
seen[node_key] = true
|
|
150
|
+
stack << [node, true]
|
|
151
|
+
node[:children].each { |child| stack << [child, false] }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
lookup
|
|
156
|
+
end
|
|
157
|
+
|
|
78
158
|
def visible_nodes(node)
|
|
79
159
|
results = {}
|
|
80
160
|
stack = [[node, false, {}]]
|
|
@@ -14,44 +14,15 @@ module Kward
|
|
|
14
14
|
|
|
15
15
|
def items
|
|
16
16
|
tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf_id)
|
|
17
|
-
active_path = tree_nodes.active_path
|
|
18
17
|
tool_calls_by_id = tree_nodes.tool_calls
|
|
19
|
-
visible_roots = tree_nodes.visible_roots
|
|
20
|
-
multiple_roots = visible_roots.length > 1
|
|
21
|
-
result = []
|
|
22
|
-
|
|
23
|
-
stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
|
|
24
|
-
[root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
|
|
25
|
-
end.reverse
|
|
26
|
-
|
|
27
|
-
until stack.empty?
|
|
28
|
-
node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
|
|
29
|
-
entry = node[:source]["entry"] || {}
|
|
30
|
-
display_indent = multiple_roots ? [indent - 1, 0].max : indent
|
|
31
|
-
prefix = session_tree_visual_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
|
|
32
|
-
result << {
|
|
33
|
-
entry: entry,
|
|
34
|
-
label: session_tree_label(entry, node[:source], prefix, active_path.include?(entry["id"].to_s), tool_calls_by_id)
|
|
35
|
-
}
|
|
36
18
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else
|
|
44
|
-
indent
|
|
45
|
-
end
|
|
46
|
-
connector_position = [display_indent - 1, 0].max
|
|
47
|
-
child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
|
|
48
|
-
|
|
49
|
-
children.each_with_index.reverse_each do |child, index|
|
|
50
|
-
stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
|
|
51
|
-
end
|
|
19
|
+
tree_nodes.layout_rows.map do |row|
|
|
20
|
+
prefix = row[:prefix].empty? ? "" : " #{row[:prefix]}"
|
|
21
|
+
{
|
|
22
|
+
entry: row[:entry],
|
|
23
|
+
label: session_tree_label(row[:entry], row[:source], prefix, row[:active_path], tool_calls_by_id)
|
|
24
|
+
}
|
|
52
25
|
end
|
|
53
|
-
|
|
54
|
-
result
|
|
55
26
|
end
|
|
56
27
|
|
|
57
28
|
private
|
|
@@ -81,32 +52,6 @@ module Kward
|
|
|
81
52
|
end
|
|
82
53
|
end
|
|
83
54
|
|
|
84
|
-
def session_tree_visual_prefix(display_indent, gutters, show_connector, is_last, foldable)
|
|
85
|
-
return "" if display_indent.to_i <= 0
|
|
86
|
-
|
|
87
|
-
connector_position = show_connector ? display_indent - 1 : -1
|
|
88
|
-
indentation = " "
|
|
89
|
-
indentation + (0...(display_indent * 3)).map do |index|
|
|
90
|
-
level = index / 3
|
|
91
|
-
position = index % 3
|
|
92
|
-
gutter = gutters.find { |candidate| candidate[:position] == level }
|
|
93
|
-
|
|
94
|
-
if gutter
|
|
95
|
-
position.zero? && gutter[:show] ? "│" : " "
|
|
96
|
-
elsif show_connector && level == connector_position
|
|
97
|
-
if position.zero?
|
|
98
|
-
is_last ? "└" : "├"
|
|
99
|
-
elsif position == 1
|
|
100
|
-
foldable ? "⊟" : "─"
|
|
101
|
-
else
|
|
102
|
-
" "
|
|
103
|
-
end
|
|
104
|
-
else
|
|
105
|
-
" "
|
|
106
|
-
end
|
|
107
|
-
end.join
|
|
108
|
-
end
|
|
109
|
-
|
|
110
55
|
def session_tree_tool_display(message, tool_calls_by_id)
|
|
111
56
|
tool_call = tool_calls_by_id[session_tree_message_tool_call_id(message).to_s]
|
|
112
57
|
return SessionTreeToolDisplay.label(tool_call) if tool_call
|
|
@@ -6,8 +6,12 @@ module Kward
|
|
|
6
6
|
module Skills
|
|
7
7
|
# Parsed skill metadata and instruction path.
|
|
8
8
|
class Registry
|
|
9
|
-
|
|
9
|
+
SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
|
|
10
12
|
@config_dir = config_dir
|
|
13
|
+
@workspace_root = workspace_root
|
|
14
|
+
@project_skills_trusted = project_skills_trusted
|
|
11
15
|
@skill_class = skill_class
|
|
12
16
|
@max_file_bytes = max_file_bytes
|
|
13
17
|
@markdown_parser = markdown_parser
|
|
@@ -15,24 +19,23 @@ module Kward
|
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def skills
|
|
18
|
-
skills_root = File.join(@config_dir, "skills")
|
|
19
|
-
return [] unless Dir.exist?(skills_root)
|
|
20
|
-
|
|
21
22
|
seen = {}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
skill_sources.flat_map do |source|
|
|
24
|
+
scan_source(source).filter_map do |path|
|
|
25
|
+
skill = parse_skill(path)
|
|
26
|
+
next unless skill
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if seen[skill.name]
|
|
29
|
+
warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
|
|
30
|
+
next
|
|
31
|
+
end
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
seen[skill.name] = true
|
|
34
|
+
skill
|
|
35
|
+
end
|
|
33
36
|
end
|
|
34
37
|
rescue StandardError => e
|
|
35
|
-
warn "Warning: skipping Kward skills
|
|
38
|
+
warn "Warning: skipping Kward skills: #{e.message}"
|
|
36
39
|
[]
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -54,7 +57,8 @@ module Kward
|
|
|
54
57
|
size = File.size(real_target)
|
|
55
58
|
return "Error: skill file too large: #{path} (#{size} bytes)" if size > @max_file_bytes
|
|
56
59
|
|
|
57
|
-
File.read(real_target)
|
|
60
|
+
content = File.read(real_target)
|
|
61
|
+
relative_path.to_s.empty? ? skill_content(skill, content) : content
|
|
58
62
|
rescue Errno::ENOENT
|
|
59
63
|
"Error: skill file not found: #{path}"
|
|
60
64
|
rescue StandardError => e
|
|
@@ -63,17 +67,105 @@ module Kward
|
|
|
63
67
|
|
|
64
68
|
private
|
|
65
69
|
|
|
70
|
+
def skill_sources
|
|
71
|
+
[
|
|
72
|
+
SkillSource.new(root: File.join(@workspace_root, ".kward", "skills"), label: "project Kward skills", scope: :project, precedence: 0),
|
|
73
|
+
SkillSource.new(root: File.join(@workspace_root, ".agents", "skills"), label: "project Agent Skills", scope: :project, precedence: 1),
|
|
74
|
+
SkillSource.new(root: File.join(@config_dir, "skills"), label: "user Kward skills", scope: :user, precedence: 2),
|
|
75
|
+
SkillSource.new(root: File.expand_path("~/.agents/skills"), label: "user Agent Skills", scope: :user, precedence: 3)
|
|
76
|
+
]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def scan_source(source)
|
|
80
|
+
return [] unless Dir.exist?(source.root)
|
|
81
|
+
if source.scope == :project && !@project_skills_trusted
|
|
82
|
+
warn "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
|
|
83
|
+
return []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
warn "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def skill_content(skill, content)
|
|
93
|
+
lines = [
|
|
94
|
+
%(<skill_content name="#{xml_escape(skill.name)}">),
|
|
95
|
+
content,
|
|
96
|
+
"",
|
|
97
|
+
"Skill directory: #{File.realpath(skill.folder)}",
|
|
98
|
+
"Relative paths in this skill are relative to the skill directory."
|
|
99
|
+
]
|
|
100
|
+
resources = skill_resources(skill.folder)
|
|
101
|
+
unless resources.empty?
|
|
102
|
+
lines << ""
|
|
103
|
+
lines << "<skill_resources>"
|
|
104
|
+
resources.each { |path| lines << " <file>#{xml_escape(path)}</file>" }
|
|
105
|
+
lines << "</skill_resources>"
|
|
106
|
+
end
|
|
107
|
+
lines << "</skill_content>"
|
|
108
|
+
lines.join("\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def skill_resources(folder)
|
|
112
|
+
roots = %w[scripts references assets].map { |name| File.join(folder, name) }.select { |path| Dir.exist?(path) }
|
|
113
|
+
resources = roots.flat_map do |root|
|
|
114
|
+
Dir.glob(File.join(root, "**", "*"))
|
|
115
|
+
end.select { |path| File.file?(path) }.sort.first(200)
|
|
116
|
+
base = Pathname.new(folder)
|
|
117
|
+
resources.map { |path| Pathname.new(path).relative_path_from(base).to_s }
|
|
118
|
+
rescue StandardError
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def xml_escape(text)
|
|
123
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
124
|
+
end
|
|
125
|
+
|
|
66
126
|
def parse_skill(path)
|
|
67
127
|
frontmatter, = @markdown_parser.call(path)
|
|
68
128
|
name = frontmatter.fetch("name", "").to_s.strip
|
|
69
|
-
name = File.basename(File.dirname(path)) if name.empty?
|
|
70
129
|
description = frontmatter.fetch("description", "").to_s.strip
|
|
130
|
+
return warn_skip(path, "missing name") if name.empty?
|
|
131
|
+
return warn_skip(path, "missing description") if description.empty?
|
|
132
|
+
return warn_skip(path, "description exceeds 1024 characters") if description.length > 1024
|
|
133
|
+
|
|
134
|
+
warn "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
|
|
135
|
+
warn "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
|
|
136
|
+
warn "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
|
|
137
|
+
|
|
138
|
+
compatibility = optional_text(frontmatter["compatibility"])
|
|
139
|
+
warn "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
|
|
71
140
|
|
|
72
|
-
@skill_class.new(
|
|
141
|
+
@skill_class.new(
|
|
142
|
+
name: name,
|
|
143
|
+
description: description,
|
|
144
|
+
folder: File.dirname(path),
|
|
145
|
+
path: path,
|
|
146
|
+
license: optional_text(frontmatter["license"]),
|
|
147
|
+
compatibility: compatibility,
|
|
148
|
+
metadata: frontmatter["metadata"].is_a?(Hash) ? frontmatter["metadata"] : {},
|
|
149
|
+
allowed_tools: optional_text(frontmatter["allowed-tools"])
|
|
150
|
+
)
|
|
73
151
|
rescue StandardError => e
|
|
74
152
|
warn "Warning: skipping Kward skill #{path}: #{e.message}"
|
|
75
153
|
nil
|
|
76
154
|
end
|
|
155
|
+
|
|
156
|
+
def valid_name?(name)
|
|
157
|
+
name.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def optional_text(value)
|
|
161
|
+
text = value.to_s.strip
|
|
162
|
+
text.empty? ? nil : text
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def warn_skip(path, reason)
|
|
166
|
+
warn "Warning: skipping Kward skill #{path}: #{reason}"
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
77
169
|
end
|
|
78
170
|
end
|
|
79
171
|
end
|
data/lib/kward/tools/mcp_tool.rb
CHANGED
|
@@ -8,12 +8,16 @@ module Kward
|
|
|
8
8
|
# Tool wrapper for reading configured skill instructions.
|
|
9
9
|
class ReadSkill < Base
|
|
10
10
|
# Builds the tool schema and stores the execution dependency.
|
|
11
|
-
def initialize
|
|
11
|
+
def initialize(skills: nil)
|
|
12
|
+
name_property = { type: "string", description: "Skill name." }
|
|
13
|
+
skill_names = Array(skills).map(&:name).compact.sort
|
|
14
|
+
name_property[:enum] = skill_names unless skill_names.empty?
|
|
15
|
+
|
|
12
16
|
super(
|
|
13
17
|
"read_skill",
|
|
14
18
|
"Read configured skill instructions/files.",
|
|
15
19
|
properties: {
|
|
16
|
-
name:
|
|
20
|
+
name: name_property,
|
|
17
21
|
path: { type: "string", description: "Path inside skill; default SKILL.md." }
|
|
18
22
|
},
|
|
19
23
|
required: ["name"]
|
|
@@ -21,11 +25,12 @@ module Kward
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# Executes the tool and returns model-facing output text.
|
|
24
|
-
def call(args,
|
|
28
|
+
def call(args, conversation, cancellation: nil)
|
|
25
29
|
name = argument(args, :name, "")
|
|
26
30
|
path = argument(args, :path)
|
|
31
|
+
workspace_root = conversation.respond_to?(:workspace_root) ? conversation.workspace_root : Dir.pwd
|
|
27
32
|
|
|
28
|
-
ConfigFiles.read_skill_file(name, path)
|
|
33
|
+
ConfigFiles.read_skill_file(name, path, workspace_root: workspace_root)
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
end
|