kward 0.70.0 → 0.72.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -0,0 +1,136 @@
1
+ require_relative "message_access"
2
+ require_relative "message_text"
3
+ require_relative "tools/tool_call"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Shared traversal helpers for persisted session trees.
8
+ #
9
+ # Frontends own their final labels or payloads. This class owns only the
10
+ # frontend-neutral mechanics needed by both terminal and RPC tree views:
11
+ # active-path lookup, hidden tool-call-only assistant nodes, visible-node
12
+ # flattening, and assistant tool-call lookup by id.
13
+ class SessionTreeNodes
14
+ def initialize(roots:, current_leaf: nil)
15
+ @roots = roots
16
+ @current_leaf = current_leaf
17
+ end
18
+
19
+ def active_path
20
+ by_id = entries_by_id
21
+ ids = []
22
+ entry = by_id[@current_leaf.to_s]
23
+ seen = {}
24
+ while entry && !seen[entry["id"].to_s]
25
+ seen[entry["id"].to_s] = true
26
+ ids << entry["id"].to_s
27
+ entry = by_id[entry["parentId"].to_s]
28
+ end
29
+ ids
30
+ end
31
+
32
+ def visible_roots
33
+ @roots.flat_map { |root| visible_nodes(root) }
34
+ end
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
+ def tool_calls
53
+ @roots.each_with_object({}) do |root, calls|
54
+ stack = [root]
55
+ seen = {}
56
+ until stack.empty?
57
+ node = stack.pop
58
+ next if seen[node.object_id]
59
+
60
+ seen[node.object_id] = true
61
+ entry = node["entry"] || {}
62
+ message = entry["message"]
63
+ if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
64
+ MessageAccess.tool_calls(message).each { |tool_call| calls[ToolCall.id(tool_call).to_s] = tool_call }
65
+ end
66
+ stack.concat(Array(node["children"]))
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.truncate_text(text)
72
+ normalized = text.to_s.gsub(/\s+/, " ").strip
73
+ normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
74
+ end
75
+
76
+ private
77
+
78
+ def visible_nodes(node)
79
+ results = {}
80
+ stack = [[node, false, {}]]
81
+
82
+ until stack.empty?
83
+ current, visited, seen = stack.pop
84
+ node_key = current.object_id
85
+ next if seen[node_key]
86
+
87
+ if visited
88
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
89
+ results[node_key] = if hidden_entry?(current["entry"] || {})
90
+ children
91
+ else
92
+ [{ source: current, children: children }]
93
+ end
94
+ else
95
+ branch_seen = seen.merge(node_key => true)
96
+ stack << [current, true, seen]
97
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
98
+ end
99
+ end
100
+
101
+ results[node.object_id] || []
102
+ end
103
+
104
+ def hidden_entry?(entry)
105
+ return false if @current_leaf && entry["id"].to_s == @current_leaf.to_s
106
+ return false unless entry["type"] == "message"
107
+
108
+ message = entry["message"]
109
+ return false unless message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
110
+
111
+ content = MessageAccess.content(message)
112
+ content_tool_calls = content.is_a?(Array) && content.any? { |part| MessageAccess.value(part, :type) == "toolCall" }
113
+ (content_tool_calls && !text_content?(content)) || (!MessageAccess.tool_calls(message).empty? && MessageText.full_text(message).empty?)
114
+ end
115
+
116
+ def text_content?(content)
117
+ Array(content).any? { |part| MessageAccess.value(part, :type) == "text" && MessageAccess.value(part, :text).to_s.strip != "" }
118
+ end
119
+
120
+ def entries_by_id
121
+ @roots.each_with_object({}) do |root, map|
122
+ stack = [root]
123
+ seen = {}
124
+ until stack.empty?
125
+ node = stack.pop
126
+ next if seen[node.object_id]
127
+
128
+ seen[node.object_id] = true
129
+ entry = node["entry"] || {}
130
+ map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
131
+ stack.concat(Array(node["children"]))
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -1,7 +1,7 @@
1
1
  require_relative "message_access"
2
2
  require_relative "message_text"
3
+ require_relative "session_tree_nodes"
3
4
  require_relative "session_tree_tool_display"
4
- require_relative "tools/tool_call"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -13,13 +13,14 @@ module Kward
13
13
  end
14
14
 
15
15
  def items
