kward 0.71.0 → 0.73.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/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -178,6 +178,9 @@ module Kward
178
178
  # @return [String] workspace directory this store lists and creates sessions for
179
179
  attr_reader :cwd
180
180
 
181
+ # @return [String] configuration directory containing session and tab files
182
+ attr_reader :config_dir
183
+
181
184
  # Creates a new empty session file for the store's workspace directory.
182
185
  #
183
186
  # Parent fields record clone/fork ancestry; they do not imply live coupling
@@ -285,6 +288,7 @@ module Kward
285
288
  session_memories: memory_state["sessionMemories"],
286
289
  last_memory_retrieval: memory_state["lastRetrieval"]
287
290
  )
291
+ restore_tool_output_artifacts(records, conversation)
288
292
  conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
289
293
  session = Session.new(
290
294
  store: self,
@@ -561,6 +565,46 @@ module Kward
561
565
  records.reverse.find { |record| record["type"] == "memory_state" } || { "sessionMemories" => [], "lastRetrieval" => nil }
562
566
  end
563
567
 
568
+ def restore_tool_output_artifacts(records, conversation)
569
+ tool_names = tool_message_names_by_id(records)
570
+ records.each do |record|
571
+ next unless record["type"] == "tool_execution_end"
572
+
573
+ content = record.dig("result", "content")
574
+ next if content.nil?
575
+
576
+ tool_name = tool_names[record["toolCallId"].to_s] || raw_tool_name(record["toolName"])
577
+ next if tool_name.to_s.empty?
578
+
579
+ conversation.restore_tool_output_artifact(
580
+ tool_name: tool_name,
581
+ content: content,
582
+ created_at: parse_time(record["timestamp"])
583
+ )
584
+ end
585
+ end
586
+
587
+ def tool_message_names_by_id(records)
588
+ records.each_with_object({}) do |record, names|
589
+ next unless record["type"] == "message"
590
+
591
+ message = record["message"]
592
+ next unless message.is_a?(Hash) && message_role(message) == "tool"
593
+
594
+ tool_call_id = message_tool_call_id(message).to_s
595
+ names[tool_call_id] = message_name(message) unless tool_call_id.empty? || message_name(message).to_s.empty?
596
+ end
597
+ end
598
+
599
+ def raw_tool_name(name)
600
+ {
601
+ "bash" => "run_shell_command",
602
+ "edit" => "edit_file",
603
+ "read" => "read_file",
604
+ "write" => "write_file"
605
+ }.fetch(name.to_s, name.to_s)
606
+ end
607
+
564
608
  def session_runtime(records, header)
565
609
  result = {
566
610
  "provider" => header["provider"],
@@ -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
@@ -0,0 +1,84 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Terminal input key byte sequences grouped by semantic key.
4
+ module TerminalKeys
5
+ CTRL_SPACE = "\x00".freeze
6
+ CTRL_A = "\x01".freeze
7
+ CTRL_B = "\x02".freeze
8
+ CTRL_C = "\x03".freeze
9
+ CTRL_D = "\x04".freeze
10
+ CTRL_E = "\x05".freeze
11
+ CTRL_F = "\x06".freeze
12
+ CTRL_K = "\x0B".freeze
13
+ CTRL_L = "\x0C".freeze
14
+ CTRL_N = "\x0E".freeze
15
+ CTRL_P = "\x10".freeze
16
+ CTRL_Q = "\x11".freeze
17
+ CTRL_R = "\x12".freeze
18
+ CTRL_S = "\x13".freeze
19
+ CTRL_T = "\x14".freeze
20
+ CTRL_U = "\x15".freeze
21
+ CTRL_V = "\x16".freeze
22
+ CTRL_W = "\x17".freeze
23
+ CTRL_X = "\x18".freeze
24
+ CTRL_Y = "\x19".freeze
25
+ CTRL_Z = "\x1A".freeze
26
+
27
+ RETURN = ["\n", "\r"].freeze
28
+ BACKSPACE = ["\b", "\x7F"].freeze
29
+ TAB = ["\t", "\e[9u", "\e[9;1u", "\e[27;1;9~"].freeze
30
+
31
+ LEFT = ["\e[D", "\eOD"].freeze
32
+ RIGHT = ["\e[C", "\eOC"].freeze
33
+ UP = ["\e[A", "\eOA"].freeze
34
+ DOWN = ["\e[B", "\eOB"].freeze
35
+ HOME = ["\e[H", "\eOH", "\e[1~", "\e[7~"].freeze
36
+ END_KEY = ["\e[F", "\eOF", "\e[4~", "\e[8~"].freeze
37
+ DELETE = ["\e[3~"].freeze
38
+ PAGE_UP = ["\e[5~"].freeze
39
+ PAGE_DOWN = ["\e[6~"].freeze
40
+
41
+ SHIFT_TAB = ["\e[Z", "\e[1;2Z", "\e[9;2u", "\e[27;2;9~", "\e[1;2I"].freeze
42
+ CTRL_TAB = ["\e[9;5u", "\e[27;5;9~", "\e[1;5I"].freeze
43
+ CTRL_SHIFT_TAB = ["\e[9;6u", "\e[27;6;9~", "\e[1;6I", "\e[1;6Z"].freeze
44
+ SHIFT_ENTER = ["\e[13;2u", "\e[13;2~", "\e[27;2;13~", "\e\r", "\e\n"].freeze
45
+
46
+ ALT_LEFT = ["\e[1;3D", "\e[3D"].freeze
47
+ ALT_RIGHT = ["\e[1;3C", "\e[3C"].freeze
48
+ ALT_UP = ["\e[1;3A", "\e[3A"].freeze
49
+ ALT_DOWN = ["\e[1;3B", "\e[3B"].freeze
50
+
51
+ SHIFT_LEFT = ["\e[1;2D", "\e[2D"].freeze
52
+ SHIFT_RIGHT = ["\e[1;2C", "\e[2C"].freeze
53
+ SHIFT_UP = ["\e[1;2A", "\e[2A"].freeze
54
+ SHIFT_DOWN = ["\e[1;2B", "\e[2B"].freeze
55
+
56
+ CTRL_LEFT = ["\e[1;5D", "\e[5D"].freeze
57
+ CTRL_RIGHT = ["\e[1;5C", "\e[5C"].freeze
58
+ CTRL_UP = ["\e[1;5A", "\e[5A"].freeze
59
+ CTRL_DOWN = ["\e[1;5B", "\e[5B"].freeze
60
+
61
+ ALT_SHIFT_LEFT = ["\e[1;4D", "\e[4D"].freeze
62
+ ALT_SHIFT_RIGHT = ["\e[1;4C", "\e[4C"].freeze
63
+ ALT_SHIFT_UP = ["\e[1;4A", "\e[4A"].freeze
64
+ ALT_SHIFT_DOWN = ["\e[1;4B", "\e[4B"].freeze
65
+
66
+ CTRL_SHIFT_RIGHT = ["\e[1;6C", "\e[6C"].freeze
67
+ CTRL_SHIFT_UP = ["\e[1;6A", "\e[6A"].freeze
68
+ CTRL_SHIFT_DOWN = ["\e[1;6B", "\e[6B"].freeze
69
+
70
+ CTRL_T_CSI_U = "\e[116;5u".freeze
71
+ CTRL_W_CSI_U = "\e[119;5u".freeze
72
+ CTRL_NUMBER_TAB_PATTERN = /\A\e\[((?:49)|(?:5[0-7]));5u\z/.freeze
73
+
74
+ CSI_U_PATTERN = /\A\e\[(\d+)((?:;[\d:]*)*)u/.freeze
75
+ MODIFIED_CURSOR_PATTERN = /\A\e\[(\d+);(\d+)([CDFH])\z/.freeze
76
+ MODIFIED_DELETE_PATTERN = /\A\e\[3;(\d+)~\z/.freeze
77
+ UP_PATTERN = /\A\e\[[0-9;:]*A\z/.freeze
78
+ DOWN_PATTERN = /\A\e\[[0-9;:]*B\z/.freeze
79
+ RIGHT_PATTERN = /\A\e\[[0-9;:]*C\z/.freeze
80
+ LEFT_PATTERN = /\A\e\[[0-9;:]*D\z/.freeze
81
+ CSI_KEY_PATTERN = /\A\e\[[0-9;:]*[A-Za-z~]/.freeze
82
+ SS3_KEY_PATTERN = /\A\eO[A-Za-z]/.freeze
83
+ end
84
+ end
@@ -0,0 +1,42 @@
1
+ require "base64"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Terminal control escape sequence builders.
6
+ module TerminalSequences
7
+ KEYBOARD_PROTOCOL_ENABLE = "\e[>25u".freeze
8
+ KEYBOARD_PROTOCOL_RESTORE = "\e[<u".freeze
9
+ BRACKETED_PASTE_ENABLE = "\e[?2004h".freeze
10
+ BRACKETED_PASTE_RESTORE = "\e[?2004l".freeze
11
+ BRACKETED_PASTE_START = "\e[200~".freeze
12
+ BRACKETED_PASTE_END = "\e[201~".freeze
13
+ SYNCHRONIZED_OUTPUT_ENABLE = "\e[?2026h".freeze
14
+ SYNCHRONIZED_OUTPUT_DISABLE = "\e[?2026l".freeze
15
+ CURSOR_SHOW = "\e[?25h".freeze
16
+ CURSOR_HIDE = "\e[?25l".freeze
17
+ CURSOR_SHAPE_DEFAULT = "\e[0 q".freeze
18
+ CURSOR_SHAPE_BAR = "\e[6 q".freeze
19
+ MOUSE_REPORTING_ENABLE = "\e[?1003h\e[?1006h".freeze
20
+ MOUSE_REPORTING_DISABLE = "\e[?1006l\e[?1003l".freeze
21
+ SGR_INVERSE = "\e[7m".freeze
22
+ SGR_INVERSE_OFF = "\e[27m".freeze
23
+
24
+ module_function
25
+
26
+ def scroll_region(top, bottom)
27
+ "\e[#{top};#{bottom}r"
28
+ end
29
+
30
+ def restore_scroll_region
31
+ "\e[r"
32
+ end
33
+
34
+ def move_to(row, col)
35
+ "\e[#{row};#{col}H"
36
+ end
37
+
38
+ def osc52(text)
39
+ "\e]52;c;#{Base64.strict_encode64(text.to_s)}\a"
40
+ end
41
+ end
42
+ end
@@ -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,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