kward 0.69.0 → 0.69.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a84b87d1fd620a0008222c1f7cf6efd3fb7d019818e6674f75e3160ad738d2b3
4
- data.tar.gz: 297fc5bd96d88356731b659cd6d43da8349e5711e50f1419a11afdab82192d57
3
+ metadata.gz: 4ac5f4222e4587d469ce5cb838e4fb0404309083a85a144378f16e75fdfa76ba
4
+ data.tar.gz: 325995cade98eb049a465bbfa9d12f2c7ffc9ed1fec0b14be889d2810741fe93
5
5
  SHA512:
6
- metadata.gz: 1f7614a284acdd69deeeb8ae509ca42b093e7e9e55eac47d4c23d63d30ca0ccb4079c5fc21f747863097d1c938084627d6576b32b12cdecc7754c5cecff558db
7
- data.tar.gz: ccc2a3e15276ef03d50b4d52ccdc1b1977c1555b3f367d152b8d889ee518d315645a910a6d1940a94e7fb5e378bf5a614bfe87189b8e723505e7eba3aaf13c0d
6
+ metadata.gz: 7fe3449248d4ae757422cd8e8113ad23b1bdb9dd6555015c64ea628911a8aa937a336b109945029b94f3f5e3fa4846b017af9455e839188ae3b2eb9ca1254cac
7
+ data.tar.gz: a92899f7e09d769220246923442450312b27852a801e64f273c3237594ab831b578ad72bde87f6fc6611c4b6fb5a02f4e6080bb7ecb2cd7cc3130e4804e76fc0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to Kward will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.69.1] - 2026-06-18
8
+
9
+ ### Fixed
10
+
11
+ - Fixed `/tree` session rendering to tolerate malformed cyclic tree records instead of overflowing the Ruby stack.
12
+
7
13
  ## [0.69.0] - 2026-06-17
8
14
 
9
15
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kward (0.69.0)
4
+ kward (0.69.1)
5
5
  base64
6
6
  nokogiri
7
7
  tiktoken_ruby
@@ -70,7 +70,7 @@ CHECKSUMS
70
70
  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
71
71
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
72
72
  erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9
73
- kward (0.69.0)
73
+ kward (0.69.1)
74
74
  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
75
75
  nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42
76
76
  nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976
@@ -34,7 +34,12 @@ module Kward
34
34
  multiple_roots = visible_roots.length > 1
35
35
  result = []
36
36
 
37
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
37
+ stack = visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
+ [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
39
+ end.reverse
40
+
41
+ until stack.empty?
42
+ node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
38
43
  entry = node[:source]["entry"] || {}
39
44
  entry_id = entry["id"].to_s
40
45
  formatted = tree_entry_display(entry, tool_calls_by_id)
@@ -66,14 +71,10 @@ module Kward
66
71
  end
67
72
  connector_position = [display_indent - 1, 0].max
68
73
  child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
69
- children.each_with_index do |child, index|
70
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
74
+ children.each_with_index.reverse_each do |child, index|
75
+ stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
71
76
  end
72
77
  end
73
-
74
- visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
75
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
76
- end
77
78
  result
78
79
  end
79
80
 
@@ -83,7 +84,9 @@ module Kward
83
84
  by_id = tree_entries_by_id(roots)
84
85
  ids = []
85
86
  current = by_id[leaf_id.to_s]
86
- while current
87
+ seen = {}
88
+ while current && !seen[current["id"].to_s]
89
+ seen[current["id"].to_s] = true
87
90
  ids << current["id"].to_s
88
91
  current = by_id[current["parentId"].to_s]
89
92
  end
@@ -93,8 +96,12 @@ module Kward
93
96
  def tree_entries_by_id(roots)
94
97
  roots.each_with_object({}) do |root, map|
95
98
  stack = [root]
99
+ seen = {}
96
100
  until stack.empty?
97
101
  node = stack.pop
102
+ next if seen[node.object_id]
103
+
104
+ seen[node.object_id] = true
98
105
  entry = node["entry"] || {}
99
106
  map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
100
107
  stack.concat(Array(node["children"]))
@@ -103,10 +110,29 @@ module Kward
103
110
  end
104
111
 
105
112
  def visible_tree_nodes(node)
106
- children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child) }
107
- return children if hidden_tree_entry?(node["entry"] || {})
113
+ results = {}
114
+ stack = [[node, false, {}]]
115
+
116
+ until stack.empty?
117
+ current, visited, seen = stack.pop
118
+ node_key = current.object_id
119
+ next if seen[node_key]
120
+
121
+ if visited
122
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
123
+ results[node_key] = if hidden_tree_entry?(current["entry"] || {})
124
+ children
125
+ else
126
+ [{ source: current, children: children }]
127
+ end
128
+ else
129
+ branch_seen = seen.merge(node_key => true)
130
+ stack << [current, true, seen]
131
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
132
+ end
133
+ end
108
134
 
109
- [{ source: node, children: children }]
135
+ results[node.object_id] || []
110
136
  end
111
137
 
112
138
  def hidden_tree_entry?(entry)
@@ -126,15 +152,30 @@ module Kward
126
152
  end
127
153
 
128
154
  def tree_contains_active_path?(node, active_path)