16
- active_path = session_tree_active_path(@roots, @current_leaf_id)
17
- tool_calls_by_id = session_tree_tool_calls(@roots)
18
- visible_roots = @roots.flat_map { |root| visible_session_tree_nodes(root) }
16
+ tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf_id)
17
+ active_path = tree_nodes.active_path
18
+ tool_calls_by_id = tree_nodes.tool_calls
19
+ visible_roots = tree_nodes.visible_roots
19
20
  multiple_roots = visible_roots.length > 1
20
21
  result = []
21
22
 
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
+ stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
23
24
  [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
24
25
  end.reverse
25
26
 
@@ -33,7 +34,7 @@ module Kward
33
34
  label: session_tree_label(entry, node[:source], prefix, active_path.include?(entry["id"].to_s), tool_calls_by_id)
34
35
  }
35
36
 
36
- children = node[:children].sort_by { |child| session_tree_contains_active_path?(child, active_path) ? 0 : 1 }
37
+ children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
37
38
  multiple_children = children.length > 1
38
39
  child_indent = if multiple_children
39
40
  indent + 1
@@ -55,52 +56,6 @@ module Kward
55
56
 
56
57
  private
57
58
 
58
- def visible_session_tree_nodes(node)
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
80
-
81
- results[node.object_id] || []
82
- end
83
-
84
- def hidden_session_tree_entry?(entry)
85
- return false if @current_leaf_id && entry["id"].to_s == @current_leaf_id.to_s
86
- return false unless entry["type"] == "message"
87
-
88
- message = entry["message"]
89
- return false unless message.is_a?(Hash) && message_role(message) == "assistant"
90
-
91
- content = message_content(message)
92
- content_tool_calls = content.is_a?(Array) && content.any? { |part| session_tree_content_part_value(part, :type) == "toolCall" }
93
- (content_tool_calls && !session_tree_text_content?(content)) || (!message_tool_calls(message).empty? && full_message_text(message).empty?)
94
- end
95
-
96
- def session_tree_text_content?(content)
97
- Array(content).any? { |part| session_tree_content_part_value(part, :type) == "text" && session_tree_content_part_value(part, :text).to_s.strip != "" }
98
- end
99
-
100
- def session_tree_content_part_value(part, key)
101
- MessageAccess.value(part, key)
102
- end
103
-
104
59
  def session_tree_label(entry, node, prefix, active_path, tool_calls_by_id)
105
60
  label = node["label"] || entry["resolvedLabel"]
106
61
  label = label.to_s.strip
@@ -152,70 +107,6 @@ module Kward
152
107
  end.join
153
108
  end
154
109
 
155
- def session_tree_contains_active_path?(node, active_path)
156
- stack = [node]
157
- seen = {}
158
- until stack.empty?
159
- current = stack.pop
160
- next if seen[current.object_id]
161
-
162
- seen[current.object_id] = true
163
- entry_id = (current[:source]["entry"] || {})["id"].to_s
164
- return true if active_path.include?(entry_id)
165
-
166
- stack.concat(current[:children])
167
- end
168
- false
169
- end
170
-
171
- def session_tree_active_path(roots, leaf_id)
172
- by_id = session_tree_entries_by_id(roots)
173
- ids = []
174
- entry = by_id[leaf_id.to_s]
175
- seen = {}
176
- while entry && !seen[entry["id"].to_s]
177
- seen[entry["id"].to_s] = true
178
- ids << entry["id"].to_s
179
- entry = by_id[entry["parentId"].to_s]
180
- end
181
- ids
182
- end
183
-
184
- def session_tree_entries_by_id(roots)
185
- roots.each_with_object({}) do |root, map|
186
- stack = [root]
187
- seen = {}
188
- until stack.empty?
189
- node = stack.pop
190
- next if seen[node.object_id]
191
-
192
- seen[node.object_id] = true
193
- entry = node["entry"] || {}
194
- map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
195
- stack.concat(Array(node["children"]))
196
- end
197
- end
198
- end
199
-
200
- def session_tree_tool_calls(roots)
201
- roots.each_with_object({}) do |root, tool_calls|
202
- stack = [root]
203
- seen = {}
204
- until stack.empty?
205
- node = stack.pop
206
- next if seen[node.object_id]
207
-
208
- seen[node.object_id] = true
209
- entry = node["entry"] || {}
210
- message = entry["message"]
211
- if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
212
- message_tool_calls(message).each { |tool_call| tool_calls[tool_call_id(tool_call).to_s] = tool_call }
213
- end
214
- stack.concat(Array(node["children"]))
215
- end
216
- end
217
- end
218
-
219
110
  def session_tree_tool_display(message, tool_calls_by_id)
