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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -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