129
- entry_id = (node[:source]["entry"] || {})["id"].to_s
130
- active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
155
+ stack = [node]
156
+ seen = {}
157
+ until stack.empty?
158
+ current = stack.pop
159
+ next if seen[current.object_id]
160
+
161
+ seen[current.object_id] = true
162
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
163
+ return true if active_path.include?(entry_id)
164
+
165
+ stack.concat(current[:children])
166
+ end
167
+ false
131
168
  end
132
169
 
133
170
  def tree_tool_calls(roots)
134
171
  roots.each_with_object({}) do |root, tool_calls_by_id|
135
172
  stack = [root]
173
+ seen = {}
136
174
  until stack.empty?
137
175
  node = stack.pop
176
+ next if seen[node.object_id]
177
+
178
+ seen[node.object_id] = true
138
179
  entry = node["entry"] || {}
139
180
  message = entry["message"]
140
181
  if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
@@ -592,7 +592,11 @@ module Kward
592
592
  next unless node
593
593
 
594
594
  parent = nodes[entry["parentId"].to_s]
595
- parent ? parent["children"] << node : roots << node
595
+ if parent && !parent.equal?(node)
596
+ parent["children"] << node unless parent["children"].include?(node)
597
+ else
598
+ roots << node unless roots.include?(node)
599
+ end
596
600
  end
597
601
  roots
598
602
  end
@@ -19,7 +19,12 @@ module Kward
19
19
  multiple_roots = visible_roots.length > 1
20
20
  result = []
21
21
 
22
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
22
+ stack = visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
23
+ [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
24
+ end.reverse
25
+
26
+ until stack.empty?
27
+ node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
23
28
  entry = node[:source]["entry"] || {}
24
29
  display_indent = multiple_roots ? [indent - 1, 0].max : indent
25
30
  prefix = session_tree_visual_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
@@ -40,25 +45,40 @@ module Kward
40
45
  connector_position = [display_indent - 1, 0].max
41
46
  child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
42
47
 
43
- children.each_with_index do |child, index|
44
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
48
+ children.each_with_index.reverse_each do |child, index|
49
+ stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
45
50
  end
46
51
  end
47
52
 
48
- visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
49
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
50
- end
51
-
52
53
  result
53
54
  end
54
55
 
55
56
  private
56
57
 
57
58
  def visible_session_tree_nodes(node)
58
- children = Array(node["children"]).flat_map { |child| visible_session_tree_nodes(child) }
59
- return children if hidden_session_tree_entry?(node["entry"] || {})
59
+ results = {}
60
+ stack = [[node, false, {}]]
61
+
62
+ until stack.empty?
63
+ current, visited, seen = stack.pop
64
+ node_key = current.object_id
65
+ next if seen[node_key]
66
+
67
+ if visited
68
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
69
+ results[node_key] = if hidden_session_tree_entry?(current["entry"] || {})
70
+ children
71
+ else
72
+ [{ source: current, children: children }]
73
+ end
74
+ else
75
+ branch_seen = seen.merge(node_key => true)
76
+ stack << [current, true, seen]
77
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
78
+ end
79
+ end
60
80
 
61
- [{ source: node, children: children }]
81
+ results[node.object_id] || []
62
82
  end
63
83
 
64
84
  def hidden_session_tree_entry?(entry)
@@ -132,15 +152,28 @@ module Kward
132
152
  end
133
153
 
134
154
  def session_tree_contains_active_path?(node, active_path)
135
- entry_id = (node[:source]["entry"] || {})["id"].to_s
136
- active_path.include?(entry_id) || node[:children].any? { |child| session_tree_contains_active_path?(child, active_path) }
155
+ stack = [node]
156
+ seen = {}
157
+ until stack.empty?
158
+ current = stack.pop
159
+ next if seen[current.object_id]
160
+
161
+ seen[current.object_id] = true
162
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
163
+ return true if active_path.include?(entry_id)
164
+
165
+ stack.concat(current[:children])
166
+ end
167
+ false
137
168
  end
138
169
 
139
170
  def session_tree_active_path(roots, leaf_id)
140
171
  by_id = session_tree_entries_by_id(roots)
141
172
  ids = []
142
173
  entry = by_id[leaf_id.to_s]
143
- while entry
174
+ seen = {}
175
+ while entry && !seen[entry["id"].to_s]
176
+ seen[entry["id"].to_s] = true
144
177
  ids << entry["id"].to_s
145
178
  entry = by_id[entry["parentId"].to_s]
146
179
  end
@@ -150,8 +183,12 @@ module Kward
150
183
  def session_tree_entries_by_id(roots)
151
184
  roots.each_with_object({}) do |root, map|
152
185
  stack = [root]
186
+ seen = {}
153
187
  until stack.empty?
154
188
  node = stack.pop
189
+ next if seen[node.object_id]
190
+
191
+ seen[node.object_id] = true
155
192
  entry = node["entry"] || {}
156
193
  map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
157
194
  stack.concat(Array(node["children"]))
@@ -162,8 +199,12 @@ module Kward
162
199
  def session_tree_tool_calls(roots)
163
200
  roots.each_with_object({}) do |root, tool_calls|
164
201
  stack = [root]
202
+ seen = {}
165
203
  until stack.empty?
166
204
  node = stack.pop
205
+ next if seen[node.object_id]
206
+
207
+ seen[node.object_id] = true
167
208
  entry = node["entry"] || {}
168
209
  message = entry["message"]
169
210
  if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.69.0"
4
+ VERSION = "0.69.1"
5
5
  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.69.0
4
+ version: 0.69.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood