kward 0.66.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
data/lib/kward/events.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Kward
|
|
2
|
+
module Events
|
|
3
|
+
ReasoningDelta = Struct.new(:delta, keyword_init: true)
|
|
4
|
+
AssistantDelta = Struct.new(:delta, keyword_init: true)
|
|
5
|
+
AssistantMessage = Struct.new(:message, keyword_init: true)
|
|
6
|
+
Retry = Struct.new(:provider, :model, :attempt, :max_attempts, :delay_seconds, :error, :request_bytes, keyword_init: true)
|
|
7
|
+
Steering = Struct.new(:input, :created_at, keyword_init: true)
|
|
8
|
+
SteeringApplied = Struct.new(:count, keyword_init: true)
|
|
9
|
+
ToolCall = Struct.new(:tool_call, keyword_init: true)
|
|
10
|
+
ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
|
|
11
|
+
Answer = Struct.new(:content, keyword_init: true)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
class ExportPath
|
|
5
|
+
def self.resolve(path, workspace_root:, default_path:, session_dir: nil)
|
|
6
|
+
explicit = path.to_s.strip
|
|
7
|
+
return File.expand_path(default_path) if explicit.empty?
|
|
8
|
+
|
|
9
|
+
resolved = File.expand_path(explicit, workspace_root)
|
|
10
|
+
allowed_roots = [workspace_root, session_dir].compact.map { |root| Pathname.new(root).expand_path }
|
|
11
|
+
expanded = Pathname.new(resolved).expand_path
|
|
12
|
+
unless allowed_roots.any? { |root| inside?(expanded, root) }
|
|
13
|
+
raise ArgumentError, "export path outside workspace or session directory: #{path}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
parent = expanded.dirname
|
|
17
|
+
raise Errno::ENOENT, parent.to_s unless parent.directory?
|
|
18
|
+
raise ArgumentError, "export path outside workspace or session directory: #{path}" if parent.symlink?
|
|
19
|
+
|
|
20
|
+
resolved
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.inside?(path, root)
|
|
24
|
+
path.to_s == root.to_s || path.to_s.start_with?("#{root}/")
|
|
25
|
+
end
|
|
26
|
+
private_class_method :inside?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "cgi"
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Kward
|
|
8
|
+
module ImageAttachments
|
|
9
|
+
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
10
|
+
MIME_TYPES = {
|
|
11
|
+
".gif" => "image/gif",
|
|
12
|
+
".jpg" => "image/jpeg",
|
|
13
|
+
".jpeg" => "image/jpeg",
|
|
14
|
+
".png" => "image/png",
|
|
15
|
+
".webp" => "image/webp"
|
|
16
|
+
}.freeze
|
|
17
|
+
DATA_URI_PATTERN = %r{data:(image/(?:gif|jpe?g|png|webp));base64,([A-Za-z0-9+/=\r\n]+)}i.freeze
|
|
18
|
+
MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\(([^)]+)\)/.freeze
|
|
19
|
+
EMBEDDED_IMAGE_EXTENSION_PATTERN = /\.(?:gif|jpe?g|png|webp)\b/i.freeze
|
|
20
|
+
DEFAULT_TERMINAL_IMAGE_WIDTH = "40".freeze
|
|
21
|
+
SCREENSHOT_SEARCH_DIRS = ["Desktop", "Downloads", "Pictures"].freeze
|
|
22
|
+
PASTED_IMAGE_BASENAME_PATTERN = /\A(?:screenshot|screen shot|pasted[-_]image)/i.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def content_from_text(text)
|
|
27
|
+
text = text.to_s
|
|
28
|
+
images = image_parts_from_text(text)
|
|
29
|
+
return text if images.empty?
|
|
30
|
+
|
|
31
|
+
[{ type: "text", text: text }] + images
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def image_parts_from_text(text)
|
|
35
|
+
seen = {}
|
|
36
|
+
data_uri_parts(text, seen) + path_parts(text, seen)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_references_from_text(text)
|
|
40
|
+
text = text.to_s
|
|
41
|
+
references = references_from_text(text).select { |reference| reference[:status] == :attached }
|
|
42
|
+
{ text: display_text_without_references(text, references), attachments: references }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def display_text_without_references(text, references)
|
|
46
|
+
references.reduce(text.to_s.dup) do |result, reference|
|
|
47
|
+
source = reference[:source_text].to_s
|
|
48
|
+
source.empty? ? result : result.sub(source, "")
|
|
49
|
+
end.gsub(/[ \t]{2,}/, " ").gsub(/[ \t]+\n/, "\n").strip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def references_from_text(text)
|
|
53
|
+
seen = {}
|
|
54
|
+
refs = data_uri_references(text.to_s, seen)
|
|
55
|
+
image_paths_from_text(text.to_s).each do |path|
|
|
56
|
+
key = "path:#{path}"
|
|
57
|
+
next if seen[key]
|
|
58
|
+
|
|
59
|
+
expanded_path = resolve_image_path(path)
|
|
60
|
+
if expanded_path && File.file?(expanded_path)
|
|
61
|
+
next if seen[expanded_path]
|
|
62
|
+
next if File.size(expanded_path) > MAX_IMAGE_BYTES
|
|
63
|
+
next unless mime_type(expanded_path)
|
|
64
|
+
|
|
65
|
+
seen[key] = true
|
|
66
|
+
seen[expanded_path] = true
|
|
67
|
+
refs << image_reference(path, expanded_path)
|
|
68
|
+
elsif image_reference_candidate?(path)
|
|
69
|
+
seen[key] = true
|
|
70
|
+
refs << missing_image_reference(path)
|
|
71
|
+
end
|
|
72
|
+
rescue SystemCallError
|
|
73
|
+
seen[key] = true
|
|
74
|
+
refs << missing_image_reference(path)
|
|
75
|
+
end
|
|
76
|
+
refs
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def data_uri_references(text, seen)
|
|
80
|
+
text.scan(DATA_URI_PATTERN).filter_map do |media_type, data|
|
|
81
|
+
source_text = Regexp.last_match[0]
|
|
82
|
+
normalized_data = data.gsub(/\s+/, "")
|
|
83
|
+
key = "data:#{media_type.downcase};#{normalized_data}"
|
|
84
|
+
next if seen[key]
|
|
85
|
+
|
|
86
|
+
decoded_bytes = Base64.decode64(normalized_data).bytesize
|
|
87
|
+
next if decoded_bytes > MAX_IMAGE_BYTES
|
|
88
|
+
|
|
89
|
+
seen[key] = true
|
|
90
|
+
{
|
|
91
|
+
status: :attached,
|
|
92
|
+
type: "image",
|
|
93
|
+
label: "pasted image",
|
|
94
|
+
media_type: media_type.downcase.sub("image/jpg", "image/jpeg"),
|
|
95
|
+
size_bytes: decoded_bytes,
|
|
96
|
+
source_text: source_text
|
|
97
|
+
}
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def image_reference(original_path, expanded_path)
|
|
104
|
+
{
|
|
105
|
+
status: :attached,
|
|
106
|
+
type: "image",
|
|
107
|
+
label: File.basename(expanded_path),
|
|
108
|
+
media_type: mime_type(expanded_path),
|
|
109
|
+
size_bytes: File.size(expanded_path),
|
|
110
|
+
path: expanded_path,
|
|
111
|
+
original_path: original_path,
|
|
112
|
+
source_text: original_path
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def missing_image_reference(path)
|
|
117
|
+
{
|
|
118
|
+
status: :missing,
|
|
119
|
+
type: "image",
|
|
120
|
+
label: File.basename(clean_markdown_path(path)),
|
|
121
|
+
original_path: path,
|
|
122
|
+
source_text: path
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def image_reference_candidate?(path)
|
|
127
|
+
path = clean_markdown_path(path)
|
|
128
|
+
return false unless image_extension?(path) || path.start_with?("file://")
|
|
129
|
+
return true if path.start_with?("file://", "/", "~/", "./", "../")
|
|
130
|
+
|
|
131
|
+
File.basename(path) == path && pasted_image_basename?(path)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def data_uri_parts(text, seen)
|
|
135
|
+
text.scan(DATA_URI_PATTERN).filter_map do |media_type, data|
|
|
136
|
+
normalized_data = data.gsub(/\s+/, "")
|
|
137
|
+
key = "data:#{media_type.downcase};#{normalized_data}"
|
|
138
|
+
next if seen[key]
|
|
139
|
+
|
|
140
|
+
decoded_bytes = Base64.decode64(normalized_data).bytesize
|
|
141
|
+
next if decoded_bytes > MAX_IMAGE_BYTES
|
|
142
|
+
|
|
143
|
+
seen[key] = true
|
|
144
|
+
{ type: "image", media_type: media_type.downcase.sub("image/jpg", "image/jpeg"), data: normalized_data }
|
|
145
|
+
rescue ArgumentError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def path_parts(text, seen)
|
|
151
|
+
image_paths_from_text(text).filter_map do |path|
|
|
152
|
+
expanded_path = resolve_image_path(path)
|
|
153
|
+
next unless expanded_path
|
|
154
|
+
next if seen[expanded_path]
|
|
155
|
+
next unless File.file?(expanded_path)
|
|
156
|
+
next if File.size(expanded_path) > MAX_IMAGE_BYTES
|
|
157
|
+
|
|
158
|
+
media_type = mime_type(expanded_path)
|
|
159
|
+
next unless media_type
|
|
160
|
+
|
|
161
|
+
seen[expanded_path] = true
|
|
162
|
+
{
|
|
163
|
+
type: "image",
|
|
164
|
+
media_type: media_type,
|
|
165
|
+
data: Base64.strict_encode64(File.binread(expanded_path)),
|
|
166
|
+
path: expanded_path
|
|
167
|
+
}
|
|
168
|
+
rescue SystemCallError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def image_paths_from_text(text)
|
|
174
|
+
paths = []
|
|
175
|
+
text.scan(MARKDOWN_IMAGE_PATTERN) do |match|
|
|
176
|
+
paths << clean_markdown_path(match.first)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
text.each_line do |line|
|
|
180
|
+
candidate = path_candidate_from_line(line)
|
|
181
|
+
paths << candidate if candidate
|
|
182
|
+
paths.concat(path_tokens_from_line(line))
|
|
183
|
+
paths.concat(embedded_image_candidates_from_line(line))
|
|
184
|
+
end
|
|
185
|
+
paths.compact.uniq
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def embedded_image_candidates_from_line(line)
|
|
189
|
+
text = line.to_s
|
|
190
|
+
candidates = []
|
|
191
|
+
text.scan(EMBEDDED_IMAGE_EXTENSION_PATTERN) do
|
|
192
|
+
end_index = Regexp.last_match.end(0)
|
|
193
|
+
embedded_path_start_indexes(text, end_index).each do |start_index|
|
|
194
|
+
candidate = clean_markdown_path(text[start_index...end_index])
|
|
195
|
+
next unless embedded_image_candidate?(candidate)
|
|
196
|
+
|
|
197
|
+
candidates << candidate
|
|
198
|
+
break
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
candidates
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def embedded_image_candidate?(path)
|
|
205
|
+
image_reference_candidate?(path)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def embedded_path_start_indexes(text, end_index)
|
|
209
|
+
starts = [0]
|
|
210
|
+
text[0...end_index].scan(/\s+/) { starts << Regexp.last_match.end(0) }
|
|
211
|
+
starts.uniq
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def path_candidate_from_line(line)
|
|
215
|
+
stripped = line.strip
|
|
216
|
+
return nil if stripped.empty?
|
|
217
|
+
return stripped if stripped.start_with?("file://")
|
|
218
|
+
|
|
219
|
+
shell_words = Shellwords.split(stripped)
|
|
220
|
+
return shell_words.first if shell_words.length == 1
|
|
221
|
+
|
|
222
|
+
stripped
|
|
223
|
+
rescue ArgumentError
|
|
224
|
+
stripped
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def path_tokens_from_line(line)
|
|
228
|
+
Shellwords.split(line).select { |word| image_extension?(word) || word.start_with?("file://") }
|
|
229
|
+
rescue ArgumentError
|
|
230
|
+
line.scan(/\S+/).select { |word| image_extension?(word) || word.start_with?("file://") }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def clean_markdown_path(path)
|
|
234
|
+
path.to_s.strip.sub(/\A["']/, "").sub(/["']\z/, "")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def resolve_image_path(path)
|
|
238
|
+
expanded_path = expand_image_path(path)
|
|
239
|
+
return expanded_path if expanded_path && File.file?(expanded_path)
|
|
240
|
+
|
|
241
|
+
expand_image_basename(path)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def expand_image_path(path)
|
|
245
|
+
path = clean_markdown_path(path)
|
|
246
|
+
path = file_uri_path(path) if path.start_with?("file://")
|
|
247
|
+
return nil unless image_extension?(path)
|
|
248
|
+
|
|
249
|
+
File.expand_path(path)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def expand_image_basename(path)
|
|
253
|
+
path = clean_markdown_path(path)
|
|
254
|
+
return nil unless image_extension?(path)
|
|
255
|
+
return nil unless File.basename(path) == path
|
|
256
|
+
|
|
257
|
+
current_candidate = File.join(Dir.pwd, path)
|
|
258
|
+
return File.expand_path(current_candidate) if File.file?(current_candidate)
|
|
259
|
+
return nil unless pasted_image_basename?(path)
|
|
260
|
+
|
|
261
|
+
screenshot_search_dirs.filter_map do |dir|
|
|
262
|
+
candidate = File.join(dir, path)
|
|
263
|
+
next unless File.file?(candidate)
|
|
264
|
+
|
|
265
|
+
File.expand_path(candidate)
|
|
266
|
+
end.first
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def pasted_image_basename?(path)
|
|
270
|
+
File.basename(path).match?(PASTED_IMAGE_BASENAME_PATTERN)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def screenshot_search_dirs(home: Dir.home, tmpdir: Dir.tmpdir)
|
|
274
|
+
(SCREENSHOT_SEARCH_DIRS.map { |dir| File.join(home, dir) } + [tmpdir]).uniq
|
|
275
|
+
rescue ArgumentError
|
|
276
|
+
[]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def file_uri_path(uri_text)
|
|
280
|
+
uri = URI.parse(uri_text)
|
|
281
|
+
CGI.unescape(uri.path.to_s)
|
|
282
|
+
rescue URI::InvalidURIError
|
|
283
|
+
uri_text.delete_prefix("file://")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def image_extension?(path)
|
|
287
|
+
MIME_TYPES.key?(File.extname(path.to_s).downcase)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def mime_type(path)
|
|
291
|
+
MIME_TYPES[File.extname(path.to_s).downcase]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def data_url(part)
|
|
295
|
+
media_type = part[:mimeType] || part["mimeType"] || part[:media_type] || part["media_type"]
|
|
296
|
+
"data:#{media_type};base64,#{part[:data] || part["data"]}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, env: ENV)
|
|
300
|
+
data = part[:data] || part["data"]
|
|
301
|
+
return nil if data.to_s.empty?
|
|
302
|
+
|
|
303
|
+
name = part[:path] || part["path"]
|
|
304
|
+
if iterm_image_protocol?(env)
|
|
305
|
+
iterm_image_sequence(data, name, width)
|
|
306
|
+
elsif kitty_image_protocol?(env)
|
|
307
|
+
kitty_image_sequence(data, name, width)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def iterm_image_protocol?(env)
|
|
312
|
+
env["TERM_PROGRAM"] == "iTerm.app"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def kitty_image_protocol?(env)
|
|
316
|
+
env["KITTY_WINDOW_ID"].to_s != "" || env["TERM"].to_s.include?("kitty") || env["TERM_PROGRAM"] == "WezTerm"
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def iterm_image_sequence(data, name, width)
|
|
320
|
+
params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
|
|
321
|
+
params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
|
|
322
|
+
"\e]1337;File=#{params.join(";")}:#{data}\a"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def kitty_image_sequence(data, name, width)
|
|
326
|
+
params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
|
|
327
|
+
params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
|
|
328
|
+
"\e_G#{params.join(";")}:#{data}\e\\"
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require_relative "message_access"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
class MarkdownTranscript
|
|
5
|
+
def initialize(conversation)
|
|
6
|
+
@conversation = conversation
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def render
|
|
10
|
+
lines = ["# Kward Session", ""]
|
|
11
|
+
@conversation.messages.each do |message|
|
|
12
|
+
role = MessageAccess.role(message)
|
|
13
|
+
next if role == "system"
|
|
14
|
+
|
|
15
|
+
lines << "## #{role.to_s.capitalize}"
|
|
16
|
+
name = MessageAccess.name(message)
|
|
17
|
+
lines << "Tool: `#{name}`" if role == "tool" && name
|
|
18
|
+
lines << ""
|
|
19
|
+
lines << markdown_content(message_markdown_content(message, role))
|
|
20
|
+
lines << ""
|
|
21
|
+
end
|
|
22
|
+
lines.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def message_markdown_content(message, role)
|
|
28
|
+
if role == "compactionSummary"
|
|
29
|
+
MessageAccess.value(message, :summary)
|
|
30
|
+
elsif role == "user"
|
|
31
|
+
message_display_text(message)
|
|
32
|
+
else
|
|
33
|
+
MessageAccess.content(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message_display_text(message)
|
|
38
|
+
display_content = MessageAccess.value(message, :display_content) || MessageAccess.value(message, :displayContent)
|
|
39
|
+
return display_content.to_s unless display_content.nil?
|
|
40
|
+
|
|
41
|
+
markdown_content(MessageAccess.content(message))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def markdown_content(content)
|
|
45
|
+
case content
|
|
46
|
+
when Array
|
|
47
|
+
content.filter_map { |part| markdown_content_part(part) }.join("\n")
|
|
48
|
+
else
|
|
49
|
+
content.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def markdown_content_part(part)
|
|
54
|
+
return part.to_s unless part.respond_to?(:key?)
|
|
55
|
+
|
|
56
|
+
type = MessageAccess.value(part, :type).to_s
|
|
57
|
+
case type
|
|
58
|
+
when "text"
|
|
59
|
+
MessageAccess.value(part, :text)
|
|
60
|
+
when "image"
|
|
61
|
+
path = MessageAccess.value(part, :path)
|
|
62
|
+
media_type = MessageAccess.value(part, :mimeType) || MessageAccess.value(part, :media_type) || "image"
|
|
63
|
+
"[#{media_type}#{path ? ": #{path}" : ""}]"
|
|
64
|
+
when "thinking", "reasoning"
|
|
65
|
+
thinking = MessageAccess.value(part, :thinking) || MessageAccess.value(part, :reasoning) || MessageAccess.value(part, :text)
|
|
66
|
+
thinking.to_s.empty? ? nil : "Reasoning:\n#{thinking}"
|
|
67
|
+
else
|
|
68
|
+
MessageAccess.value(part, :text)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|