kward 0.84.0 → 0.85.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -1,659 +1,2 @@
1
- require_relative "config_files"
2
- require_relative "deep_copy"
3
- require_relative "hooks"
4
- require_relative "transport"
5
-
6
- # Namespace for the Kward CLI agent runtime.
7
- module Kward
8
- # Loads trusted user plugin files and provides the plugin DSL.
9
- #
10
- # Plugins live in the user plugin directory, run as local Ruby code, and can
11
- # register slash commands, one footer renderer, prompt context, and live
12
- # transcript-event observers for CLI and RPC frontends.
13
- #
14
- # This registry is intentionally trust-based, not a sandbox. Keep plugin loading
15
- # restricted to `ConfigFiles.plugin_paths`, keep workspace-local code out of the
16
- # load path, and expose immutable transcript views so plugins can observe state
17
- # without corrupting active conversations.
18
- class PluginRegistry
19
- COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
20
-
21
- # Registered slash command exposed in completion, RPC command listings, and
22
- # interactive command dispatch.
23
- Command = Struct.new(:name, :description, :argument_hint, :path, :handler, keyword_init: true) do
24
- def entry
25
- { name: name, description: description, argument_hint: argument_hint }
26
- end
27
- end
28
-
29
- # Registered interactive command that takes over the composer region with a
30
- # Kward-driven render and input loop. Like a slash command but with canvas
31
- # rendering capabilities for games, dashboards, viewers, and similar uses.
32
- InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
33
- def entry
34
- { name: name, description: description, argument_hint: argument_hint }
35
- end
36
- end
37
-
38
- # Registered plugin-owned tab runtime. Its factory receives a
39
- # `PluginTabHost` and its persisted descriptor, then returns a driver.
40
- TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transport, :local, :transcript_events, :path, :handler, keyword_init: true)
41
-
42
- # Registered external transport. The factory receives a transport host and
43
- # configuration when the transport is started, not while plugins load.
44
- TransportType = Struct.new(:id, :name, :capabilities, :execution_profile, :path, :handler, keyword_init: true)
45
-
46
- # Read-only event passed to plugin transcript observers.
47
- TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
48
- def to_h
49
- { type: type, payload: payload }
50
- end
51
- end
52
-
53
- # Registered lifecycle hook handler.
54
- HookHandler = Struct.new(:event, :id, :description, :path, :order, :match, :failure_policy, :handler, keyword_init: true)
55
-
56
- # Read-only transcript view exposed to plugin code.
57
- class Transcript
58
- # Creates an object for trusted plugin loading and dispatch.
59
- def initialize(conversation)
60
- @conversation = conversation
61
- end
62
-
63
- # Returns a deep-frozen copy of the active conversation messages.
64
- #
65
- # @return [Array<Hash>] immutable transcript message data
66
- def messages
67
- DeepCopy.freeze(DeepCopy.dup(@conversation.messages))
68
- end
69
- end
70
-
71
- # Runtime context passed to plugin commands, footers, prompt context
72
- # renderers, and transcript event handlers.
73
- class Context
74
- attr_reader :args, :workspace_root
75
-
76
- # Creates an object for trusted plugin loading and dispatch.
77
- def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil)
78
- @conversation = conversation
79
- @args = args.to_s
80
- @session = session
81
- @workspace_root = workspace_root
82
- @say_callback = say_callback
83
- end
84
-
85
- # @return [Transcript] read-only transcript wrapper
86
- def transcript
87
- Transcript.new(@conversation)
88
- end
89
-
90
- # Emits command output to the active frontend when available.
91
- #
92
- # @param message [#to_s] message to display
93
- # @return [nil]
94
- def say(message)
95
- @say_callback&.call(message.to_s)
96
- nil
97
- end
98
-
99
- # @return [String, nil] active session identifier
100
- def session_id
101
- @session&.id
102
- end
103
-
104
- # @return [String, nil] human-readable active session name
105
- def session_name
106
- @session&.name
107
- end
108
-
109
- # @return [String, nil] saved active session path
110
- def session_path
111
- @session&.path
112
- end
113
-
114
- # Requests that the conversation rebuild its system message after plugin
115
- # state changes that affect prompt context.
116
- #
117
- # @return [nil]
118
- def refresh_system_message!
119
- @conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
120
- nil
121
- end
122
-
123
- # Allows the current lifecycle event to continue.
124
- # @return [Hooks::Decision]
125
- def allow(message = nil, metadata: nil)
126
- Hooks::Decision.allow(message, metadata: metadata)
127
- end
128
-
129
- # Denies the current lifecycle event.
130
- # @return [Hooks::Decision]
131
- def deny(message = nil, metadata: nil)
132
- Hooks::Decision.deny(message, metadata: metadata)
133
- end
134
-
135
- # Requests frontend approval for the current lifecycle event.
136
- # @return [Hooks::Decision]
137
- def ask(message = nil, metadata: nil)
138
- Hooks::Decision.ask(message, metadata: metadata)
139
- end
140
-
141
- # Continues with an event-specific payload replacement.
142
- # @param payload [Hash] replacement fields supported by the event
143
- # @return [Hooks::Decision]
144
- def modify(payload, message: nil, metadata: nil)
145
- Hooks::Decision.modify(payload, message: message, metadata: metadata)
146
- end
147
-
148
- # Allows the event while recording a warning.
149
- # @return [Hooks::Decision]
150
- def warn(message = nil, metadata: nil)
151
- Hooks::Decision.warn(message, metadata: metadata)
152
- end
153
-
154
- # Requests a retry when the current event supports it.
155
- # @return [Hooks::Decision]
156
- def retry(message = nil, payload: nil, metadata: nil)
157
- Hooks::Decision.retry(message, payload: payload, metadata: metadata)
158
- end
159
-
160
- # Defers the event when the current workflow supports it.
161
- # @return [Hooks::Decision]
162
- def defer(message = nil, payload: nil, metadata: nil)
163
- Hooks::Decision.defer(message, payload: payload, metadata: metadata)
164
- end
165
- end
166
-
167
- # Public DSL object yielded by `Kward.plugin` blocks.
168
- #
169
- # Plugin files normally interact with this object only through a block:
170
- #
171
- # @example Register a plugin command
172
- # Kward.plugin do |plugin|
173
- # plugin.command "hello", description: "Say hello" do |args, ctx|
174
- # name = args.strip.empty? ? "there" : args.strip
175
- # ctx.say "Hello, #{name}."
176
- # end
177
- # end
178
- #
179
- # @api public
180
- class DSL
181
- # Creates an object for trusted plugin loading and dispatch.
182
- def initialize(registry, path)
183
- @registry = registry
184
- @path = path
185
- end
186
-
187
- # Registers a slash command.
188
- #
189
- # The command is available in the interactive CLI and through the RPC
190
- # command bridge. Command names do not include the leading `/`.
191
- #
192
- # @param name [String, #to_s] command name without the leading slash
193
- # @param description [String] short text shown in command listings
194
- # @param argument_hint [String] optional usage hint for arguments
195
- # @yieldparam args [String] text after the command name
196
- # @yieldparam ctx [Context] plugin execution context
197
- # @return [void]
198
- # @api public
199
- def command(name, description: "", argument_hint: "", &block)
200
- @registry.register_command(name, description: description, argument_hint: argument_hint, path: @path, &block)
201
- end
202
-
203
- # Registers or replaces the custom footer renderer.
204
- #
205
- # Only one footer renderer is active. If multiple plugins register one,
206
- # the later renderer replaces the earlier renderer.
207
- #
208
- # @yieldparam ctx [Context] plugin execution context
209
- # @return [void]
210
- # @api public
211
- def footer(&block)
212
- @registry.register_footer(path: @path, &block)
213
- end
214
-
215
- # Registers a live transcript event observer.
216
- #
217
- # Observer errors are caught and reported as warnings so a plugin cannot
218
- # crash the active turn by raising from an event handler.
219
- #
220
- # @yieldparam event [TranscriptEvent] normalized transcript event
221
- # @yieldparam ctx [Context] plugin execution context
222
- # @return [void]
223
- # @api public
224
- def on_transcript_event(&block)
225
- @registry.register_transcript_event(path: @path, &block)
226
- end
227
-
228
- # Registers a lifecycle hook handler.
229
- #
230
- # Hooks are deterministic runtime callbacks around Kward lifecycle events.
231
- # They can return a {Hooks::Decision}, a decision hash, a decision string,
232
- # or nil to allow the operation.
233
- #
234
- # @param event [String, #to_s] lifecycle event name
235
- # @param id [String, nil] stable hook identifier for logs and diagnostics
236
- # @param description [String] short human-readable purpose
237
- # @param order [Integer] lower values run first
238
- # @param match [Hash, nil] optional event selector
239
- # @yieldparam event [Hooks::Event] immutable lifecycle event
240
- # @yieldparam ctx [Context] plugin execution context and decision helpers
241
- # @return [void]
242
- # @api public
243
- def hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, &block)
244
- @registry.register_hook(event, id: id, description: description, order: order, match: match, failure_policy: failure_policy, path: @path, &block)
245
- end
246
-
247
- # Registers prompt context text injected into future system prompts.
248
- #
249
- # Keep this text short and never include secrets. The returned string can
250
- # be sent to the active model as part of Kward's system instructions.
251
- #
252
- # @yieldparam ctx [Context] plugin execution context
253
- # @return [void]
254
- # @api public
255
- def prompt_context(&block)
256
- @registry.register_prompt_context(path: @path, &block)
257
- end
258
-
259
- # Registers an interactive command that takes over the composer region with
260
- # a Kward-driven render and input loop. The handler receives an
261
- # interactive controller object with a canvas API for drawing colored
262
- # cells and reading keys. Useful for games, dashboards, and viewers.
263
- #
264
- # @param name [String, #to_s] command name without the leading slash
265
- # @param rows [Integer] fixed canvas height in terminal rows
266
- # @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
267
- # @param description [String] short text shown in command listings
268
- # @param argument_hint [String] optional usage hint for arguments
269
- # @yieldparam ui [Object] interactive controller with canvas and key API
270
- # @yieldparam ctx [Context] plugin execution context
271
- # @return [void]
272
- # @api public
273
- def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
274
- @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
275
- end
276
-
277
- # Registers a plugin-owned chat type. `id` is a durable identifier used
278
- # in persisted tab layouts and transport chat handles and must not change.
279
- # The factory receives a `PluginTabHost` and a descriptor hash.
280
- #
281
- # @param name [String] command name used by `/tab open <name>`
282
- # @param id [String] stable persisted tab type identifier
283
- # @param title [String] default tab label
284
- # @param singleton [Symbol] `:global` for one shared plugin runtime
285
- # @param rpc [Boolean] expose this chat through trusted local RPC
286
- # @param transport [Boolean] allow external transport adapters to target this chat
287
- # @param local [Boolean] expose this chat as an interactive local tab
288
- # @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
289
- # @yieldparam host [PluginTabHost] supported host dependencies
290
- # @yieldparam descriptor [Hash] persisted tab descriptor
291
- # @return [void]
292
- # @api public
293
- def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, &block)
294
- @registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transport: transport, local: local, transcript_events: transcript_events, path: @path, &block)
295
- end
296
-
297
- # Registers an external messaging or event transport. The factory is
298
- # called when the transport runtime starts.
299
- #
300
- # @param name [String] human-readable transport name
301
- # @param id [String] stable transport identifier
302
- # @param capabilities [Hash, Transport::Capabilities] supported features
303
- # @yieldparam host [Object] transport host
304
- # @yieldparam config [Object] transport configuration
305
- # @return [void]
306
- # @api public
307
- def transport(name, id:, capabilities: nil, execution_profile: nil, &block)
308
- @registry.register_transport(name, id: id, capabilities: capabilities, execution_profile: execution_profile, path: @path, &block)
309
- end
310
- end
311
-
312
- # Mutable singleton guard used while loading trusted plugin files.
313
- class << self
314
- attr_accessor :loading_registry, :loading_path
315
-
316
- def load(paths: nil, reserved_commands: [], warning_sink: nil)
317
- warning_sink ||= ConfigFiles.warning_sink
318
- paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
319
- registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
320
- paths.each { |path| registry.load_file(path) }
321
- registry
322
- end
323
- end
324
-
325
- # Creates an object for trusted plugin loading and dispatch.
326
- def initialize(reserved_commands: [], warning_sink: nil)
327
- @reserved_commands = reserved_commands.map(&:to_s)
328
- @warning_sink = warning_sink
329
- @commands = {}
330
- @interactive_commands = {}
331
- @tab_types = {}
332
- @tab_types_by_id = {}
333
- @transports = {}
334
- @transports_by_id = {}
335
- @footer = nil
336
- @footer_path = nil
337
- @transcript_event_handlers = []
338
- @prompt_context_renderers = []
339
- @hook_handlers = []
340
- @paths = []
341
- end
342
-
343
- # @return [String, nil] plugin file currently responsible for footer output
344
- attr_reader :footer_path
345
-
346
- # @return [Array<String>] plugin files successfully loaded by this registry
347
- attr_reader :paths
348
-
349
- def commands
350
- @commands.values
351
- end
352
-
353
- def command_for(name)
354
- @commands[name.to_s]
355
- end
356
-
357
- def interactive_commands
358
- @interactive_commands.values
359
- end
360
-
361
- def interactive_command_for(name)
362
- @interactive_commands[name.to_s]
363
- end
364
-
365
- def tab_types
366
- @tab_types.values
367
- end
368
-
369
- def tab_type_for(name)
370
- @tab_types[name.to_s]
371
- end
372
-
373
- def tab_type_for_id(id)
374
- @tab_types_by_id[id.to_s]
375
- end
376
-
377
- def transport_tab_types
378
- @tab_types.values.select(&:transport)
379
- end
380
-
381
- def transports
382
- @transports.values
383
- end
384
-
385
- def transport_for(name)
386
- @transports[name.to_s]
387
- end
388
-
389
- def transport_for_id(id)
390
- @transports_by_id[id.to_s]
391
- end
392
-
393
- def footer_renderer
394
- @footer
395
- end
396
-
397
- def transcript_event_handlers
398
- @transcript_event_handlers.map { |entry| entry[:handler] }
399
- end
400
-
401
- def prompt_context_renderers
402
- @prompt_context_renderers.map { |entry| entry[:renderer] }
403
- end
404
-
405
- def hook_handlers
406
- @hook_handlers.dup
407
- end
408
-
409
- def hook_manager
410
- manager = Hooks::Manager.new
411
- @hook_handlers.each do |hook|
412
- manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
413
- hook.handler.call(event, context)
414
- end
415
- end
416
- manager
417
- end
418
-
419
- def prompt_context(context)
420
- parts = []
421
- @prompt_context_renderers.each do |entry|
422
- rendered = entry[:renderer].call(context)
423
- parts << rendered.to_s unless rendered.to_s.empty?
424
- rescue StandardError => e
425
- emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
426
- end
427
- parts.empty? ? nil : parts.join("\n\n")
428
- end
429
-
430
- def notify_transcript_event(event, context)
431
- transcript_event = transcript_event_for(event)
432
- return unless transcript_event
433
-
434
- @transcript_event_handlers.each do |entry|
435
- entry[:handler].call(transcript_event, context)
436
- rescue StandardError => e
437
- emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
438
- end
439
- nil
440
- end
441
-
442
- def load_file(path)
443
- previous_registry = self.class.loading_registry
444
- previous_path = self.class.loading_path
445
- self.class.loading_registry = self
446
- self.class.loading_path = path
447
- Kernel.load(path, true)
448
- @paths << path
449
- rescue StandardError => e
450
- emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
451
- ensure
452
- self.class.loading_registry = previous_registry
453
- self.class.loading_path = previous_path
454
- end
455
-
456
- def evaluate(path: nil, &block)
457
- dsl = DSL.new(self, path)
458
- block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
459
- self
460
- end
461
-
462
- def register_command(name, description: "", argument_hint: "", path: nil, &handler)
463
- name = name.to_s
464
- raise "Plugin command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
465
- raise "Plugin command /#{name} requires a handler" unless handler
466
-
467
- if @reserved_commands.include?(name)
468
- emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
469
- return nil
470
- end
471
- if @commands.key?(name)
472
- emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
473
- return nil
474
- end
475
-
476
- @commands[name] = Command.new(
477
- name: name,
478
- description: description.to_s,
479
- argument_hint: argument_hint.to_s,
480
- path: path,
481
- handler: handler
482
- )
483
- end
484
-
485
- def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
486
- name = name.to_s
487
- raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
488
- raise "Interactive command /#{name} requires a handler" unless handler
489
-
490
- if @reserved_commands.include?(name) || @commands.key?(name)
491
- emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
492
- return nil
493
- end
494
- if @interactive_commands.key?(name)
495
- emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
496
- return nil
497
- end
498
-
499
- @interactive_commands[name] = InteractiveCommand.new(
500
- name: name,
501
- description: description.to_s,
502
- argument_hint: argument_hint.to_s,
503
- rows: [[rows.to_i, 1].max, 1].max,
504
- fps: [[fps.to_f, 1].max, 120].min,
505
- path: path,
506
- handler: handler
507
- )
508
- end
509
-
510
- def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, path: nil, &handler)
511
- name = name.to_s
512
- id = id.to_s
513
- raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
514
- raise "Plugin tab type id is required" if id.empty?
515
- raise "Plugin tab type #{name} requires a handler" unless handler
516
-
517
- if @tab_types.key?(name) || @tab_types_by_id.key?(id)
518
- emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
519
- return nil
520
- end
521
-
522
- tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transport: transport == true, local: local == true, transcript_events: transcript_events == true, path: path, handler: handler)
523
- @tab_types[name] = tab_type
524
- @tab_types_by_id[id] = tab_type
525
- end
526
-
527
- def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
528
- name = name.to_s
529
- id = id.to_s
530
- raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
531
- raise "Plugin transport id is required" if id.empty?
532
- raise "Plugin transport #{name} requires a handler" unless handler
533
-
534
- if @transports.key?(name) || @transports_by_id.key?(id)
535
- emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
536
- return nil
537
- end
538
-
539
- capabilities = normalize_transport_capabilities(capabilities)
540
- execution_profile = normalize_execution_profile(execution_profile)
541
- transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
542
- @transports[name] = transport
543
- @transports_by_id[id] = transport
544
- end
545
-
546
- def register_footer(path: nil, &renderer)
547
- raise "Plugin footer requires a renderer" unless renderer
548
-
549
- emit_warning "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
550
- @footer = renderer
551
- @footer_path = path
552
- end
553
-
554
- def emit_warning(message)
555
- @warning_sink ? @warning_sink.call(message) : warn(message)
556
- end
557
-
558
- def register_transcript_event(path: nil, &handler)
559
- raise "Plugin transcript event requires a handler" unless handler
560
-
561
- @transcript_event_handlers << { path: path, handler: handler }
562
- end
563
-
564
- def register_prompt_context(path: nil, &renderer)
565
- raise "Plugin prompt context requires a renderer" unless renderer
566
-
567
- @prompt_context_renderers << { path: path, renderer: renderer }
568
- end
569
-
570
- def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
571
- event = event.to_s
572
- raise "Plugin hook event is required" if event.empty?
573
- raise "Plugin hook #{event} requires a handler" unless handler
574
-
575
- @hook_handlers << HookHandler.new(
576
- event: event,
577
- id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
578
- description: description.to_s,
579
- path: path,
580
- order: order.to_i,
581
- match: match,
582
- failure_policy: failure_policy,
583
- handler: handler
584
- )
585
- end
586
-
587
- private
588
-
589
- def normalize_execution_profile(profile)
590
- return nil if profile.nil?
591
- return profile if profile.is_a?(Transport::ExecutionProfile)
592
- raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
593
- end
594
-
595
- def normalize_transport_capabilities(capabilities)
596
- return Transport.capabilities if capabilities.nil?
597
- return capabilities if capabilities.is_a?(Transport::Capabilities)
598
- raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
599
-
600
- Transport.capabilities(**capabilities.transform_keys(&:to_sym))
601
- end
602
-
603
- def transcript_event_for(event)
604
- case event.class.name
605
- when "Kward::Events::ReasoningDelta"
606
- transcript_event("reasoning_delta", delta: event.delta)
607
- when "Kward::Events::ReasoningBoundary"
608
- transcript_event("reasoning_boundary")
609
- when "Kward::Events::AssistantDelta"
610
- transcript_event("assistant_delta", delta: event.delta)
611
- when "Kward::Events::AssistantMessage"
612
- transcript_event("assistant_message", message: event.message)
613
- when "Kward::Events::Retry"
614
- transcript_event(
615
- "model_retry",
616
- provider: event.provider,
617
- model: event.model,
618
- attempt: event.attempt,
619
- max_attempts: event.max_attempts,
620
- delay_seconds: event.delay_seconds,
621
- error: event.error,
622
- request_bytes: event.request_bytes
623
- )
624
- when "Kward::Events::Steering"
625
- transcript_event("turn_steered", input: event.input, created_at: event.created_at)
626
- when "Kward::Events::ToolCall"
627
- transcript_event("tool_call", tool_call: event.tool_call)
628
- when "Kward::Events::ToolResult"
629
- transcript_event("tool_result", tool_call: event.tool_call, content: event.content)
630
- when "Kward::Events::Answer"
631
- transcript_event("answer", content: event.content)
632
- end
633
- end
634
-
635
- def transcript_event(type, payload = {})
636
- TranscriptEvent.new(
637
- type: type,
638
- payload: DeepCopy.freeze(DeepCopy.dup(payload))
639
- ).freeze
640
- end
641
- end
642
-
643
- # Registers a trusted local plugin.
644
- #
645
- # This method is intended for Ruby files loaded from the user plugin
646
- # directory. It raises if called outside plugin loading so workspace code
647
- # cannot silently mutate Kward's runtime by merely being required.
648
- #
649
- # @yieldparam plugin [PluginRegistry::DSL] plugin registration DSL
650
- # @return [Object, nil] the plugin block result
651
- # @api public
652
- def self.plugin(&block)
653
- registry = PluginRegistry.loading_registry
654
- raise "Kward.plugin can only be called while loading a plugin" unless registry
655
-
656
- dsl = PluginRegistry::DSL.new(registry, PluginRegistry.loading_path)
657
- block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
658
- end
659
- end
1
+ # Compatibility require path for the public plugin registry.
2
+ require_relative "plugins/registry"