220
111
  tool_call = tool_calls_by_id[session_tree_message_tool_call_id(message).to_s]
221
112
  return SessionTreeToolDisplay.label(tool_call) if tool_call
@@ -233,12 +124,11 @@ module Kward
233
124
  end
234
125
 
235
126
  def display_message_text(message)
236
- truncate_session_tree_text(full_message_text(message))
127
+ SessionTreeNodes.truncate_text(full_message_text(message))
237
128
  end
238
129
 
239
130
  def truncate_session_tree_text(text)
240
- normalized = text.to_s.gsub(/\s+/, " ").strip
241
- normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
131
+ SessionTreeNodes.truncate_text(text)
242
132
  end
243
133
 
244
134
  def full_message_text(message)
@@ -249,10 +139,6 @@ module Kward
249
139
  MessageAccess.role(message)
250
140
  end
251
141
 
252
- def message_content(message)
253
- MessageAccess.content(message)
254
- end
255
-
256
142
  def message_name(message)
257
143
  MessageAccess.name(message)
258
144
  end
@@ -261,13 +147,5 @@ module Kward
261
147
  MessageAccess.tool_call_id(message)
262
148
  end
263
149
 
264
- def message_tool_calls(message)
265
- MessageAccess.tool_calls(message)
266
- end
267
-
268
- def tool_call_id(tool_call)
269
- ToolCall.id(tool_call)
270
- end
271
-
272
150
  end
273
151
  end
@@ -0,0 +1,47 @@
1
+ require "digest"
2
+ require "json"
3
+ require_relative "config_files"
4
+ require_relative "private_file"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Persists the terminal UI's open session tabs per workspace.
9
+ class TabStore
10
+ def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
11
+ @config_dir = config_dir
12
+ @cwd = File.expand_path(cwd)
13
+ end
14
+
15
+ def load
16
+ return { "session_paths" => [], "active_index" => 0 } unless File.file?(path)
17
+
18
+ data = JSON.parse(File.read(path))
19
+ paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
20
+ labels = Array(data["labels"]).map(&:to_s)
21
+ active_index = data["active_index"].to_i
22
+ { "session_paths" => paths, "labels" => labels, "active_index" => active_index }
23
+ rescue JSON::ParserError
24
+ { "session_paths" => [], "active_index" => 0 }
25
+ end
26
+
27
+ def save(session_paths:, active_index:, labels: [])
28
+ paths = Array(session_paths).map(&:to_s).reject(&:empty?)
29
+ PrivateFile.write_json(path, {
30
+ "cwd" => @cwd,
31
+ "session_paths" => paths,
32
+ "labels" => Array(labels).map(&:to_s),
33
+ "active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
34
+ })
35
+ end
36
+
37
+ def path
38
+ File.join(@config_dir, "tabs", "#{workspace_key}.json")
39
+ end
40
+
41
+ private
42
+
43
+ def workspace_key
44
+ Digest::SHA256.hexdigest(@cwd)[0, 24]
45
+ end
46
+ end
47
+ end
@@ -8,13 +8,14 @@ require_relative "../rpc/redactor"
8
8
  module Kward
9
9
  # Append-only JSONL telemetry logger with secret-conscious error payloads.
10
10
  class TelemetryLogger
11
- CATEGORIES = %w[tokens performance tools errors].freeze
11
+ CATEGORIES = %w[tokens performance tools errors compaction].freeze
12
12
  ENV_KEYS = {
13
13
  "enabled" => "KWARD_LOGGING",
14
14
  "tokens" => "KWARD_LOGGING_TOKENS",
15
15
  "performance" => "KWARD_LOGGING_PERFORMANCE",
16
16
  "tools" => "KWARD_LOGGING_TOOLS",
17
- "errors" => "KWARD_LOGGING_ERRORS"
17
+ "errors" => "KWARD_LOGGING_ERRORS",
18
+ "compaction" => "KWARD_LOGGING_COMPACTION"
18
19
  }.freeze
19
20
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
20
21
 
@@ -101,7 +102,8 @@ module Kward
101
102
  "tokens" => truthy?(logging["tokens"]),
102
103
  "performance" => truthy?(logging["performance"]),
103
104
  "tools" => truthy?(logging["tools"]),
104
- "errors" => truthy?(logging["errors"])
105
+ "errors" => truthy?(logging["errors"]),
106
+ "compaction" => truthy?(logging["compaction"])
105
107
  }
106
108
  rescue StandardError
107
109
  CATEGORIES.each_with_object({ "enabled" => false }) { |category, result| result[category] = false }
