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
|
@@ -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
|