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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +56 -3
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +1 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +108 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +72 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/memory_commands.rb +1 -5
  27. data/lib/kward/cli/plugins.rb +41 -1
  28. data/lib/kward/cli/prompt_interface.rb +32 -3
  29. data/lib/kward/cli/rendering.rb +1 -1
  30. data/lib/kward/cli/runtime_helpers.rb +44 -5
  31. data/lib/kward/cli/sessions.rb +13 -0
  32. data/lib/kward/cli/settings.rb +226 -152
  33. data/lib/kward/cli/slash_commands.rb +81 -42
  34. data/lib/kward/cli/tabs.rb +26 -6
  35. data/lib/kward/cli.rb +26 -3
  36. data/lib/kward/cli_transcript_formatter.rb +0 -7
  37. data/lib/kward/compaction/token_estimator.rb +112 -0
  38. data/lib/kward/compactor.rb +42 -116
  39. data/lib/kward/config_files.rb +117 -25
  40. data/lib/kward/deep_copy.rb +42 -0
  41. data/lib/kward/diff_view_mode.rb +36 -0
  42. data/lib/kward/events.rb +1 -0
  43. data/lib/kward/export_path.rb +2 -6
  44. data/lib/kward/frontmatter.rb +43 -0
  45. data/lib/kward/hooks/audit_log.rb +121 -0
  46. data/lib/kward/hooks/catalog.rb +110 -0
  47. data/lib/kward/hooks/command_handler.rb +66 -0
  48. data/lib/kward/hooks/config_loader.rb +104 -0
  49. data/lib/kward/hooks/decision.rb +102 -0
  50. data/lib/kward/hooks/event.rb +60 -0
  51. data/lib/kward/hooks/http_handler.rb +72 -0
  52. data/lib/kward/hooks/manager.rb +158 -0
  53. data/lib/kward/hooks/matcher.rb +75 -0
  54. data/lib/kward/hooks.rb +16 -0
  55. data/lib/kward/markdown_transcript.rb +4 -3
  56. data/lib/kward/mcp/client.rb +1 -1
  57. data/lib/kward/mcp/server_config.rb +7 -3
  58. data/lib/kward/mcp/stdio_transport.rb +7 -1
  59. data/lib/kward/memory/turn_context.rb +19 -0
  60. data/lib/kward/message_access.rb +2 -2
  61. data/lib/kward/message_text.rb +19 -0
  62. data/lib/kward/model/client.rb +13 -45
  63. data/lib/kward/model/context_usage.rb +10 -4
  64. data/lib/kward/model/copilot_models.rb +44 -0
  65. data/lib/kward/model/model_info.rb +24 -0
  66. data/lib/kward/model/payloads.rb +15 -17
  67. data/lib/kward/model/stream_parser.rb +178 -13
  68. data/lib/kward/pan/server.rb +51 -2
  69. data/lib/kward/path_guard.rb +13 -0
  70. data/lib/kward/plugin_registry.rb +86 -25
  71. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  72. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  73. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  74. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  75. data/lib/kward/prompt_interface.rb +4 -1
  76. data/lib/kward/prompts/commands.rb +3 -3
  77. data/lib/kward/prompts.rb +7 -7
  78. data/lib/kward/rpc/mcp_status.rb +50 -0
  79. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  80. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  81. data/lib/kward/rpc/server.rb +94 -14
  82. data/lib/kward/rpc/session_manager.rb +258 -33
  83. data/lib/kward/rpc/session_metrics.rb +1 -1
  84. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  85. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  86. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  87. data/lib/kward/rpc/turn_context.rb +109 -0
  88. data/lib/kward/session_store.rb +6 -20
  89. data/lib/kward/session_tree_nodes.rb +98 -18
  90. data/lib/kward/session_tree_renderer.rb +6 -61
  91. data/lib/kward/skills/registry.rb +109 -17
  92. data/lib/kward/tools/mcp_tool.rb +4 -0
  93. data/lib/kward/tools/read_skill.rb +9 -4
  94. data/lib/kward/tools/registry.rb +266 -8
  95. data/lib/kward/tools/search/code.rb +4 -22
  96. data/lib/kward/tools/search/web.rb +28 -2
  97. data/lib/kward/update_check.rb +117 -0
  98. data/lib/kward/version.rb +1 -1
  99. data/lib/kward/workers/manager.rb +43 -3
  100. data/lib/kward/workspace.rb +3 -1
  101. data/templates/default/fulldoc/html/css/kward.css +22 -0
  102. data/templates/default/fulldoc/html/js/kward.js +2 -0
  103. data/templates/default/kward_navigation.rb +4 -2
  104. data/templates/default/layout/html/setup.rb +2 -0
  105. 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 Tauren-compatible RPC payloads.
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 ToolCall.value(message, :role).to_s
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 = ToolCall.value(message, :summary) || ToolCall.value(message, :content)
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(ToolCall.value(message, :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(ToolCall.value(message, :content), preserve_thinking: true))
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(ToolCall.value(message, :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
- items = ToolCall.value(message, :response_items) || ToolCall.value(message, :responseItems)
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
- calls = ToolCall.value(message, :tool_calls) || ToolCall.value(message, :toolCalls)
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
@@ -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 = deep_copy(messages)
248
+ persisted = persistence_copy(messages)
248
249
  persisted.each { |message| session.append_message(message) }
249
- conversation = Conversation.new(messages: deep_copy(persisted), read_paths: read_paths, workspace_root: @cwd, provider: provider, model: model, reasoning_effort: reasoning_effort)
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| deep_copy(message) }
855
+ conversation.messages.reject { |message| message_role(message) == "system" }.map { |message| persistence_copy(message) }
855
856
  end
856
857
 
857
- def deep_copy(value)
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
- return MessageAccess.summary(message).to_s.gsub(/\s+/, " ").strip.slice(0, 120) if message_role(message) == "compactionSummary"
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
- normalized = text.to_s.gsub(/\s+/, " ").strip
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
- children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
38
- multiple_children = children.length > 1
39
- child_indent = if multiple_children
40
- indent + 1
41
- elsif just_branched && indent.positive?
42
- indent + 1
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
- def initialize(config_dir:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
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
- Dir.glob(File.join(skills_root, "*", "SKILL.md")).sort.filter_map do |path|
23
- skill = parse_skill(path)
24
- next unless skill
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
- if seen[skill.name]
27
- warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
28
- next
29
- end
28
+ if seen[skill.name]
29
+ warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
30
+ next
31
+ end
30
32
 
31
- seen[skill.name] = true
32
- skill
33
+ seen[skill.name] = true
34
+ skill
35
+ end
33
36
  end
34
37
  rescue StandardError => e
35
- warn "Warning: skipping Kward skills in #{skills_root}: #{e.message}"
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
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(name: name, description: description, folder: File.dirname(path), path: path)
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
@@ -8,6 +8,10 @@ module Kward
8
8
  class MCPTool < Base
9
9
  attr_reader :server_name, :remote_name
10
10
 
11
+ def display_name
12
+ "#{server_name}.#{remote_name}"
13
+ end
14
+
11
15
  def initialize(server_name:, client:, tool:)
12
16
  @server_name = server_name.to_s
13
17
  @client = client
@@ -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: { type: "string", description: "Skill 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, _conversation, cancellation: nil)
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