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,327 @@
1
+ require_relative "config_files"
2
+
3
+ module Kward
4
+ # Loads trusted user plugin files and provides the plugin DSL.
5
+ #
6
+ # Plugins live in the user plugin directory, run as local Ruby code, and can
7
+ # register slash commands, one footer renderer, prompt context, and live
8
+ # transcript-event observers for CLI and RPC frontends.
9
+ class PluginRegistry
10
+ COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
11
+
12
+ # Registered slash command exposed in completion, RPC command listings, and
13
+ # interactive command dispatch.
14
+ Command = Struct.new(:name, :description, :argument_hint, :path, :handler, keyword_init: true) do
15
+ def entry
16
+ { name: name, description: description, argument_hint: argument_hint }
17
+ end
18
+ end
19
+
20
+ # Read-only event passed to plugin transcript observers.
21
+ TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
22
+ def to_h
23
+ { type: type, payload: payload }
24
+ end
25
+ end
26
+
27
+ # Read-only transcript view exposed to plugin code.
28
+ class Transcript
29
+ def initialize(conversation)
30
+ @conversation = conversation
31
+ end
32
+
33
+ # Returns a deep-frozen copy of the active conversation messages.
34
+ #
35
+ # @return [Array<Hash>] immutable transcript message data
36
+ def messages
37
+ PluginRegistry.deep_freeze(PluginRegistry.deep_dup(@conversation.messages))
38
+ end
39
+ end
40
+
41
+ # Runtime context passed to plugin commands, footers, prompt context
42
+ # renderers, and transcript event handlers.
43
+ class Context
44
+ attr_reader :args, :workspace_root
45
+
46
+ def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil)
47
+ @conversation = conversation
48
+ @args = args.to_s
49
+ @session = session
50
+ @workspace_root = workspace_root
51
+ @say_callback = say_callback
52
+ end
53
+
54
+ # @return [Transcript] read-only transcript wrapper
55
+ def transcript
56
+ Transcript.new(@conversation)
57
+ end
58
+
59
+ # Emits command output to the active frontend when available.
60
+ #
61
+ # @param message [#to_s] message to display
62
+ # @return [nil]
63
+ def say(message)
64
+ @say_callback&.call(message.to_s)
65
+ nil
66
+ end
67
+
68
+ def session_id
69
+ @session&.id
70
+ end
71
+
72
+ def session_name
73
+ @session&.name
74
+ end
75
+
76
+ def session_path
77
+ @session&.path
78
+ end
79
+
80
+ # Requests that the conversation rebuild its system message after plugin
81
+ # state changes that affect prompt context.
82
+ #
83
+ # @return [nil]
84
+ def refresh_system_message!
85
+ @conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
86
+ nil
87
+ end
88
+ end
89
+
90
+ # DSL object yielded by `Kward.plugin` blocks.
91
+ class DSL
92
+ def initialize(registry, path)
93
+ @registry = registry
94
+ @path = path
95
+ end
96
+
97
+ # Registers a slash command.
98
+ #
99
+ # @param name [String, #to_s] command name without the leading slash
100
+ # @param description [String] short text shown in command listings
101
+ # @param argument_hint [String] optional usage hint for arguments
102
+ # @yieldparam args [String] text after the command name
103
+ # @yieldparam ctx [Context] plugin execution context
104
+ def command(name, description: "", argument_hint: "", &block)
105
+ @registry.register_command(name, description: description, argument_hint: argument_hint, path: @path, &block)
106
+ end
107
+
108
+ # Registers or replaces the custom footer renderer.
109
+ #
110
+ # @yieldparam ctx [Context] plugin execution context
111
+ def footer(&block)
112
+ @registry.register_footer(path: @path, &block)
113
+ end
114
+
115
+ # Registers a live transcript event observer.
116
+ #
117
+ # @yieldparam event [TranscriptEvent] normalized transcript event
118
+ # @yieldparam ctx [Context] plugin execution context
119
+ def on_transcript_event(&block)
120
+ @registry.register_transcript_event(path: @path, &block)
121
+ end
122
+
123
+ # Registers prompt context text injected into future system prompts.
124
+ #
125
+ # @yieldparam ctx [Context] plugin execution context
126
+ def prompt_context(&block)
127
+ @registry.register_prompt_context(path: @path, &block)
128
+ end
129
+ end
130
+
131
+ class << self
132
+ attr_accessor :loading_registry, :loading_path
133
+
134
+ def load(paths: ConfigFiles.plugin_paths, reserved_commands: [])
135
+ registry = new(reserved_commands: reserved_commands)
136
+ paths.each { |path| registry.load_file(path) }
137
+ registry
138
+ end
139
+
140
+ def deep_dup(value)
141
+ case value
142
+ when Hash
143
+ value.each_with_object({}) { |(key, item), result| result[key] = deep_dup(item) }
144
+ when Array
145
+ value.map { |item| deep_dup(item) }
146
+ else
147
+ value.dup
148
+ end
149
+ rescue TypeError
150
+ value
151
+ end
152
+
153
+ def deep_freeze(value)
154
+ case value
155
+ when Hash
156
+ value.each_value { |item| deep_freeze(item) }
157
+ when Array
158
+ value.each { |item| deep_freeze(item) }
159
+ end
160
+ value.freeze
161
+ end
162
+ end
163
+
164
+ def initialize(reserved_commands: [])
165
+ @reserved_commands = reserved_commands.map(&:to_s)
166
+ @commands = {}
167
+ @footer = nil
168
+ @footer_path = nil
169
+ @transcript_event_handlers = []
170
+ @prompt_context_renderers = []
171
+ end
172
+
173
+ attr_reader :footer_path
174
+
175
+ def commands
176
+ @commands.values
177
+ end
178
+
179
+ def command_for(name)
180
+ @commands[name.to_s]
181
+ end
182
+
183
+ def footer_renderer
184
+ @footer
185
+ end
186
+
187
+ def transcript_event_handlers
188
+ @transcript_event_handlers.map { |entry| entry[:handler] }
189
+ end
190
+
191
+ def prompt_context_renderers
192
+ @prompt_context_renderers.map { |entry| entry[:renderer] }
193
+ end
194
+
195
+ def prompt_context(context)
196
+ parts = []
197
+ @prompt_context_renderers.each do |entry|
198
+ rendered = entry[:renderer].call(context)
199
+ parts << rendered.to_s unless rendered.to_s.empty?
200
+ rescue StandardError => e
201
+ warn "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
202
+ end
203
+ parts.empty? ? nil : parts.join("\n\n")
204
+ end
205
+
206
+ def notify_transcript_event(event, context)
207
+ transcript_event = transcript_event_for(event)
208
+ return unless transcript_event
209
+
210
+ @transcript_event_handlers.each do |entry|
211
+ entry[:handler].call(transcript_event, context)
212
+ rescue StandardError => e
213
+ warn "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
214
+ end
215
+ nil
216
+ end
217
+
218
+ def load_file(path)
219
+ previous_registry = self.class.loading_registry
220
+ previous_path = self.class.loading_path
221
+ self.class.loading_registry = self
222
+ self.class.loading_path = path
223
+ Kernel.load(path)
224
+ rescue StandardError => e
225
+ warn "Warning: skipping Kward plugin #{path}: #{e.message}"
226
+ ensure
227
+ self.class.loading_registry = previous_registry
228
+ self.class.loading_path = previous_path
229
+ end
230
+
231
+ def evaluate(path: nil, &block)
232
+ dsl = DSL.new(self, path)
233
+ block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
234
+ self
235
+ end
236
+
237
+ def register_command(name, description: "", argument_hint: "", path: nil, &handler)
238
+ name = name.to_s
239
+ raise "Plugin command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
240
+ raise "Plugin command /#{name} requires a handler" unless handler
241
+
242
+ if @reserved_commands.include?(name)
243
+ warn "Warning: skipping Kward plugin command /#{name}: reserved command"
244
+ return nil
245
+ end
246
+ if @commands.key?(name)
247
+ warn "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
248
+ return nil
249
+ end
250
+
251
+ @commands[name] = Command.new(
252
+ name: name,
253
+ description: description.to_s,
254
+ argument_hint: argument_hint.to_s,
255
+ path: path,
256
+ handler: handler
257
+ )
258
+ end
259
+
260
+ def register_footer(path: nil, &renderer)
261
+ raise "Plugin footer requires a renderer" unless renderer
262
+
263
+ warn "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
264
+ @footer = renderer
265
+ @footer_path = path
266
+ end
267
+
268
+ def register_transcript_event(path: nil, &handler)
269
+ raise "Plugin transcript event requires a handler" unless handler
270
+
271
+ @transcript_event_handlers << { path: path, handler: handler }
272
+ end
273
+
274
+ def register_prompt_context(path: nil, &renderer)
275
+ raise "Plugin prompt context requires a renderer" unless renderer
276
+
277
+ @prompt_context_renderers << { path: path, renderer: renderer }
278
+ end
279
+
280
+ private
281
+
282
+ def transcript_event_for(event)
283
+ case event.class.name
284
+ when "Kward::Events::ReasoningDelta"
285
+ transcript_event("reasoning_delta", delta: event.delta)
286
+ when "Kward::Events::AssistantDelta"
287
+ transcript_event("assistant_delta", delta: event.delta)
288
+ when "Kward::Events::AssistantMessage"
289
+ transcript_event("assistant_message", message: event.message)
290
+ when "Kward::Events::Retry"
291
+ transcript_event(
292
+ "model_retry",
293
+ provider: event.provider,
294
+ model: event.model,
295
+ attempt: event.attempt,
296
+ max_attempts: event.max_attempts,
297
+ delay_seconds: event.delay_seconds,
298
+ error: event.error,
299
+ request_bytes: event.request_bytes
300
+ )
301
+ when "Kward::Events::Steering"
302
+ transcript_event("turn_steered", input: event.input, created_at: event.created_at)
303
+ when "Kward::Events::ToolCall"
304
+ transcript_event("tool_call", tool_call: event.tool_call)
305
+ when "Kward::Events::ToolResult"
306
+ transcript_event("tool_result", tool_call: event.tool_call, content: event.content)
307
+ when "Kward::Events::Answer"
308
+ transcript_event("answer", content: event.content)
309
+ end
310
+ end
311
+
312
+ def transcript_event(type, payload)
313
+ TranscriptEvent.new(
314
+ type: type,
315
+ payload: PluginRegistry.deep_freeze(PluginRegistry.deep_dup(payload))
316
+ ).freeze
317
+ end
318
+ end
319
+
320
+ def self.plugin(&block)
321
+ registry = PluginRegistry.loading_registry
322
+ raise "Kward.plugin can only be called while loading a plugin" unless registry
323
+
324
+ dsl = PluginRegistry::DSL.new(registry, PluginRegistry.loading_path)
325
+ block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
326
+ end
327
+ end
@@ -0,0 +1,18 @@
1
+ require "fileutils"
2
+ require "json"
3
+
4
+ module Kward
5
+ module PrivateFile
6
+ module_function
7
+
8
+ def write_json(path, data)
9
+ path = File.expand_path(path)
10
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
11
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
12
+ file.write(JSON.pretty_generate(data))
13
+ file.write("\n")
14
+ end
15
+ File.chmod(0o600, path)
16
+ end
17
+ end
18
+ end