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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +93 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +415 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +123 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +75 -5
- data/doc/session-management.md +35 -1
- data/doc/shell.md +332 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +79 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +268 -4
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +217 -9
- data/lib/kward/cli/slash_commands.rb +628 -2
- data/lib/kward/cli/tabs.rb +725 -0
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +150 -26
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +145 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +559 -0
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +84 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1271 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +288 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +451 -57
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +99 -56
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +19 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +366 -222
- data/lib/kward/prompts/commands.rb +9 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +169 -83
- data/lib/kward/rpc/session_manager.rb +45 -121
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +204 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +93 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +10 -0
- data/lib/kward/workspace.rb +125 -87
- data/templates/default/fulldoc/html/css/kward.css +140 -36
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +23 -34
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +67 -1
data/lib/kward/session_store.rb
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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|
|
|
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|
|
|
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
|
-
|
|
127
|
+
SessionTreeNodes.truncate_text(full_message_text(message))
|
|
237
128
|
end
|
|
238
129
|
|
|
239
130
|
def truncate_session_tree_text(text)
|
|
240
|
-
|
|
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
|