kward 0.66.0 → 0.67.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.
@@ -0,0 +1,96 @@
1
+ require "open3"
2
+ require "rbconfig"
3
+
4
+ module Kward
5
+ # Best-effort session file remover. Uses the OS trash/recycle bin when a
6
+ # supported platform tool is available, and falls back to permanent deletion.
7
+ class SessionTrash
8
+ def initialize(env: ENV, command_runner: nil, host_os: RbConfig::CONFIG["host_os"])
9
+ @env = env
10
+ @command_runner = command_runner || method(:run_command)
11
+ @host_os = host_os
12
+ end
13
+
14
+ def delete(path)
15
+ return false unless File.exist?(path)
16
+
17
+ return true if move_to_trash(path)
18
+
19
+ File.delete(path) if File.exist?(path)
20
+ true
21
+ end
22
+
23
+ private
24
+
25
+ def move_to_trash(path)
26
+ trash_commands.each do |name, command|
27
+ next unless executable?(name)
28
+
29
+ return true if @command_runner.call(command, path) && !File.exist?(path)
30
+ end
31
+
32
+ false
33
+ end
34
+
35
+ def trash_commands
36
+ if windows?
37
+ powershell_trash_commands
38
+ elsif macos?
39
+ [["osascript", ["osascript", "-e", "on run argv", "-e", "tell application \"Finder\" to delete POSIX file (item 1 of argv)", "-e", "end run"]]]
40
+ else
41
+ [
42
+ ["gio", ["gio", "trash"]],
43
+ ["trash-put", ["trash-put"]],
44
+ ["kioclient5", ["kioclient5", "move", nil, "trash:/"]],
45
+ ["kioclient", ["kioclient", "move", nil, "trash:/"]]
46
+ ]
47
+ end
48
+ end
49
+
50
+ def powershell_trash_commands
51
+ script = <<~POWERSHELL.strip
52
+ Add-Type -AssemblyName Microsoft.VisualBasic;
53
+ [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile($args[0], 'OnlyErrorDialogs', 'SendToRecycleBin')
54
+ POWERSHELL
55
+ [
56
+ ["pwsh", ["pwsh", "-NoProfile", "-Command", script]],
57
+ ["powershell", ["powershell", "-NoProfile", "-Command", script]]
58
+ ]
59
+ end
60
+
61
+ def executable?(name)
62
+ paths = @env.fetch("PATH", "").split(File::PATH_SEPARATOR)
63
+ extensions = windows? ? @env.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") : [""]
64
+ paths.any? do |path|
65
+ extensions.any? do |extension|
66
+ candidate = File.join(path, name + extension)
67
+ File.file?(candidate) && File.executable?(candidate)
68
+ end
69
+ end
70
+ end
71
+
72
+ def run_command(command, path)
73
+ command = if command.include?(nil)
74
+ command.map { |part| part || path }
75
+ else
76
+ command + [path]
77
+ end
78
+ _stdout, _stderr, status = Open3.capture3(*command)
79
+ status.success?
80
+ rescue StandardError
81
+ false
82
+ end
83
+
84
+ def macos?
85
+ host_os.match?(/darwin/i)
86
+ end
87
+
88
+ def windows?
89
+ host_os.match?(/mswin|mingw|cygwin/i)
90
+ end
91
+
92
+ def host_os
93
+ @host_os
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,264 @@
1
+ require "json"
2
+ require_relative "message_access"
3
+ require_relative "tools/tool_call"
4
+
5
+ module Kward
6
+ class SessionTreeRenderer
7
+ def initialize(roots:, current_leaf_id:)
8
+ @roots = roots
9
+ @current_leaf_id = current_leaf_id
10
+ end
11
+
12
+ def items
13
+ active_path = session_tree_active_path(@roots, @current_leaf_id)
14
+ tool_calls_by_id = session_tree_tool_calls(@roots)
15
+ visible_roots = @roots.flat_map { |root| visible_session_tree_nodes(root) }
16
+ multiple_roots = visible_roots.length > 1
17
+ result = []
18
+
19
+ walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
20
+ entry = node[:source]["entry"] || {}
21
+ display_indent = multiple_roots ? [indent - 1, 0].max : indent
22
+ prefix = session_tree_visual_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
23
+ result << {
24
+ entry: entry,
25
+ label: session_tree_label(entry, node[:source], prefix, active_path.include?(entry["id"].to_s), tool_calls_by_id)
26
+ }
27
+
28
+ children = node[:children].sort_by { |child| session_tree_contains_active_path?(child, active_path) ? 0 : 1 }
29
+ multiple_children = children.length > 1
30
+ child_indent = if multiple_children
31
+ indent + 1
32
+ elsif just_branched && indent.positive?
33
+ indent + 1
34
+ else
35
+ indent
36
+ end
37
+ connector_position = [display_indent - 1, 0].max
38
+ child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
39
+
40
+ children.each_with_index do |child, index|
41
+ walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
42
+ end
43
+ end
44
+
45
+ visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
46
+ walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
47
+ end
48
+
49
+ result
50
+ end
51
+
52
+ private
53
+
54
+ def visible_session_tree_nodes(node)
55
+ children = Array(node["children"]).flat_map { |child| visible_session_tree_nodes(child) }
56
+ return children if hidden_session_tree_entry?(node["entry"] || {})
57
+
58
+ [{ source: node, children: children }]
59
+ end
60
+
61
+ def hidden_session_tree_entry?(entry)
62
+ return false if @current_leaf_id && entry["id"].to_s == @current_leaf_id.to_s
63
+ return false unless entry["type"] == "message"
64
+
65
+ message = entry["message"]
66
+ return false unless message.is_a?(Hash) && message_role(message) == "assistant"
67
+
68
+ content = message_content(message)
69
+ content_tool_calls = content.is_a?(Array) && content.any? { |part| session_tree_content_part_value(part, :type) == "toolCall" }
70
+ (content_tool_calls && !session_tree_text_content?(content)) || (!message_tool_calls(message).empty? && full_message_text(message).empty?)
71
+ end
72
+
73
+ def session_tree_text_content?(content)
74
+ Array(content).any? { |part| session_tree_content_part_value(part, :type) == "text" && session_tree_content_part_value(part, :text).to_s.strip != "" }
75
+ end
76
+
77
+ def session_tree_content_part_value(part, key)
78
+ MessageAccess.value(part, key)
79
+ end
80
+
81
+ def session_tree_label(entry, node, prefix, active_path, tool_calls_by_id)
82
+ label = node["label"] || entry["resolvedLabel"]
83
+ label = label.to_s.strip
84
+ label_text = label.empty? ? "" : "[#{label}] "
85
+ path_marker = active_path ? "• " : ""
86
+ "#{prefix}#{path_marker}#{label_text}#{session_tree_entry_display(entry, tool_calls_by_id)}"
87
+ end
88
+
89
+ def session_tree_entry_display(entry, tool_calls_by_id = {})
90
+ case entry["type"]
91
+ when "message"
92
+ message = entry["message"] || {}
93
+ role = message_role(message).to_s
94
+ return session_tree_tool_display(message, tool_calls_by_id) if ["tool", "toolResult"].include?(role)
95
+
96
+ "#{role.empty? ? 'message' : role}: #{display_message_text(message)}"
97
+ when "compaction"
98
+ "compaction: #{display_message_text(entry["message"] || {})}"
99
+ when "branch_summary"
100
+ "[branch summary]: #{truncate_session_tree_text(entry["summary"])}"
101
+ else
102
+ entry["type"].to_s
103
+ end
104
+ end
105
+
106
+ def session_tree_visual_prefix(display_indent, gutters, show_connector, is_last, foldable)
107
+ return "" if display_indent.to_i <= 0
108
+
109
+ connector_position = show_connector ? display_indent - 1 : -1
110
+ (0...(display_indent * 3)).map do |index|
111
+ level = index / 3
112
+ position = index % 3
113
+ gutter = gutters.find { |candidate| candidate[:position] == level }
114
+
115
+ if gutter
116
+ position.zero? && gutter[:show] ? "│" : " "
117
+ elsif show_connector && level == connector_position
118
+ if position.zero?
119
+ is_last ? "└" : "├"
120
+ elsif position == 1
121
+ foldable ? "⊟" : "─"
122
+ else
123
+ " "
124
+ end
125
+ else
126
+ " "
127
+ end
128
+ end.join
129
+ end
130
+
131
+ def session_tree_contains_active_path?(node, active_path)
132
+ entry_id = (node[:source]["entry"] || {})["id"].to_s
133
+ active_path.include?(entry_id) || node[:children].any? { |child| session_tree_contains_active_path?(child, active_path) }
134
+ end
135
+
136
+ def session_tree_active_path(roots, leaf_id)
137
+ by_id = session_tree_entries_by_id(roots)
138
+ ids = []
139
+ entry = by_id[leaf_id.to_s]
140
+ while entry
141
+ ids << entry["id"].to_s
142
+ entry = by_id[entry["parentId"].to_s]
143
+ end
144
+ ids
145
+ end
146
+
147
+ def session_tree_entries_by_id(roots)
148
+ roots.each_with_object({}) do |root, map|
149
+ stack = [root]
150
+ until stack.empty?
151
+ node = stack.pop
152
+ entry = node["entry"] || {}
153
+ map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
154
+ stack.concat(Array(node["children"]))
155
+ end
156
+ end
157
+ end
158
+
159
+ def session_tree_tool_calls(roots)
160
+ roots.each_with_object({}) do |root, tool_calls|
161
+ stack = [root]
162
+ until stack.empty?
163
+ node = stack.pop
164
+ entry = node["entry"] || {}
165
+ message = entry["message"]
166
+ if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
167
+ message_tool_calls(message).each { |tool_call| tool_calls[tool_call_id(tool_call).to_s] = tool_call }
168
+ end
169
+ stack.concat(Array(node["children"]))
170
+ end
171
+ end
172
+ end
173
+
174
+ def session_tree_tool_display(message, tool_calls_by_id)
175
+ tool_call = tool_calls_by_id[session_tree_message_tool_call_id(message).to_s]
176
+ return session_tree_format_tool_call(tool_call) if tool_call
177
+
178
+ name = session_tree_message_tool_name(message).to_s
179
+ "[#{name.empty? ? 'tool' : name}]"
180
+ end
181
+
182
+ def session_tree_message_tool_call_id(message)
183
+ message_tool_call_id(message) || MessageAccess.value(message, :toolCallId)
184
+ end
185
+
186
+ def session_tree_message_tool_name(message)
187
+ message_name(message) || MessageAccess.value(message, :toolName)
188
+ end
189
+
190
+ def session_tree_format_tool_call(tool_call)
191
+ name = ToolCall.display_name(tool_call)
192
+ args = tool_call_args(tool_call)
193
+ case name
194
+ when "read"
195
+ path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
196
+ offset = args["offset"] || args[:offset]
197
+ limit = args["limit"] || args[:limit]
198
+ display = path.to_s
199
+ if offset || limit
200
+ start_line = offset || 1
201
+ end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
202
+ display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
203
+ end
204
+ "[read: #{display}]"
205
+ when "write", "edit"
206
+ path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
207
+ "[#{name}: #{path}]"
208
+ when "bash"
209
+ command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
210
+ "[bash: #{command.length > 50 ? "#{command.slice(0, 50)}..." : command}]"
211
+ else
212
+ serialized = JSON.dump(args)
213
+ "[#{name}: #{serialized.length > 40 ? "#{serialized.slice(0, 40)}..." : serialized}]"
214
+ end
215
+ end
216
+
217
+ def display_message_text(message)
218
+ truncate_session_tree_text(full_message_text(message))
219
+ end
220
+
221
+ def truncate_session_tree_text(text)
222
+ normalized = text.to_s.gsub(/\s+/, " ").strip
223
+ normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
224
+ end
225
+
226
+ def full_message_text(message)
227
+ content = message_content(message)
228
+ text = if content.is_a?(Array)
229
+ content.filter_map { |part| MessageAccess.value(part, :text) }.join("\n")
230
+ else
231
+ content.to_s
232
+ end
233
+ text.strip
234
+ end
235
+
236
+ def message_role(message)
237
+ MessageAccess.role(message)
238
+ end
239
+
240
+ def message_content(message)
241
+ MessageAccess.content(message)
242
+ end
243
+
244
+ def message_name(message)
245
+ MessageAccess.name(message)
246
+ end
247
+
248
+ def message_tool_call_id(message)
249
+ MessageAccess.tool_call_id(message)
250
+ end
251
+
252
+ def message_tool_calls(message)
253
+ MessageAccess.tool_calls(message)
254
+ end
255
+
256
+ def tool_call_id(tool_call)
257
+ ToolCall.id(tool_call)
258
+ end
259
+
260
+ def tool_call_args(tool_call)
261
+ ToolCall.arguments(tool_call)
262
+ end
263
+ end
264
+ end
@@ -69,7 +69,9 @@ module Kward
69
69
  end
70
70
 
71
71
  def build_schema_tools
72
- tools = core_tools
72
+ tools = @tools.values_at(
73
+ "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
74
+ )
73
75
  tools << @tools["web_search"] if web_search_available?
74
76
  tools << @tools["read_skill"] if skills_available?
75
77
  tools << @tools["ask_user_question"] if ask_user_question_available?
data/lib/kward/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Kward
2
2
  # Current gem version.
3
- VERSION = "0.66.0"
3
+ VERSION = "0.67.0"
4
4
  end
@@ -12,8 +12,9 @@ module Kward
12
12
  MAX_EDIT_DIFF_BYTES = 8 * 1024
13
13
  DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
14
14
 
15
- def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES)
15
+ def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
16
16
  @root = Pathname.new(root).realpath