@@ -0,0 +1,25 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Small text navigation helpers shared by composer and editor buffers.
4
+ module TextBoundary
5
+ module_function
6
+
7
+ def previous_word_boundary(text, index)
8
+ cursor = index.to_i
9
+ cursor -= 1 while cursor.positive? && word_separator?(text[cursor - 1])
10
+ cursor -= 1 while cursor.positive? && !word_separator?(text[cursor - 1])
11
+ cursor
12
+ end
13
+
14
+ def next_word_boundary(text, index)
15
+ cursor = index.to_i
16
+ cursor += 1 while cursor < text.length && word_separator?(text[cursor])
17
+ cursor += 1 while cursor < text.length && !word_separator?(text[cursor])
18
+ cursor
19
+ end
20
+
21
+ def word_separator?(char)
22
+ char.to_s.match?(/\s/)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,127 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Deterministically trims large tool outputs before they are appended to the
4
+ # model-facing transcript.
5
+ #
6
+ # The original output is still handed to session/tool-execution persistence by
7
+ # ToolRegistry; this object only decides what the next model call sees. Keep it
8
+ # conservative: small outputs and short errors are more valuable verbatim than
9
+ # compacted.
10
+ class ToolOutputCompactor
11
+ MIN_BYTES = 12 * 1024
12
+ ERROR_OUTPUT_MAX_BYTES = 8 * 1024
13
+ HEAD_LINES = 40
14
+ TAIL_LINES = 40
15
+ ERROR_CONTEXT_LINES = 2
16
+
17
+ ERROR_PATTERN = /\b(error|fatal|failed|failure|exception|traceback|panic|segmentation fault|assertion)\b/i.freeze
18
+ TEST_PATTERN = /(^\s*\d+\)\s|\b(\d+\s+(tests?|examples?|runs?|assertions?|failures?|errors?|skips?)|finished in|failures?:|seed\s+\d+)\b)/i.freeze
19
+ SEARCH_PATTERN = /(^\#{1,6}\s+\S+|^[-*]\s+\S+|\S+:\d+:|https?:\/\/\S+)/.freeze
20
+
21
+ def compact(tool_name, content, artifact_id: nil)
22
+ text = normalize(content)
23
+ return text unless text.bytesize > MIN_BYTES
24
+ return text if error_output?(text) && text.bytesize <= ERROR_OUTPUT_MAX_BYTES
25
+
26
+ compacted = tool_name.to_s == "run_shell_command" ? compact_shell_output(text) : compact_lines(text)
27
+ return text if compacted == text
28
+ return text if compacted.bytesize >= text.bytesize
29
+
30
+ artifact_id = yield if artifact_id.nil? && block_given?
31
+ header = compacted_header(tool_name, text, compacted, artifact_id: artifact_id)
32
+ candidate = "#{header}\n\n#{compacted}"
33
+ candidate.bytesize < text.bytesize ? candidate : text
34
+ end
35
+
36
+ private
37
+
38
+ def normalize(content)
39
+ return content unless content.is_a?(String)
40
+
41
+ Conversation.normalize_tool_content(content)
42
+ end
43
+
44
+ def error_output?(text)
45
+ text.match?(ERROR_PATTERN)
46
+ end
47
+
48
+ def compact_shell_output(text)
49
+ sections = shell_sections(text)
50
+ return compact_lines(text) if sections.empty?
51
+
52
+ sections.map do |heading, body|
53
+ next heading if body.empty?
54
+
55
+ "#{heading}\n#{compact_lines(body)}"
56
+ end.join("\n")
57
+ end
58
+
59
+ def shell_sections(text)
60
+ parts = text.split(/\n(?=STDOUT:\n|STDERR:\n)/)
61
+ return [] if parts.length < 2
62
+
63
+ parts.map do |part|
64
+ heading, body = part.split("\n", 2)
65
+ [heading, body.to_s]
66
+ end
67
+ end
68
+
69
+ def compact_lines(text)
70
+ lines = text.split("\n", -1)
71
+ selected = selected_line_indexes(lines)
72
+ return text if selected.length >= lines.length
73
+
74
+ render_selected_lines(lines, selected)
75
+ end
76
+
77
+ def selected_line_indexes(lines)
78
+ indexes = []
79
+ indexes.concat((0...[HEAD_LINES, lines.length].min).to_a)
80
+ indexes.concat(priority_context_indexes(lines))
81
+
82
+ tail_start = [lines.length - TAIL_LINES, 0].max
83
+ indexes.concat((tail_start...lines.length).to_a)
84
+ indexes.uniq.sort
85
+ end
86
+
87
+ def priority_context_indexes(lines)
88
+ indexes = []
89
+ lines.each_with_index do |line, index|
90
+ next unless line.match?(ERROR_PATTERN) || line.match?(TEST_PATTERN) || line.match?(SEARCH_PATTERN)
91
+
92
+ first = [index - ERROR_CONTEXT_LINES, 0].max
93
+ last = [index + ERROR_CONTEXT_LINES, lines.length - 1].min
94
+ indexes.concat((first..last).to_a)
95
+ end
96
+ indexes
97
+ end
98
+
99
+ def render_selected_lines(lines, selected)
100
+ output = []
101
+ previous = nil
102
+ selected.each do |index|
103
+ if previous && index > previous + 1
104
+ output << "[... omitted lines #{previous + 2}-#{index} ...]"
105
+ end
106
+ output << lines[index]
107
+ previous = index
108
+ end
109
+ output.join("\n")
110
+ end
111
+
112
+ def compacted_header(tool_name, original, compacted, artifact_id: nil)
113
+ [
114
+ "[Tool output compacted by Kward: #{original.bytesize} bytes -> #{compacted.bytesize} bytes]",
115
+ "Tool: #{tool_name}",
116
+ "Preserved first #{HEAD_LINES} lines, last #{TAIL_LINES} lines, and error/failure/search context.",
117
+ retrieval_instruction(artifact_id)
118
+ ].join("\n")
119
+ end
120
+
121
+ def retrieval_instruction(artifact_id)
122
+ return "Full output is retained outside model context." if artifact_id.to_s.empty?
123
+
124
+ "Full output id: #{artifact_id}. Use retrieve_tool_output to inspect it."
125
+ end
126
+ end
127
+ end
@@ -24,8 +24,8 @@ module Kward
24
24
  description: @description,
25
25
  parameters: {
26
26
  type: "object",
27
- properties: @properties,
28
- required: @required,
27
+ properties: sorted_properties,
28
+ required: @required.sort,
29
29
  additionalProperties: false
30
30
  }
31
31
  }
