kward 0.68.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 +4 -4
- data/.github/workflows/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +40 -0
- data/Gemfile.lock +8 -2
- data/README.md +32 -25
- data/Rakefile +14 -1
- data/doc/authentication.md +74 -56
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +18 -0
- data/doc/extensibility.md +89 -128
- data/doc/getting-started.md +52 -54
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -97
- data/doc/releasing.md +3 -1
- data/doc/rpc.md +1 -1
- data/doc/usage.md +125 -144
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +10 -3
- data/lib/kward/cli/compaction.rb +3 -3
- data/lib/kward/cli/interactive_turn.rb +3 -1
- data/lib/kward/cli/memory_commands.rb +16 -16
- data/lib/kward/cli/plugins.rb +3 -3
- data/lib/kward/cli/prompt_interface.rb +15 -13
- data/lib/kward/cli/rendering.rb +35 -46
- data/lib/kward/cli/runtime_helpers.rb +13 -2
- data/lib/kward/cli/sessions.rb +21 -21
- data/lib/kward/cli/settings.rb +49 -43
- data/lib/kward/cli/slash_commands.rb +6 -4
- data/lib/kward/cli/stats.rb +2 -2
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +5 -1
- data/lib/kward/cli.rb +14 -2
- data/lib/kward/cli_transcript_formatter.rb +36 -5
- data/lib/kward/compactor.rb +2 -2
- data/lib/kward/config_files.rb +45 -10
- data/lib/kward/conversation.rb +41 -9
- data/lib/kward/memory/manager.rb +131 -14
- data/lib/kward/message_access.rb +6 -0
- data/lib/kward/model/context_usage.rb +11 -10
- data/lib/kward/model/model_info.rb +18 -1
- data/lib/kward/model/payloads.rb +89 -10
- data/lib/kward/model/stream_parser.rb +258 -25
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +20 -11
- data/lib/kward/prompts.rb +61 -7
- data/lib/kward/rpc/server.rb +7 -2
- data/lib/kward/rpc/session_manager.rb +18 -2
- data/lib/kward/rpc/session_metrics.rb +2 -2
- data/lib/kward/rpc/session_tree_rows.rb +54 -13
- data/lib/kward/rpc/transcript_normalizer.rb +47 -0
- data/lib/kward/session_store.rb +45 -2
- data/lib/kward/session_tree_renderer.rb +54 -13
- data/lib/kward/starter_pack_installer.rb +2 -2
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/registry.rb +9 -2
- data/lib/kward/tools/search/web.rb +3 -3
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +14 -1
|
@@ -34,7 +34,12 @@ module Kward
|
|
|
34
34
|
multiple_roots = visible_roots.length > 1
|
|
35
35
|
result = []
|
|
36
36
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
130
|
-
|
|
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"
|
|
@@ -61,6 +61,7 @@ module Kward
|
|
|
61
61
|
|
|
62
62
|
def normalize_assistant_message(message)
|
|
63
63
|
content = reasoning_first_content(normalize_content(ToolCall.value(message, :content), preserve_thinking: true))
|
|
64
|
+
content = response_item_content(message) if text_content_empty?(content)
|
|
64
65
|
reasoning = normalize_reasoning_summary(message)
|
|
65
66
|
content.unshift(reasoning) if reasoning && !thinking_content?(content)
|
|
66
67
|
tool_calls(message).each do |tool_call|
|
|
@@ -154,6 +155,52 @@ module Kward
|
|
|
154
155
|
summary.to_s.empty? ? nil : { type: "thinking", thinking: summary.to_s }
|
|
155
156
|
end
|
|
156
157
|
|
|
158
|
+
def response_item_content(message)
|
|
159
|
+
response_items(message).filter_map do |item|
|
|
160
|
+
next unless item.is_a?(Hash)
|
|
161
|
+
|
|
162
|
+
case ToolCall.value(item, :type).to_s
|
|
163
|
+
when "reasoning"
|
|
164
|
+
thinking = reasoning_item_text(item)
|
|
165
|
+
{ type: "thinking", thinking: thinking } unless thinking.empty?
|
|
166
|
+
when "message"
|
|
167
|
+
next if ToolCall.value(item, :phase).to_s == "commentary"
|
|
168
|
+
|
|
169
|
+
text = response_message_item_text(item)
|
|
170
|
+
{ type: "text", text: text } unless text.empty?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def response_items(message)
|
|
176
|
+
items = ToolCall.value(message, :response_items) || ToolCall.value(message, :responseItems)
|
|
177
|
+
items.is_a?(Array) ? items : []
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def reasoning_item_text(item)
|
|
181
|
+
summary = ToolCall.value(item, :summary)
|
|
182
|
+
content = ToolCall.value(item, :content)
|
|
183
|
+
response_text_parts(summary).empty? ? response_text_parts(content).join("\n\n") : response_text_parts(summary).join("\n\n")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def response_message_item_text(item)
|
|
187
|
+
response_text_parts(ToolCall.value(item, :content)).join
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def response_text_parts(parts)
|
|
191
|
+
Array(parts).filter_map do |part|
|
|
192
|
+
next unless part.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
ToolCall.value(part, :text) || ToolCall.value(part, :refusal)
|
|
195
|
+
end.map(&:to_s)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def text_content_empty?(content)
|
|
199
|
+
Array(content).all? do |part|
|
|
200
|
+
!part.is_a?(Hash) || !["text", "thinking"].include?(ToolCall.value(part, :type).to_s) || ToolCall.value(part, :text).to_s.empty? && ToolCall.value(part, :thinking).to_s.empty?
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
157
204
|
def thinking_content?(content)
|
|
158
205
|
content.any? { |part| thinking_content_part?(part) }
|
|
159
206
|
end
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
|
+
require "digest"
|
|
2
3
|
require "json"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
require "time"
|
|
@@ -28,7 +29,7 @@ module Kward
|
|
|
28
29
|
VERSION = 2
|
|
29
30
|
LAST_SESSION_FILENAME = "last_session.json"
|
|
30
31
|
|
|
31
|
-
SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
|
|
32
|
+
SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :provider, :model, :reasoning_effort, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
|
|
32
33
|
|
|
33
34
|
# Live handle that attaches persistence callbacks to a conversation.
|
|
34
35
|
#
|
|
@@ -77,6 +78,8 @@ module Kward
|
|
|
77
78
|
conversation.on_compact = lambda { |message| compact(message) }
|
|
78
79
|
conversation.on_tool_execution = lambda { |tool_call, content| append_tool_execution(tool_call, content) }
|
|
79
80
|
conversation.on_runtime_update = lambda { |provider:, model:, reasoning_effort:| update_runtime(provider: provider, model: model, reasoning_effort: reasoning_effort) }
|
|
81
|
+
conversation.on_system_message_change = lambda { |system_message| append_system_prompt_snapshot(system_message, reason: "changed") }
|
|
82
|
+
append_system_prompt_snapshot(conversation.system_message, reason: "attach")
|
|
80
83
|
self
|
|
81
84
|
end
|
|
82
85
|
|
|
@@ -99,6 +102,11 @@ module Kward
|
|
|
99
102
|
@store.append_record(@path, RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record)
|
|
100
103
|
end
|
|
101
104
|
|
|
105
|
+
# Persists the current system prompt as audit metadata when it changes.
|
|
106
|
+
def append_system_prompt_snapshot(system_message, reason: "changed")
|
|
107
|
+
@store.append_system_prompt_snapshot(@path, system_message, reason: reason)
|
|
108
|
+
end
|
|
109
|
+
|
|
102
110
|
# Persists the session memory snapshot used when the session is restored.
|
|
103
111
|
def update_memory_state(session_memories:, last_retrieval: nil)
|
|
104
112
|
@store.append_record(@path, {
|
|
@@ -427,12 +435,39 @@ module Kward
|
|
|
427
435
|
end
|
|
428
436
|
end
|
|
429
437
|
|
|
438
|
+
def append_system_prompt_snapshot(path, system_message, reason: "changed")
|
|
439
|
+
content = MessageAccess.content(system_message).to_s
|
|
440
|
+
return if content.empty?
|
|
441
|
+
return if latest_system_prompt_hash(records_from_file(path)) == system_prompt_hash(content)
|
|
442
|
+
|
|
443
|
+
append_record(path, {
|
|
444
|
+
type: "system_prompt",
|
|
445
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
446
|
+
reason: reason.to_s,
|
|
447
|
+
hash: system_prompt_hash(content),
|
|
448
|
+
content: content
|
|
449
|
+
})
|
|
450
|
+
end
|
|
451
|
+
|
|
430
452
|
def self.safe_cwd(cwd)
|
|
431
453
|
"--#{File.expand_path(cwd).sub(%r{\A[/\\]}, "").gsub(%r{[/\\:]}, "-")}--"
|
|
432
454
|
end
|
|
433
455
|
|
|
434
456
|
private
|
|
435
457
|
|
|
458
|
+
def latest_system_prompt_hash(records)
|
|
459
|
+
records.reverse_each do |record|
|
|
460
|
+
next unless record["type"] == "system_prompt"
|
|
461
|
+
|
|
462
|
+
return record["hash"].to_s unless record["hash"].to_s.empty?
|
|
463
|
+
end
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def system_prompt_hash(content)
|
|
468
|
+
"sha256:#{Digest::SHA256.hexdigest(content.to_s)}"
|
|
469
|
+
end
|
|
470
|
+
|
|
436
471
|
def resolve_session_path(path)
|
|
437
472
|
expanded = path.to_s.start_with?("~/") ? File.join(Dir.home, path.to_s[2..]) : path.to_s
|
|
438
473
|
resolved = File.expand_path(expanded, @cwd)
|
|
@@ -557,7 +592,11 @@ module Kward
|
|
|
557
592
|
next unless node
|
|
558
593
|
|
|
559
594
|
parent = nodes[entry["parentId"].to_s]
|
|
560
|
-
parent
|
|
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
|
|
561
600
|
end
|
|
562
601
|
roots
|
|
563
602
|
end
|
|
@@ -722,6 +761,7 @@ module Kward
|
|
|
722
761
|
|
|
723
762
|
messages = restored_messages(records)
|
|
724
763
|
name = session_name(records)
|
|
764
|
+
runtime = session_runtime(records, header)
|
|
725
765
|
first_message = messages.find { |message| ["user", "compactionSummary"].include?(message_role(message)) }
|
|
726
766
|
stats = File.stat(path)
|
|
727
767
|
|
|
@@ -734,6 +774,9 @@ module Kward
|
|
|
734
774
|
name: name,
|
|
735
775
|
first_message: first_message ? message_text(first_message) : "",
|
|
736
776
|
message_count: messages.count { |message| ["user", "assistant", "tool", "toolResult", "compactionSummary"].include?(message_role(message)) },
|
|
777
|
+
provider: runtime["provider"],
|
|
778
|
+
model: runtime["model"],
|
|
779
|
+
reasoning_effort: runtime["reasoningEffort"],
|
|
737
780
|
parent_id: header["parentId"],
|
|
738
781
|
parent_path: header["parentPath"],
|
|
739
782
|
depth: 0,
|
|
@@ -19,7 +19,12 @@ module Kward
|
|
|
19
19
|
multiple_roots = visible_roots.length > 1
|
|
20
20
|
result = []
|
|
21
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
|
+
[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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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"
|
|
@@ -11,9 +11,9 @@ require_relative "config_files"
|
|
|
11
11
|
module Kward
|
|
12
12
|
# Installs Kward's starter prompt/instruction files into the user config dir.
|
|
13
13
|
class StarterPackInstaller
|
|
14
|
-
VERSION = "v1.0.
|
|
14
|
+
VERSION = "v1.0.1"
|
|
15
15
|
ARCHIVE_URL = "https://codeload.github.com/kaiwood/kward-starter-pack/tar.gz/refs/tags/#{VERSION}".freeze
|
|
16
|
-
ALLOWED_FILES = ["
|
|
16
|
+
ALLOWED_FILES = ["PRINCIPLES.md"].freeze
|
|
17
17
|
ALLOWED_PREFIXES = ["prompts/", "skills/"].freeze
|
|
18
18
|
Result = Struct.new(:installed, :skipped, keyword_init: true)
|
|
19
19
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require_relative "search/web_fetch"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
7
|
+
module Tools
|
|
8
|
+
# Fetches a specific URL and extracts readable page content.
|
|
9
|
+
class FetchContent < Base
|
|
10
|
+
# Builds the tool schema and stores the execution dependency.
|
|
11
|
+
def initialize(web_fetch:)
|
|
12
|
+
@web_fetch = web_fetch
|
|
13
|
+
super(
|
|
14
|
+
"fetch_content",
|
|
15
|
+
"Fetch a specific URL and extract readable bounded content.",
|
|
16
|
+
properties: {
|
|
17
|
+
url: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "HTTP or HTTPS URL to fetch."
|
|
20
|
+
},
|
|
21
|
+
max_bytes: {
|
|
22
|
+
type: "integer",
|
|
23
|
+
description: "Maximum returned content bytes; default 16384, max 131072."
|
|
24
|
+
},
|
|
25
|
+
extract: {
|
|
26
|
+
type: "string",
|
|
27
|
+
enum: %w[auto text markdown],
|
|
28
|
+
description: "Extraction mode; default auto."
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
required: ["url"]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Executes the tool and returns model-facing output text.
|
|
36
|
+
def call(args, _conversation, cancellation: nil)
|
|
37
|
+
@web_fetch.fetch_content(args)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require_relative "search/web_fetch"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
7
|
+
module Tools
|
|
8
|
+
# Fetches bounded raw content from a specific URL.
|
|
9
|
+
class FetchRaw < Base
|
|
10
|
+
# Builds the tool schema and stores the execution dependency.
|
|
11
|
+
def initialize(web_fetch:)
|
|
12
|
+
@web_fetch = web_fetch
|
|
13
|
+
super(
|
|
14
|
+
"fetch_raw",
|
|
15
|
+
"Fetch bounded raw content from a specific URL.",
|
|
16
|
+
properties: {
|
|
17
|
+
url: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "HTTP or HTTPS URL to fetch."
|
|
20
|
+
},
|
|
21
|
+
max_bytes: {
|
|
22
|
+
type: "integer",
|
|
23
|
+
description: "Maximum returned content bytes; default 16384, max 131072."
|
|
24
|
+
},
|
|
25
|
+
accept: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Optional HTTP Accept header."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
required: ["url"]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Executes the tool and returns model-facing output text.
|
|
35
|
+
def call(args, _conversation, cancellation: nil)
|
|
36
|
+
@web_fetch.fetch_raw(args)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -2,6 +2,8 @@ require_relative "../config_files"
|
|
|
2
2
|
require_relative "ask_user_question"
|
|
3
3
|
require_relative "code_search"
|
|
4
4
|
require_relative "edit_file"
|
|
5
|
+
require_relative "fetch_content"
|
|
6
|
+
require_relative "fetch_raw"
|
|
5
7
|
require_relative "list_directory"
|
|
6
8
|
require_relative "read_file"
|
|
7
9
|
require_relative "read_skill"
|
|
@@ -10,6 +12,7 @@ require_relative "web_search"
|
|
|
10
12
|
require_relative "write_file"
|
|
11
13
|
require_relative "search/code"
|
|
12
14
|
require_relative "search/web"
|
|
15
|
+
require_relative "search/web_fetch"
|
|
13
16
|
require_relative "tool_call"
|
|
14
17
|
require_relative "../workspace"
|
|
15
18
|
|
|
@@ -45,14 +48,16 @@ module Kward
|
|
|
45
48
|
# @param prompt [Object, nil] interactive prompt bridge; must implement
|
|
46
49
|
# `ask_user_question` before that tool is advertised
|
|
47
50
|
# @param web_search [WebSearch] live web search implementation
|
|
51
|
+
# @param web_fetch [WebFetch] specific URL fetch implementation
|
|
48
52
|
# @param code_search [CodeSearch] public source/package search implementation
|
|
49
53
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
50
54
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
51
55
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
52
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
|
|
56
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
|
|
53
57
|
@workspace = workspace
|
|
54
58
|
@prompt = prompt
|
|
55
59
|
@web_search = web_search
|
|
60
|
+
@web_fetch = web_fetch
|
|
56
61
|
@code_search = code_search
|
|
57
62
|
@skills = skills
|
|
58
63
|
@web_search_enabled = web_search_enabled
|
|
@@ -103,7 +108,7 @@ module Kward
|
|
|
103
108
|
tools = @tools.values_at(
|
|
104
109
|
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
|
|
105
110
|
)
|
|
106
|
-
tools
|
|
111
|
+
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
107
112
|
tools << @tools["read_skill"] if skills_available?
|
|
108
113
|
tools << @tools["ask_user_question"] if ask_user_question_available?
|
|
109
114
|
tools
|
|
@@ -112,6 +117,8 @@ module Kward
|
|
|
112
117
|
def all_tools
|
|
113
118
|
core_tools + [
|
|
114
119
|
Tools::WebSearch.new(web_search: @web_search),
|
|
120
|
+
Tools::FetchContent.new(web_fetch: @web_fetch),
|
|
121
|
+
Tools::FetchRaw.new(web_fetch: @web_fetch),
|
|
115
122
|
Tools::ReadSkill.new,
|
|
116
123
|
Tools::AskUserQuestion.new(prompt: @prompt)
|
|
117
124
|
]
|
|
@@ -64,7 +64,7 @@ module Kward
|
|
|
64
64
|
provider: provider
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
sections = ["# Web search"]
|
|
67
|
+
sections = ["# Web search", "Use fetch_content with a result URL to verify human-readable pages, or fetch_raw for specs, JSON, YAML, XML, and other machine-readable resources."]
|
|
68
68
|
failures = []
|
|
69
69
|
any_results = false
|
|
70
70
|
|
|
@@ -715,7 +715,7 @@ module Kward
|
|
|
715
715
|
|
|
716
716
|
# HTTP adapter used by web-search providers and fallbacks.
|
|
717
717
|
class NetHttpClient
|
|
718
|
-
Response = Struct.new(:code, :body, keyword_init: true)
|
|
718
|
+
Response = Struct.new(:code, :body, :headers, keyword_init: true)
|
|
719
719
|
|
|
720
720
|
def get(url, headers: {})
|
|
721
721
|
request(url, Net::HTTP::Get, headers: headers)
|
|
@@ -742,7 +742,7 @@ module Kward
|
|
|
742
742
|
headers.each { |key, value| http_request[key] = value }
|
|
743
743
|
yield http_request if block_given?
|
|
744
744
|
response = http.request(http_request)
|
|
745
|
-
Response.new(code: response.code, body: response.body)
|
|
745
|
+
Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
|
|
746
746
|
end
|
|
747
747
|
end
|
|
748
748
|
end
|