17
+ @guardrails = guardrails
17
18
  @max_file_bytes = max_file_bytes
18
19
  @max_read_output_bytes = max_read_output_bytes
19
20
  @max_read_output_lines = max_read_output_lines
@@ -124,10 +125,10 @@ module Kward
124
125
  target = @root.join(target) unless target.absolute?
125
126
 
126
127
  expanded = target.expand_path
127
- raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(expanded)
128
+ raise SecurityError, "path outside workspace: #{path}" if guardrails_enabled? && !inside_workspace?(expanded)
128
129
 
129
130
  resolved = target.realpath
130
- raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(resolved)
131
+ raise SecurityError, "path outside workspace: #{path}" if guardrails_enabled? && !inside_workspace?(resolved)
131
132
 
132
133
  resolved.to_s
133
134
  end
@@ -137,16 +138,20 @@ module Kward
137
138
  target = @root.join(target) unless target.absolute?
138
139
 
139
140
  expanded = target.expand_path
140
- raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(expanded)
141
+ raise SecurityError, "path outside workspace: #{path}" if guardrails_enabled? && !inside_workspace?(expanded)
141
142
 
142
143
  return workspace_path(path) if File.exist?(expanded) || File.symlink?(expanded)
143
144
 
144
145
  parent = expanded.dirname.realpath
145
- raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(parent)
146
+ raise SecurityError, "path outside workspace: #{path}" if guardrails_enabled? && !inside_workspace?(parent)
146
147
 
147
148
  expanded.to_s
148
149
  end
149
150
 
151
+ def guardrails_enabled?
152
+ @guardrails != false
153
+ end
154
+
150
155
  def inside_workspace?(path)
151
156
  path.to_s == @root.to_s || path.to_s.start_with?("#{@root}/")
152
157
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.66.0
4
+ version: 0.67.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood
@@ -132,6 +132,7 @@ files:
132
132
  - doc/plugins.md
133
133
  - doc/releasing.md
134
134
  - doc/rpc.md
135
+ - doc/troubleshooting.md
135
136
  - doc/usage.md
136
137
  - doc/web-search.md
137
138
  - exe/kward
@@ -145,7 +146,9 @@ files:
145
146
  - lib/kward/auth/openrouter_api_key.rb
146
147
  - lib/kward/cancellation.rb
147
148
  - lib/kward/cli.rb
149
+ - lib/kward/cli_transcript_formatter.rb
148
150
  - lib/kward/clipboard.rb
151
+ - lib/kward/compaction/file_operation_tracker.rb
149
152
  - lib/kward/compactor.rb
150
153
  - lib/kward/config_files.rb