@@ -34,6 +34,12 @@ module Kward
34
34
 
35
35
  private
36
36
 
37
+ def sorted_properties
38
+ @properties.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
39
+ result[key] = @properties[key]
40
+ end
41
+ end
42
+
37
43
  # Reads a tool argument while accepting symbol or string keys from restored calls.
38
44
  def argument(args, key, default = nil)
39
45
  return args[key] if args.key?(key)
@@ -0,0 +1,54 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
6
+ module Tools
7
+ # Reports approximate context budget savings for the current process.
8
+ class ContextBudgetStats < Base
9
+ def initialize(context_budget_meter: nil)
10
+ @context_budget_meter = context_budget_meter
11
+ super(
12
+ "context_budget_stats",
13
+ "Return approximate per-session context budget savings from tool output compaction and deduplication.",
14
+ properties: {},
15
+ required: []
16
+ )
17
+ end
18
+
19
+ def call(_args, conversation, cancellation: nil)
20
+ cancellation&.raise_if_cancelled!
21
+ meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
22
+ return "Error: context budget stats are unavailable" unless meter
23
+
24
+ snapshot = meter.snapshot
25
+ lines = [
26
+ "# Context budget stats",
27
+ "- Calls: #{snapshot.calls}",
28
+ "- Original bytes: #{snapshot.original_bytes}",
29
+ "- Returned bytes: #{snapshot.returned_bytes}",
30
+ "- Saved bytes: #{snapshot.saved_bytes}",
31
+ "- Estimated tokens saved: #{estimated_tokens(snapshot.saved_bytes)}"
32
+ ]
33
+ lines.concat(tool_breakdown_lines(snapshot.tool_breakdown))
34
+ lines.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def tool_breakdown_lines(tool_breakdown)
40
+ return [] if tool_breakdown.empty?
41
+
42
+ lines = ["", "## By tool"]
43
+ tool_breakdown.sort_by { |tool, data| [-data[:savedBytes], tool] }.each do |tool, data|
44
+ lines << "- #{tool}: #{data[:calls]} call(s), #{data[:savedBytes]} bytes saved, #{data[:returnedBytes]}/#{data[:originalBytes]} bytes returned"
45
+ end
46
+ lines
47
+ end
48
+
49
+ def estimated_tokens(bytes)
50
+ (bytes.to_i / 4.0).ceil
51
+ end
52
+ end
53
+ end
54
+ end