151
154
  - lib/kward/conversation.rb
@@ -160,6 +163,7 @@ files:
160
163
  - lib/kward/model/context_overflow.rb
161
164
  - lib/kward/model/context_usage.rb
162
165
  - lib/kward/model/model_info.rb
166
+ - lib/kward/model/payloads.rb
163
167
  - lib/kward/model/retry_message.rb
164
168
  - lib/kward/model/stream_parser.rb
165
169
  - lib/kward/pan/index.html.erb
@@ -167,6 +171,7 @@ files:
167
171
  - lib/kward/plugin_registry.rb
168
172
  - lib/kward/private_file.rb
169
173
  - lib/kward/prompt_interface.rb
174
+ - lib/kward/prompt_interface/banner.rb
170
175
  - lib/kward/prompts.rb
171
176
  - lib/kward/prompts/commands.rb
172
177
  - lib/kward/prompts/templates.rb
@@ -176,6 +181,7 @@ files:
176
181
  - lib/kward/rpc/config_manager.rb
177
182
  - lib/kward/rpc/prompt_bridge.rb
178
183
  - lib/kward/rpc/redactor.rb
184
+ - lib/kward/rpc/runtime_payloads.rb
179
185
  - lib/kward/rpc/server.rb
180
186
  - lib/kward/rpc/session_manager.rb
181
187
  - lib/kward/rpc/tool_event_normalizer.rb
@@ -184,6 +190,8 @@ files:
184
190
  - lib/kward/rpc/transport.rb
185
191
  - lib/kward/session_diff.rb
186
192
  - lib/kward/session_store.rb
193
+ - lib/kward/session_trash.rb
194
+ - lib/kward/session_tree_renderer.rb
187
195
  - lib/kward/skills/registry.rb
188
196
  - lib/kward/starter_pack_installer.rb
189
197
  - lib/kward/steering.rb