kward 0.83.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
- data/lib/kward/terminal/text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +109 -17
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +3 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
require_relative "../config_files"
|
|
2
|
+
require_relative "../deep_copy"
|
|
3
|
+
require_relative "../hooks"
|
|
4
|
+
require_relative "../transport"
|
|
5
|
+
require_relative "actions"
|
|
6
|
+
require_relative "chat_contract"
|
|
7
|
+
require_relative "host"
|
|
8
|
+
require_relative "ui"
|
|
9
|
+
require_relative "turn_request"
|
|
10
|
+
|
|
11
|
+
# Namespace for the Kward CLI agent runtime.
|
|
12
|
+
module Kward
|
|
13
|
+
# Loads trusted user plugin files and provides the plugin DSL.
|
|
14
|
+
#
|
|
15
|
+
# Plugins live in the user plugin directory, run as local Ruby code, and can
|
|
16
|
+
# register slash commands, namespaced actions, model-callable tools, lifecycle
|
|
17
|
+
# callbacks, composable status renderers, prompt context, and live
|
|
18
|
+
# transcript-event observers for CLI and RPC frontends.
|
|
19
|
+
#
|
|
20
|
+
# This registry is intentionally trust-based, not a sandbox. Keep plugin loading
|
|
21
|
+
# restricted to `ConfigFiles.plugin_paths`, keep workspace-local code out of the
|
|
22
|
+
# load path, and expose immutable transcript views so plugins can observe state
|
|
23
|
+
# without corrupting active conversations.
|
|
24
|
+
class PluginRegistry
|
|
25
|
+
PLUGIN_API_VERSION = "1"
|
|
26
|
+
COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
|
|
27
|
+
|
|
28
|
+
# Public registration types retained under the registry namespace.
|
|
29
|
+
Command = PluginCommand
|
|
30
|
+
Action = PluginAction
|
|
31
|
+
|
|
32
|
+
# Registered model-callable tool exposed through each normal agent tool
|
|
33
|
+
# registry. The handler receives parsed arguments and a runtime context.
|
|
34
|
+
Tool = Struct.new(:name, :description, :schema, :path, :handler, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
STATUS_PRIORITIES = { low: 0, normal: 1, high: 2 }.freeze
|
|
37
|
+
STATUS_SEPARATOR = " · "
|
|
38
|
+
|
|
39
|
+
# Registered footer/status contribution. Display order is independent from
|
|
40
|
+
# priority: order places segments, while priority decides which segments are
|
|
41
|
+
# removed first when the terminal is narrow.
|
|
42
|
+
Status = Struct.new(:id, :order, :priority, :path, :renderer, :sequence, keyword_init: true)
|
|
43
|
+
|
|
44
|
+
# Rendered, frontend-neutral status contribution.
|
|
45
|
+
StatusSegment = Struct.new(:id, :text, :tooltip, :priority, :order, keyword_init: true) do
|
|
46
|
+
def to_h
|
|
47
|
+
{ id: id, text: text, tooltip: tooltip, priority: priority.to_s, order: order }.compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Registered interactive command that takes over the composer region with a
|
|
52
|
+
# Kward-driven render and input loop. Like a slash command but with canvas
|
|
53
|
+
# rendering capabilities for games, dashboards, viewers, and similar uses.
|
|
54
|
+
InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
|
|
55
|
+
def entry
|
|
56
|
+
{ name: name, description: description, argument_hint: argument_hint }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Registered plugin-owned tab runtime. Its factory receives a
|
|
61
|
+
# `PluginTabHost` and its persisted descriptor, then returns a driver.
|
|
62
|
+
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transport, :local, :transcript_events, :capabilities, :plugin_id, :path, :handler, keyword_init: true)
|
|
63
|
+
|
|
64
|
+
# Registered external transport. The factory receives a transport host and
|
|
65
|
+
# configuration when the transport is started, not while plugins load.
|
|
66
|
+
TransportType = Struct.new(:id, :name, :capabilities, :execution_profile, :path, :handler, keyword_init: true)
|
|
67
|
+
|
|
68
|
+
# Read-only event passed to plugin transcript observers.
|
|
69
|
+
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
70
|
+
def to_h
|
|
71
|
+
{ type: type, payload: payload }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Registered lifecycle hook handler.
|
|
76
|
+
HookHandler = Struct.new(:event, :id, :description, :path, :order, :match, :failure_policy, :handler, keyword_init: true)
|
|
77
|
+
|
|
78
|
+
# Plugin-runtime callback invoked when an identified plugin starts, reloads,
|
|
79
|
+
# or shuts down.
|
|
80
|
+
LifecycleHandler = Struct.new(:event, :host, :path, :handler, keyword_init: true)
|
|
81
|
+
|
|
82
|
+
# Read-only transcript view exposed to plugin code.
|
|
83
|
+
class Transcript
|
|
84
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
85
|
+
def initialize(conversation)
|
|
86
|
+
@conversation = conversation
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns a deep-frozen copy of the active conversation messages.
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<Hash>] immutable transcript message data
|
|
92
|
+
def messages
|
|
93
|
+
DeepCopy.freeze(DeepCopy.dup(@conversation.messages))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Runtime context passed to plugin commands, tools, footers, prompt context
|
|
98
|
+
# renderers, hooks, and transcript event handlers.
|
|
99
|
+
class Context
|
|
100
|
+
attr_reader :args, :workspace_root, :cancellation, :ui, :requested_turn
|
|
101
|
+
|
|
102
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
103
|
+
def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil, cancellation: nil, ui: nil, tool_ui: nil, turn_command: nil)
|
|
104
|
+
@conversation = conversation
|
|
105
|
+
@args = args.is_a?(Hash) ? DeepCopy.freeze(DeepCopy.dup(args)) : args.to_s
|
|
106
|
+
@session = session
|
|
107
|
+
@workspace_root = workspace_root
|
|
108
|
+
@say_callback = say_callback
|
|
109
|
+
@cancellation = cancellation
|
|
110
|
+
@ui = (ui || PluginUI.new(say_callback: say_callback)).with_cancellation(cancellation)
|
|
111
|
+
@tool_ui = tool_ui
|
|
112
|
+
@turn_command = turn_command
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Whether this command runs on a host that can execute a session turn.
|
|
116
|
+
def turn_requests_supported?
|
|
117
|
+
!@turn_command.nil?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Stages one turn for dispatch after the command successfully returns.
|
|
121
|
+
# Available only to asynchronous, session-backed plugin commands.
|
|
122
|
+
# @return [nil]
|
|
123
|
+
def request_turn(system:)
|
|
124
|
+
raise ArgumentError, "Model turn requests are unavailable here; use a session command through the TUI or RPC turns/start" unless turn_requests_supported?
|
|
125
|
+
@cancellation&.raise_if_cancelled!
|
|
126
|
+
raise ArgumentError, "Only one model turn may be requested per command" if @requested_turn
|
|
127
|
+
|
|
128
|
+
@requested_turn = PluginTurnRequest.new(system: system, command: @turn_command.name, plugin_id: @turn_command.plugin_id)
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Transcript] read-only transcript wrapper
|
|
133
|
+
def transcript
|
|
134
|
+
Transcript.new(@conversation)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Emits command output to the active frontend when available.
|
|
138
|
+
#
|
|
139
|
+
# @param message [#to_s] message to display
|
|
140
|
+
# @return [nil]
|
|
141
|
+
def say(message)
|
|
142
|
+
@say_callback&.call(message.to_s)
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [String, nil] active session identifier
|
|
147
|
+
def session_id
|
|
148
|
+
@session&.id
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [String, nil] human-readable active session name
|
|
152
|
+
def session_name
|
|
153
|
+
@session&.name
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [String, nil] saved active session path
|
|
157
|
+
def session_path
|
|
158
|
+
@session&.path
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Requests that the conversation rebuild its system message after plugin
|
|
162
|
+
# state changes that affect prompt context.
|
|
163
|
+
#
|
|
164
|
+
# @return [nil]
|
|
165
|
+
def refresh_system_message!
|
|
166
|
+
@conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Builds a structured result for a typed command or plugin action.
|
|
171
|
+
#
|
|
172
|
+
# @param message [#to_s, nil] optional user-facing result text
|
|
173
|
+
# @param data [Object, nil] optional JSON-compatible machine-readable data
|
|
174
|
+
# @return [PluginResult]
|
|
175
|
+
def result(message: nil, data: nil)
|
|
176
|
+
PluginResult.new(message: message, data: data)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Returns whether the active plugin operation has been cancelled.
|
|
180
|
+
# Contexts without a cancellable operation return false.
|
|
181
|
+
def cancelled?
|
|
182
|
+
@cancellation&.cancelled? == true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Builds a fresh context for one model-callable plugin tool invocation.
|
|
186
|
+
# @api private
|
|
187
|
+
def for_tool(conversation:, cancellation: nil)
|
|
188
|
+
self.class.new(
|
|
189
|
+
conversation: conversation,
|
|
190
|
+
session: @session,
|
|
191
|
+
workspace_root: @workspace_root,
|
|
192
|
+
say_callback: @say_callback,
|
|
193
|
+
cancellation: cancellation,
|
|
194
|
+
ui: @tool_ui || @ui
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Allows the current lifecycle event to continue.
|
|
199
|
+
# @return [Hooks::Decision]
|
|
200
|
+
def allow(message = nil, metadata: nil)
|
|
201
|
+
Hooks::Decision.allow(message, metadata: metadata)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Denies the current lifecycle event.
|
|
205
|
+
# @return [Hooks::Decision]
|
|
206
|
+
def deny(message = nil, metadata: nil)
|
|
207
|
+
Hooks::Decision.deny(message, metadata: metadata)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Requests frontend approval for the current lifecycle event.
|
|
211
|
+
# @return [Hooks::Decision]
|
|
212
|
+
def ask(message = nil, metadata: nil)
|
|
213
|
+
Hooks::Decision.ask(message, metadata: metadata)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Continues with an event-specific payload replacement.
|
|
217
|
+
# @param payload [Hash] replacement fields supported by the event
|
|
218
|
+
# @return [Hooks::Decision]
|
|
219
|
+
def modify(payload, message: nil, metadata: nil)
|
|
220
|
+
Hooks::Decision.modify(payload, message: message, metadata: metadata)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Allows the event while recording a warning.
|
|
224
|
+
# @return [Hooks::Decision]
|
|
225
|
+
def warn(message = nil, metadata: nil)
|
|
226
|
+
Hooks::Decision.warn(message, metadata: metadata)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Requests a retry when the current event supports it.
|
|
230
|
+
# @return [Hooks::Decision]
|
|
231
|
+
def retry(message = nil, payload: nil, metadata: nil)
|
|
232
|
+
Hooks::Decision.retry(message, payload: payload, metadata: metadata)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Defers the event when the current workflow supports it.
|
|
236
|
+
# @return [Hooks::Decision]
|
|
237
|
+
def defer(message = nil, payload: nil, metadata: nil)
|
|
238
|
+
Hooks::Decision.defer(message, payload: payload, metadata: metadata)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Public DSL object yielded by `Kward.plugin` blocks.
|
|
243
|
+
#
|
|
244
|
+
# Plugin files normally interact with this object only through a block:
|
|
245
|
+
#
|
|
246
|
+
# @example Register a plugin command
|
|
247
|
+
# Kward.plugin do |plugin|
|
|
248
|
+
# plugin.command "hello", description: "Say hello" do |args, ctx|
|
|
249
|
+
# name = args.strip.empty? ? "there" : args.strip
|
|
250
|
+
# ctx.say "Hello, #{name}."
|
|
251
|
+
# end
|
|
252
|
+
# end
|
|
253
|
+
#
|
|
254
|
+
# @api public
|
|
255
|
+
class DSL
|
|
256
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
257
|
+
def initialize(registry, path, host: nil)
|
|
258
|
+
@registry = registry
|
|
259
|
+
@path = path
|
|
260
|
+
@host = host
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Shared metadata, configuration, storage, secrets, and logging services.
|
|
264
|
+
# Legacy plugins without declared identity return nil.
|
|
265
|
+
#
|
|
266
|
+
# @return [PluginHost, nil]
|
|
267
|
+
attr_reader :host
|
|
268
|
+
|
|
269
|
+
# Registers a slash command.
|
|
270
|
+
#
|
|
271
|
+
# The command is available in the interactive CLI and through the RPC
|
|
272
|
+
# command bridge. Command names do not include the leading `/`.
|
|
273
|
+
#
|
|
274
|
+
# @param name [String, #to_s] command name without the leading slash
|
|
275
|
+
# @param description [String] short text shown in command listings
|
|
276
|
+
# @param argument_hint [String] optional usage hint for arguments
|
|
277
|
+
# @param schema [Hash, nil] strict object JSON Schema for typed arguments
|
|
278
|
+
# @param positionals [Array<String, Symbol>] schema properties filled by positional text
|
|
279
|
+
# @yieldparam args [String, Hash] raw text for legacy commands or parsed typed arguments
|
|
280
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
281
|
+
# @return [void]
|
|
282
|
+
# @api public
|
|
283
|
+
def command(name, description: "", argument_hint: "", schema: nil, positionals: [], &block)
|
|
284
|
+
@registry.register_command(
|
|
285
|
+
name,
|
|
286
|
+
description: description,
|
|
287
|
+
argument_hint: argument_hint,
|
|
288
|
+
schema: schema,
|
|
289
|
+
positionals: positionals,
|
|
290
|
+
plugin_id: @host&.id,
|
|
291
|
+
path: @path,
|
|
292
|
+
&block
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Registers a namespaced typed action for trusted RPC clients.
|
|
297
|
+
# Identified plugin metadata is required so the action has a stable ID.
|
|
298
|
+
#
|
|
299
|
+
# @param name [String, #to_s] action name within the plugin namespace
|
|
300
|
+
# @param description [String] short client-facing description
|
|
301
|
+
# @param schema [Hash] strict object JSON Schema for typed arguments
|
|
302
|
+
# @yieldparam args [Hash] validated action arguments
|
|
303
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
304
|
+
# @return [void]
|
|
305
|
+
# @api public
|
|
306
|
+
def action(name, description:, schema: { type: "object", properties: {} }, &block)
|
|
307
|
+
raise ArgumentError, "Plugin actions require stable plugin identity" unless @host
|
|
308
|
+
|
|
309
|
+
@registry.register_action(name, plugin_id: @host.id, description: description, schema: schema, path: @path, &block)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Registers a model-callable tool for normal Kward agent turns.
|
|
313
|
+
#
|
|
314
|
+
# Tool arguments are described with a strict object JSON Schema. The
|
|
315
|
+
# handler must return model-facing text and receives the normal plugin
|
|
316
|
+
# context with the active cancellation token.
|
|
317
|
+
#
|
|
318
|
+
# @param name [String, #to_s] function name exposed to the model
|
|
319
|
+
# @param description [String] model-facing description of the operation
|
|
320
|
+
# @param schema [Hash] object JSON Schema for parsed tool arguments
|
|
321
|
+
# @yieldparam args [Hash] parsed model-provided arguments
|
|
322
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
323
|
+
# @return [void]
|
|
324
|
+
# @api public
|
|
325
|
+
def tool(name, description:, schema: { type: "object", properties: {} }, &block)
|
|
326
|
+
@registry.register_tool(name, description: description, schema: schema, path: @path, &block)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Registers a callback invoked after plugin loading when the runtime is
|
|
330
|
+
# ready to start owned background work.
|
|
331
|
+
#
|
|
332
|
+
# @yieldparam host [PluginHost] identified plugin host and resource owner
|
|
333
|
+
# @return [void]
|
|
334
|
+
# @api public
|
|
335
|
+
def on_start(&block)
|
|
336
|
+
register_lifecycle(:start, &block)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Registers a callback invoked on the old plugin instance immediately
|
|
340
|
+
# before its resources are cleaned up during reload.
|
|
341
|
+
#
|
|
342
|
+
# @yieldparam host [PluginHost] identified plugin host and resource owner
|
|
343
|
+
# @return [void]
|
|
344
|
+
# @api public
|
|
345
|
+
def on_reload(&block)
|
|
346
|
+
register_lifecycle(:reload, &block)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Registers a callback invoked immediately before plugin resources are
|
|
350
|
+
# cleaned up during process shutdown.
|
|
351
|
+
#
|
|
352
|
+
# @yieldparam host [PluginHost] identified plugin host and resource owner
|
|
353
|
+
# @return [void]
|
|
354
|
+
# @api public
|
|
355
|
+
def on_shutdown(&block)
|
|
356
|
+
register_lifecycle(:shutdown, &block)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Registers a legacy footer contribution. Multiple plugin footers are
|
|
360
|
+
# composed rather than replacing one another. Identified plugins should
|
|
361
|
+
# prefer {#status} so clients receive a descriptive stable segment ID.
|
|
362
|
+
#
|
|
363
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
364
|
+
# @return [void]
|
|
365
|
+
# @api public
|
|
366
|
+
def footer(&block)
|
|
367
|
+
@registry.register_footer(plugin_id: @host&.id, path: @path, &block)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Registers a composable status contribution.
|
|
371
|
+
#
|
|
372
|
+
# The renderer may return a string, nil to hide the segment, or a hash with
|
|
373
|
+
# `text` and optional `tooltip`. Lower order values render first. On narrow
|
|
374
|
+
# terminals low-priority segments are removed before normal- and
|
|
375
|
+
# high-priority segments.
|
|
376
|
+
#
|
|
377
|
+
# @param name [String, #to_s] stable name within the plugin namespace
|
|
378
|
+
# @param order [Integer] display order; lower values render first
|
|
379
|
+
# @param priority [Symbol, String] `low`, `normal`, or `high`
|
|
380
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
381
|
+
# @return [void]
|
|
382
|
+
# @api public
|
|
383
|
+
def status(name, order: 100, priority: :normal, &block)
|
|
384
|
+
raise ArgumentError, "Plugin status contributions require stable plugin identity" unless @host
|
|
385
|
+
|
|
386
|
+
@registry.register_status(
|
|
387
|
+
name,
|
|
388
|
+
plugin_id: @host.id,
|
|
389
|
+
order: order,
|
|
390
|
+
priority: priority,
|
|
391
|
+
path: @path,
|
|
392
|
+
&block
|
|
393
|
+
)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Registers a live transcript event observer.
|
|
397
|
+
#
|
|
398
|
+
# Observer errors are caught and reported as warnings so a plugin cannot
|
|
399
|
+
# crash the active turn by raising from an event handler.
|
|
400
|
+
#
|
|
401
|
+
# @yieldparam event [TranscriptEvent] normalized transcript event
|
|
402
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
403
|
+
# @return [void]
|
|
404
|
+
# @api public
|
|
405
|
+
def on_transcript_event(&block)
|
|
406
|
+
@registry.register_transcript_event(path: @path, &block)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Registers a lifecycle hook handler.
|
|
410
|
+
#
|
|
411
|
+
# Hooks are deterministic runtime callbacks around Kward lifecycle events.
|
|
412
|
+
# They can return a {Hooks::Decision}, a decision hash, a decision string,
|
|
413
|
+
# or nil to allow the operation.
|
|
414
|
+
#
|
|
415
|
+
# @param event [String, #to_s] lifecycle event name
|
|
416
|
+
# @param id [String, nil] stable hook identifier for logs and diagnostics
|
|
417
|
+
# @param description [String] short human-readable purpose
|
|
418
|
+
# @param order [Integer] lower values run first
|
|
419
|
+
# @param match [Hash, nil] optional event selector
|
|
420
|
+
# @yieldparam event [Hooks::Event] immutable lifecycle event
|
|
421
|
+
# @yieldparam ctx [Context] plugin execution context and decision helpers
|
|
422
|
+
# @return [void]
|
|
423
|
+
# @api public
|
|
424
|
+
def hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, &block)
|
|
425
|
+
@registry.register_hook(event, id: id, description: description, order: order, match: match, failure_policy: failure_policy, path: @path, &block)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Registers prompt context text injected into future system prompts.
|
|
429
|
+
#
|
|
430
|
+
# Keep this text short and never include secrets. The returned string can
|
|
431
|
+
# be sent to the active model as part of Kward's system instructions.
|
|
432
|
+
#
|
|
433
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
434
|
+
# @return [void]
|
|
435
|
+
# @api public
|
|
436
|
+
def prompt_context(&block)
|
|
437
|
+
@registry.register_prompt_context(path: @path, &block)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Registers an interactive command that takes over the composer region with
|
|
441
|
+
# a Kward-driven render and input loop. The handler receives an
|
|
442
|
+
# interactive controller object with a canvas API for drawing colored
|
|
443
|
+
# cells and reading keys. Useful for games, dashboards, and viewers.
|
|
444
|
+
#
|
|
445
|
+
# @param name [String, #to_s] command name without the leading slash
|
|
446
|
+
# @param rows [Integer] fixed canvas height in terminal rows
|
|
447
|
+
# @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
|
|
448
|
+
# @param description [String] short text shown in command listings
|
|
449
|
+
# @param argument_hint [String] optional usage hint for arguments
|
|
450
|
+
# @yieldparam ui [Object] interactive controller with canvas and key API
|
|
451
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
452
|
+
# @return [void]
|
|
453
|
+
# @api public
|
|
454
|
+
def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
|
|
455
|
+
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Registers a plugin-owned chat type. `id` is a durable identifier used
|
|
459
|
+
# in persisted tab layouts and transport chat handles and must not change.
|
|
460
|
+
# The factory receives a `PluginTabHost` and a descriptor hash.
|
|
461
|
+
#
|
|
462
|
+
# @param name [String] command name used by `/tab open <name>`
|
|
463
|
+
# @param id [String] stable persisted tab type identifier
|
|
464
|
+
# @param title [String] default tab label
|
|
465
|
+
# @param singleton [Symbol] `:global` for one shared plugin runtime
|
|
466
|
+
# @param rpc [Boolean] expose this chat through trusted local RPC
|
|
467
|
+
# @param transport [Boolean] allow external transport adapters to target this chat
|
|
468
|
+
# @param local [Boolean] expose this chat as an interactive local tab
|
|
469
|
+
# @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
|
|
470
|
+
# @param api [Integer, nil] versioned plugin-chat contract API
|
|
471
|
+
# @param capabilities [Hash, nil] explicit attachments, steering, and transcript-paging support
|
|
472
|
+
# @yieldparam host [PluginTabHost] supported host dependencies
|
|
473
|
+
# @yieldparam descriptor [Hash] persisted tab descriptor
|
|
474
|
+
# @return [void]
|
|
475
|
+
# @api public
|
|
476
|
+
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, api: nil, capabilities: nil, &block)
|
|
477
|
+
@registry.register_tab_type(
|
|
478
|
+
name,
|
|
479
|
+
id: id,
|
|
480
|
+
title: title,
|
|
481
|
+
singleton: singleton,
|
|
482
|
+
rpc: rpc,
|
|
483
|
+
transport: transport,
|
|
484
|
+
local: local,
|
|
485
|
+
transcript_events: transcript_events,
|
|
486
|
+
api: api,
|
|
487
|
+
capabilities: capabilities,
|
|
488
|
+
plugin_id: @host&.id,
|
|
489
|
+
path: @path,
|
|
490
|
+
&block
|
|
491
|
+
)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Registers an external messaging or event transport. The factory is
|
|
495
|
+
# called when the transport runtime starts.
|
|
496
|
+
#
|
|
497
|
+
# @param name [String] human-readable transport name
|
|
498
|
+
# @param id [String] stable transport identifier
|
|
499
|
+
# @param capabilities [Hash, Transport::Capabilities] supported features
|
|
500
|
+
# @yieldparam host [Object] transport host
|
|
501
|
+
# @yieldparam config [Object] transport configuration
|
|
502
|
+
# @return [void]
|
|
503
|
+
# @api public
|
|
504
|
+
def transport(name, id:, capabilities: nil, execution_profile: nil, &block)
|
|
505
|
+
@registry.register_transport(name, id: id, capabilities: capabilities, execution_profile: execution_profile, path: @path, &block)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
private
|
|
509
|
+
|
|
510
|
+
def register_lifecycle(event, &block)
|
|
511
|
+
raise ArgumentError, "Plugin lifecycle callbacks require stable plugin identity" unless @host
|
|
512
|
+
|
|
513
|
+
@registry.register_lifecycle(event, host: @host, path: @path, &block)
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Mutable singleton guard used while loading trusted plugin files.
|
|
518
|
+
class << self
|
|
519
|
+
attr_accessor :loading_registry, :loading_path
|
|
520
|
+
|
|
521
|
+
def load(paths: nil, reserved_commands: [], warning_sink: nil)
|
|
522
|
+
warning_sink ||= ConfigFiles.warning_sink
|
|
523
|
+
paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
|
|
524
|
+
registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
|
|
525
|
+
paths.each { |path| registry.load_file(path) }
|
|
526
|
+
registry
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Creates an object for trusted plugin loading and dispatch.
|
|
531
|
+
def initialize(reserved_commands: [], warning_sink: nil)
|
|
532
|
+
@reserved_commands = reserved_commands.map(&:to_s)
|
|
533
|
+
@warning_sink = warning_sink
|
|
534
|
+
@plugins = {}
|
|
535
|
+
@commands = {}
|
|
536
|
+
@actions = {}
|
|
537
|
+
@tools = {}
|
|
538
|
+
@interactive_commands = {}
|
|
539
|
+
@tab_types = {}
|
|
540
|
+
@tab_types_by_id = {}
|
|
541
|
+
@transports = {}
|
|
542
|
+
@transports_by_id = {}
|
|
543
|
+
@statuses = {}
|
|
544
|
+
@status_sequence = 0
|
|
545
|
+
@footer_path = nil
|
|
546
|
+
@transcript_event_handlers = []
|
|
547
|
+
@prompt_context_renderers = []
|
|
548
|
+
@hook_handlers = []
|
|
549
|
+
@lifecycle_handlers = { start: [], reload: [], shutdown: [] }
|
|
550
|
+
@lifecycle_state = :loaded
|
|
551
|
+
@lifecycle_mutex = Mutex.new
|
|
552
|
+
@paths = []
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# @return [String, nil] most recent plugin file to register legacy footer output
|
|
556
|
+
attr_reader :footer_path
|
|
557
|
+
|
|
558
|
+
# @return [Array<String>] plugin files successfully loaded by this registry
|
|
559
|
+
attr_reader :paths
|
|
560
|
+
|
|
561
|
+
# @return [Array<PluginHost>] identified plugins loaded by this registry
|
|
562
|
+
def plugins
|
|
563
|
+
@plugins.values
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def plugin_for(id)
|
|
567
|
+
@plugins[id.to_s]
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def commands
|
|
571
|
+
@commands.values
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def command_for(name)
|
|
575
|
+
@commands[name.to_s]
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def actions
|
|
579
|
+
@actions.values
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def action_for(id)
|
|
583
|
+
@actions[id.to_s]
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def tools
|
|
587
|
+
@tools.values
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def tool_for(name)
|
|
591
|
+
@tools[name.to_s]
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def interactive_commands
|
|
595
|
+
@interactive_commands.values
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def interactive_command_for(name)
|
|
599
|
+
@interactive_commands[name.to_s]
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def tab_types
|
|
603
|
+
@tab_types.values
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def tab_type_for(name)
|
|
607
|
+
@tab_types[name.to_s]
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def tab_type_for_id(id)
|
|
611
|
+
@tab_types_by_id[id.to_s]
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def transport_tab_types
|
|
615
|
+
@tab_types.values.select(&:transport)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def transports
|
|
619
|
+
@transports.values
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def transport_for(name)
|
|
623
|
+
@transports[name.to_s]
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def transport_for_id(id)
|
|
627
|
+
@transports_by_id[id.to_s]
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def status?
|
|
631
|
+
!@statuses.empty?
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Backward-compatible aggregate footer renderer.
|
|
635
|
+
def footer_renderer
|
|
636
|
+
return nil unless status?
|
|
637
|
+
|
|
638
|
+
lambda do |context|
|
|
639
|
+
compose_status(status_segments(context))
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Evaluates every status renderer independently and returns display-ordered
|
|
644
|
+
# frontend-neutral segments. A broken renderer cannot hide healthy segments.
|
|
645
|
+
def status_segments(context)
|
|
646
|
+
@statuses.values.sort_by { |status| [status.order, status.sequence] }.filter_map do |status|
|
|
647
|
+
normalize_status_segment(status, status.renderer.call(context))
|
|
648
|
+
rescue StandardError => e
|
|
649
|
+
emit_warning "Warning: Kward plugin status #{status.id} error in #{status.path}: #{e.message}"
|
|
650
|
+
nil
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# Composes already-rendered segments. When max_width is supplied, complete
|
|
655
|
+
# low-priority segments are removed first; the caller remains responsible
|
|
656
|
+
# for truncating a final oversized segment according to frontend rules.
|
|
657
|
+
def compose_status(segments, max_width: nil, &measure)
|
|
658
|
+
visible = Array(segments).dup
|
|
659
|
+
return "" if visible.empty? || (!max_width.nil? && max_width.to_i <= 0)
|
|
660
|
+
|
|
661
|
+
measure ||= ->(text) { text.to_s.length }
|
|
662
|
+
width = max_width&.to_i
|
|
663
|
+
while width && visible.length > 1 && measure.call(status_text(visible)) > width
|
|
664
|
+
remove_lowest_priority_segment!(visible)
|
|
665
|
+
end
|
|
666
|
+
status_text(visible)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def transcript_event_handlers
|
|
670
|
+
@transcript_event_handlers.map { |entry| entry[:handler] }
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def prompt_context_renderers
|
|
674
|
+
@prompt_context_renderers.map { |entry| entry[:renderer] }
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def hook_handlers
|
|
678
|
+
@hook_handlers.dup
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Activates identified plugins and invokes their start callbacks once.
|
|
682
|
+
def start!
|
|
683
|
+
transition_lifecycle!(:loaded, :active) do
|
|
684
|
+
@plugins.each_value(&:activate!)
|
|
685
|
+
run_lifecycle_callbacks(:start)
|
|
686
|
+
end
|
|
687
|
+
self
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Invokes reload callbacks on the old registry and cleans up all resources.
|
|
691
|
+
def reload!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
|
|
692
|
+
stop_lifecycle!(:reload, timeout: timeout)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Invokes shutdown callbacks and cleans up all resources.
|
|
696
|
+
def shutdown!(timeout: PluginResources::DEFAULT_SHUTDOWN_TIMEOUT)
|
|
697
|
+
stop_lifecycle!(:shutdown, timeout: timeout)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def hook_manager
|
|
701
|
+
manager = Hooks::Manager.new
|
|
702
|
+
@hook_handlers.each do |hook|
|
|
703
|
+
manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
|
|
704
|
+
hook.handler.call(event, context)
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
manager
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def prompt_context(context)
|
|
711
|
+
parts = []
|
|
712
|
+
@prompt_context_renderers.each do |entry|
|
|
713
|
+
rendered = entry[:renderer].call(context)
|
|
714
|
+
parts << rendered.to_s unless rendered.to_s.empty?
|
|
715
|
+
rescue StandardError => e
|
|
716
|
+
emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
|
|
717
|
+
end
|
|
718
|
+
parts.empty? ? nil : parts.join("\n\n")
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def notify_transcript_event(event, context)
|
|
722
|
+
transcript_event = transcript_event_for(event)
|
|
723
|
+
return unless transcript_event
|
|
724
|
+
|
|
725
|
+
@transcript_event_handlers.each do |entry|
|
|
726
|
+
entry[:handler].call(transcript_event, context)
|
|
727
|
+
rescue StandardError => e
|
|
728
|
+
emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
|
|
729
|
+
end
|
|
730
|
+
nil
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def load_file(path)
|
|
734
|
+
previous_registry = self.class.loading_registry
|
|
735
|
+
previous_path = self.class.loading_path
|
|
736
|
+
self.class.loading_registry = self
|
|
737
|
+
self.class.loading_path = path
|
|
738
|
+
Kernel.load(path, true)
|
|
739
|
+
@paths << path
|
|
740
|
+
rescue StandardError => e
|
|
741
|
+
emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
|
|
742
|
+
ensure
|
|
743
|
+
self.class.loading_registry = previous_registry
|
|
744
|
+
self.class.loading_path = previous_path
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def evaluate(path: nil, id: nil, version: nil, api: nil, &block)
|
|
748
|
+
host = register_plugin_identity(id: id, version: version, api: api, path: path)
|
|
749
|
+
dsl = DSL.new(self, path, host: host)
|
|
750
|
+
block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
|
|
751
|
+
self
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def register_plugin_identity(id:, version:, api:, path: nil)
|
|
755
|
+
values = [id, version, api]
|
|
756
|
+
return nil if values.all?(&:nil?)
|
|
757
|
+
raise ArgumentError, "Plugin id, version, and api are required together" if values.any?(&:nil?)
|
|
758
|
+
|
|
759
|
+
id = id.to_s
|
|
760
|
+
api = api.to_s
|
|
761
|
+
raise ArgumentError, "Unsupported Kward plugin API #{api.inspect} for #{id}; supported API: #{PLUGIN_API_VERSION}" unless api == PLUGIN_API_VERSION
|
|
762
|
+
raise ArgumentError, "Duplicate Kward plugin id: #{id}" if @plugins.key?(id)
|
|
763
|
+
|
|
764
|
+
@plugins[id] = PluginHost.new(
|
|
765
|
+
id: id,
|
|
766
|
+
version: version,
|
|
767
|
+
api_version: api,
|
|
768
|
+
source_path: path,
|
|
769
|
+
warning_sink: method(:emit_warning)
|
|
770
|
+
)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def register_command(name, description: "", argument_hint: "", schema: nil, positionals: [], plugin_id: nil, path: nil, &handler)
|
|
774
|
+
name = name.to_s
|
|
775
|
+
raise "Plugin command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
776
|
+
raise "Plugin command /#{name} requires a handler" unless handler
|
|
777
|
+
raise ArgumentError, "Plugin command /#{name} positionals require a schema" if schema.nil? && !Array(positionals).empty?
|
|
778
|
+
|
|
779
|
+
if @reserved_commands.include?(name)
|
|
780
|
+
emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
|
|
781
|
+
return nil
|
|
782
|
+
end
|
|
783
|
+
if @commands.key?(name)
|
|
784
|
+
emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
|
|
785
|
+
return nil
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
@commands[name] = Command.new(
|
|
789
|
+
name: name,
|
|
790
|
+
description: description.to_s,
|
|
791
|
+
argument_hint: argument_hint.to_s,
|
|
792
|
+
schema: schema,
|
|
793
|
+
positionals: positionals,
|
|
794
|
+
plugin_id: plugin_id,
|
|
795
|
+
path: path,
|
|
796
|
+
handler: handler
|
|
797
|
+
)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def register_action(name, plugin_id:, description:, schema:, path: nil, &handler)
|
|
801
|
+
name = name.to_s
|
|
802
|
+
raise "Plugin action name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
803
|
+
raise "Plugin action #{plugin_id}/#{name} requires a description" if description.to_s.strip.empty?
|
|
804
|
+
raise "Plugin action #{plugin_id}/#{name} requires a handler" unless handler
|
|
805
|
+
|
|
806
|
+
id = "#{plugin_id}/#{name}"
|
|
807
|
+
if @actions.key?(id)
|
|
808
|
+
emit_warning "Warning: skipping duplicate Kward plugin action #{id}: #{path}"
|
|
809
|
+
return nil
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
@actions[id] = Action.new(
|
|
813
|
+
name: name,
|
|
814
|
+
plugin_id: plugin_id,
|
|
815
|
+
description: description.to_s,
|
|
816
|
+
schema: schema,
|
|
817
|
+
path: path,
|
|
818
|
+
handler: handler
|
|
819
|
+
)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def register_tool(name, description:, schema:, path: nil, &handler)
|
|
823
|
+
name = name.to_s
|
|
824
|
+
raise "Plugin tool name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
825
|
+
raise "Plugin tool #{name} requires a description" if description.to_s.strip.empty?
|
|
826
|
+
raise "Plugin tool #{name} requires a handler" unless handler
|
|
827
|
+
|
|
828
|
+
if @tools.key?(name)
|
|
829
|
+
emit_warning "Warning: skipping duplicate Kward plugin tool #{name}: #{path}"
|
|
830
|
+
return nil
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
@tools[name] = Tool.new(
|
|
834
|
+
name: name,
|
|
835
|
+
description: description.to_s,
|
|
836
|
+
schema: normalize_tool_schema(name, schema),
|
|
837
|
+
path: path,
|
|
838
|
+
handler: handler
|
|
839
|
+
)
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
|
|
843
|
+
name = name.to_s
|
|
844
|
+
raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
845
|
+
raise "Interactive command /#{name} requires a handler" unless handler
|
|
846
|
+
|
|
847
|
+
if @reserved_commands.include?(name) || @commands.key?(name)
|
|
848
|
+
emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
|
|
849
|
+
return nil
|
|
850
|
+
end
|
|
851
|
+
if @interactive_commands.key?(name)
|
|
852
|
+
emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
|
|
853
|
+
return nil
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
@interactive_commands[name] = InteractiveCommand.new(
|
|
857
|
+
name: name,
|
|
858
|
+
description: description.to_s,
|
|
859
|
+
argument_hint: argument_hint.to_s,
|
|
860
|
+
rows: [[rows.to_i, 1].max, 1].max,
|
|
861
|
+
fps: [[fps.to_f, 1].max, 120].min,
|
|
862
|
+
path: path,
|
|
863
|
+
handler: handler
|
|
864
|
+
)
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, api: nil, capabilities: nil, plugin_id: nil, path: nil, &handler)
|
|
868
|
+
name = name.to_s
|
|
869
|
+
id = id.to_s
|
|
870
|
+
raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
871
|
+
raise "Plugin tab type id is required" if id.empty?
|
|
872
|
+
raise "Plugin tab type #{name} requires a handler" unless handler
|
|
873
|
+
|
|
874
|
+
if @tab_types.key?(name) || @tab_types_by_id.key?(id)
|
|
875
|
+
emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
|
|
876
|
+
return nil
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
tab_type = TabType.new(
|
|
880
|
+
id: id,
|
|
881
|
+
name: name,
|
|
882
|
+
title: title.to_s.empty? ? name.capitalize : title.to_s,
|
|
883
|
+
singleton: singleton&.to_sym,
|
|
884
|
+
rpc: rpc == true,
|
|
885
|
+
transport: transport == true,
|
|
886
|
+
local: local == true,
|
|
887
|
+
transcript_events: transcript_events == true,
|
|
888
|
+
capabilities: PluginChatCapabilities.build(api: api, capabilities: capabilities),
|
|
889
|
+
plugin_id: plugin_id,
|
|
890
|
+
path: path,
|
|
891
|
+
handler: handler
|
|
892
|
+
)
|
|
893
|
+
@tab_types[name] = tab_type
|
|
894
|
+
@tab_types_by_id[id] = tab_type
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
|
|
898
|
+
name = name.to_s
|
|
899
|
+
id = id.to_s
|
|
900
|
+
raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
901
|
+
raise "Plugin transport id is required" if id.empty?
|
|
902
|
+
raise "Plugin transport #{name} requires a handler" unless handler
|
|
903
|
+
|
|
904
|
+
if @transports.key?(name) || @transports_by_id.key?(id)
|
|
905
|
+
emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
|
|
906
|
+
return nil
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
capabilities = normalize_transport_capabilities(capabilities)
|
|
910
|
+
execution_profile = normalize_execution_profile(execution_profile)
|
|
911
|
+
transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
|
|
912
|
+
@transports[name] = transport
|
|
913
|
+
@transports_by_id[id] = transport
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def register_footer(plugin_id: nil, path: nil, &renderer)
|
|
917
|
+
raise "Plugin footer requires a renderer" unless renderer
|
|
918
|
+
|
|
919
|
+
id = if plugin_id
|
|
920
|
+
"#{plugin_id}/footer"
|
|
921
|
+
else
|
|
922
|
+
available_legacy_status_id("legacy/#{legacy_status_source(path)}")
|
|
923
|
+
end
|
|
924
|
+
@status_sequence += 1 unless @statuses.key?(id)
|
|
925
|
+
@statuses[id] = Status.new(
|
|
926
|
+
id: id,
|
|
927
|
+
order: 100,
|
|
928
|
+
priority: :normal,
|
|
929
|
+
path: path,
|
|
930
|
+
renderer: renderer,
|
|
931
|
+
sequence: @statuses[id]&.sequence || @status_sequence
|
|
932
|
+
)
|
|
933
|
+
@footer_path = path
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def register_status(name, plugin_id:, order: 100, priority: :normal, path: nil, &renderer)
|
|
937
|
+
name = name.to_s
|
|
938
|
+
raise "Plugin status name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
939
|
+
raise "Plugin status #{plugin_id}/#{name} requires a renderer" unless renderer
|
|
940
|
+
|
|
941
|
+
id = "#{plugin_id}/#{name}"
|
|
942
|
+
if @statuses.key?(id)
|
|
943
|
+
emit_warning "Warning: skipping duplicate Kward plugin status #{id}: #{path}"
|
|
944
|
+
return nil
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
order = Integer(order)
|
|
948
|
+
priority = normalize_status_priority(priority)
|
|
949
|
+
@status_sequence += 1
|
|
950
|
+
@statuses[id] = Status.new(
|
|
951
|
+
id: id,
|
|
952
|
+
order: order,
|
|
953
|
+
priority: priority,
|
|
954
|
+
path: path,
|
|
955
|
+
renderer: renderer,
|
|
956
|
+
sequence: @status_sequence
|
|
957
|
+
)
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def emit_warning(message)
|
|
961
|
+
@warning_sink ? @warning_sink.call(message) : warn(message)
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
def register_transcript_event(path: nil, &handler)
|
|
965
|
+
raise "Plugin transcript event requires a handler" unless handler
|
|
966
|
+
|
|
967
|
+
@transcript_event_handlers << { path: path, handler: handler }
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
def register_prompt_context(path: nil, &renderer)
|
|
971
|
+
raise "Plugin prompt context requires a renderer" unless renderer
|
|
972
|
+
|
|
973
|
+
@prompt_context_renderers << { path: path, renderer: renderer }
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
def register_lifecycle(event, host:, path: nil, &handler)
|
|
977
|
+
event = event.to_sym
|
|
978
|
+
raise ArgumentError, "Unknown plugin lifecycle event: #{event}" unless @lifecycle_handlers.key?(event)
|
|
979
|
+
raise ArgumentError, "Plugin lifecycle #{event} requires a handler" unless handler
|
|
980
|
+
|
|
981
|
+
@lifecycle_handlers[event] << LifecycleHandler.new(event: event, host: host, path: path, handler: handler)
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
|
|
985
|
+
event = event.to_s
|
|
986
|
+
raise "Plugin hook event is required" if event.empty?
|
|
987
|
+
raise "Plugin hook #{event} requires a handler" unless handler
|
|
988
|
+
|
|
989
|
+
@hook_handlers << HookHandler.new(
|
|
990
|
+
event: event,
|
|
991
|
+
id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
|
|
992
|
+
description: description.to_s,
|
|
993
|
+
path: path,
|
|
994
|
+
order: order.to_i,
|
|
995
|
+
match: match,
|
|
996
|
+
failure_policy: failure_policy,
|
|
997
|
+
handler: handler
|
|
998
|
+
)
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
private
|
|
1002
|
+
|
|
1003
|
+
def normalize_status_priority(priority)
|
|
1004
|
+
value = priority.to_s.to_sym
|
|
1005
|
+
return value if STATUS_PRIORITIES.key?(value)
|
|
1006
|
+
|
|
1007
|
+
raise ArgumentError, "Plugin status priority must be low, normal, or high"
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def normalize_status_segment(status, value)
|
|
1011
|
+
attributes = value.is_a?(Hash) ? value : { text: value }
|
|
1012
|
+
text = status_value(attributes, :text).to_s.gsub(/\s+/, " ").strip
|
|
1013
|
+
return nil if text.empty?
|
|
1014
|
+
|
|
1015
|
+
tooltip = status_value(attributes, :tooltip)
|
|
1016
|
+
tooltip = tooltip.to_s.gsub(/\s+/, " ").strip unless tooltip.nil?
|
|
1017
|
+
tooltip = nil if tooltip.to_s.empty?
|
|
1018
|
+
StatusSegment.new(
|
|
1019
|
+
id: status.id.dup.freeze,
|
|
1020
|
+
text: text.freeze,
|
|
1021
|
+
tooltip: tooltip&.freeze,
|
|
1022
|
+
priority: status.priority,
|
|
1023
|
+
order: status.order
|
|
1024
|
+
).freeze
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
def status_value(attributes, key)
|
|
1028
|
+
attributes.key?(key) ? attributes[key] : attributes[key.to_s]
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def status_text(segments)
|
|
1032
|
+
segments.map(&:text).join(STATUS_SEPARATOR)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def remove_lowest_priority_segment!(segments)
|
|
1036
|
+
lowest_priority = segments.map { |segment| STATUS_PRIORITIES.fetch(segment.priority) }.min
|
|
1037
|
+
index = segments.each_index.select do |candidate|
|
|
1038
|
+
STATUS_PRIORITIES.fetch(segments[candidate].priority) == lowest_priority
|
|
1039
|
+
end.max_by { |candidate| [segments[candidate].order, candidate] }
|
|
1040
|
+
segments.delete_at(index)
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def available_legacy_status_id(base_id)
|
|
1044
|
+
return base_id unless @statuses.key?(base_id)
|
|
1045
|
+
|
|
1046
|
+
suffix = 2
|
|
1047
|
+
suffix += 1 while @statuses.key?("#{base_id}##{suffix}")
|
|
1048
|
+
"#{base_id}##{suffix}"
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def legacy_status_source(path)
|
|
1052
|
+
source = path.to_s
|
|
1053
|
+
return "plugin" if source.empty?
|
|
1054
|
+
|
|
1055
|
+
basename = File.basename(source)
|
|
1056
|
+
basename == "plugin.rb" ? File.basename(File.dirname(source)) : File.basename(source, ".rb")
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def transition_lifecycle!(from, to)
|
|
1060
|
+
should_run = @lifecycle_mutex.synchronize do
|
|
1061
|
+
next false unless @lifecycle_state == from
|
|
1062
|
+
|
|
1063
|
+
@lifecycle_state = to
|
|
1064
|
+
true
|
|
1065
|
+
end
|
|
1066
|
+
yield if should_run
|
|
1067
|
+
end
|
|
1068
|
+
|
|
1069
|
+
def stop_lifecycle!(event, timeout:)
|
|
1070
|
+
transition_lifecycle!(:active, :stopped) do
|
|
1071
|
+
run_lifecycle_callbacks(event)
|
|
1072
|
+
@plugins.each_value { |host| host.shutdown(timeout: timeout) }
|
|
1073
|
+
end
|
|
1074
|
+
self
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
def run_lifecycle_callbacks(event)
|
|
1078
|
+
@lifecycle_handlers.fetch(event).each do |entry|
|
|
1079
|
+
entry.handler.arity.zero? ? entry.handler.call : entry.handler.call(entry.host)
|
|
1080
|
+
rescue StandardError => e
|
|
1081
|
+
emit_warning "Warning: Kward plugin #{event} error in #{entry.path}: #{e.message}"
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
def normalize_tool_schema(name, schema)
|
|
1086
|
+
raise ArgumentError, "Plugin tool #{name} schema must be an object" unless schema.is_a?(Hash)
|
|
1087
|
+
|
|
1088
|
+
parameters = schema.each_with_object({}) { |(key, value), result| result[key.to_sym] = DeepCopy.dup(value) }
|
|
1089
|
+
type = parameters.fetch(:type, "object").to_s
|
|
1090
|
+
raise ArgumentError, "Plugin tool #{name} schema type must be object" unless type == "object"
|
|
1091
|
+
|
|
1092
|
+
properties = parameters.fetch(:properties, {})
|
|
1093
|
+
required = parameters.fetch(:required, [])
|
|
1094
|
+
raise ArgumentError, "Plugin tool #{name} schema properties must be an object" unless properties.is_a?(Hash)
|
|
1095
|
+
raise ArgumentError, "Plugin tool #{name} schema required must be an array" unless required.is_a?(Array)
|
|
1096
|
+
if parameters[:additionalProperties] == true
|
|
1097
|
+
raise ArgumentError, "Plugin tool #{name} schema cannot allow additional properties"
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
property_names = properties.keys.map(&:to_s)
|
|
1101
|
+
required = required.map(&:to_s).uniq.sort
|
|
1102
|
+
unknown_required = required - property_names
|
|
1103
|
+
unless unknown_required.empty?
|
|
1104
|
+
raise ArgumentError, "Plugin tool #{name} schema requires unknown properties: #{unknown_required.join(', ')}"
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
parameters[:type] = "object"
|
|
1108
|
+
parameters[:properties] = properties.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
|
|
1109
|
+
result[key] = properties[key]
|
|
1110
|
+
end
|
|
1111
|
+
parameters[:required] = required
|
|
1112
|
+
parameters[:additionalProperties] = false
|
|
1113
|
+
DeepCopy.freeze(parameters)
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def normalize_execution_profile(profile)
|
|
1117
|
+
return nil if profile.nil?
|
|
1118
|
+
return profile if profile.is_a?(Transport::ExecutionProfile)
|
|
1119
|
+
raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
def normalize_transport_capabilities(capabilities)
|
|
1123
|
+
return Transport.capabilities if capabilities.nil?
|
|
1124
|
+
return capabilities if capabilities.is_a?(Transport::Capabilities)
|
|
1125
|
+
raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
|
|
1126
|
+
|
|
1127
|
+
Transport.capabilities(**capabilities.transform_keys(&:to_sym))
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
def transcript_event_for(event)
|
|
1131
|
+
case event.class.name
|
|
1132
|
+
when "Kward::Events::ReasoningDelta"
|
|
1133
|
+
transcript_event("reasoning_delta", delta: event.delta)
|
|
1134
|
+
when "Kward::Events::ReasoningBoundary"
|
|
1135
|
+
transcript_event("reasoning_boundary")
|
|
1136
|
+
when "Kward::Events::AssistantDelta"
|
|
1137
|
+
transcript_event("assistant_delta", delta: event.delta)
|
|
1138
|
+
when "Kward::Events::AssistantMessage"
|
|
1139
|
+
transcript_event("assistant_message", message: event.message)
|
|
1140
|
+
when "Kward::Events::Retry"
|
|
1141
|
+
transcript_event(
|
|
1142
|
+
"model_retry",
|
|
1143
|
+
provider: event.provider,
|
|
1144
|
+
model: event.model,
|
|
1145
|
+
attempt: event.attempt,
|
|
1146
|
+
max_attempts: event.max_attempts,
|
|
1147
|
+
delay_seconds: event.delay_seconds,
|
|
1148
|
+
error: event.error,
|
|
1149
|
+
request_bytes: event.request_bytes
|
|
1150
|
+
)
|
|
1151
|
+
when "Kward::Events::Steering"
|
|
1152
|
+
transcript_event("turn_steered", input: event.input, created_at: event.created_at)
|
|
1153
|
+
when "Kward::Events::ToolCall"
|
|
1154
|
+
transcript_event("tool_call", tool_call: event.tool_call)
|
|
1155
|
+
when "Kward::Events::ToolResult"
|
|
1156
|
+
transcript_event("tool_result", tool_call: event.tool_call, content: event.content)
|
|
1157
|
+
when "Kward::Events::Answer"
|
|
1158
|
+
transcript_event("answer", content: event.content)
|
|
1159
|
+
end
|
|
1160
|
+
end
|
|
1161
|
+
|
|
1162
|
+
def transcript_event(type, payload = {})
|
|
1163
|
+
TranscriptEvent.new(
|
|
1164
|
+
type: type,
|
|
1165
|
+
payload: DeepCopy.freeze(DeepCopy.dup(payload))
|
|
1166
|
+
).freeze
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# Registers a trusted local plugin.
|
|
1171
|
+
#
|
|
1172
|
+
# This method is intended for Ruby files loaded from the user plugin
|
|
1173
|
+
# directory. It raises if called outside plugin loading so workspace code
|
|
1174
|
+
# cannot silently mutate Kward's runtime by merely being required.
|
|
1175
|
+
#
|
|
1176
|
+
# @param id [String, nil] stable reverse-domain-style plugin identifier
|
|
1177
|
+
# @param version [String, nil] plugin release version
|
|
1178
|
+
# @param api [String, Integer, nil] Kward plugin API version
|
|
1179
|
+
# @yieldparam plugin [PluginRegistry::DSL] plugin registration DSL
|
|
1180
|
+
# @return [Object, nil] the plugin block result
|
|
1181
|
+
# @api public
|
|
1182
|
+
def self.plugin(id: nil, version: nil, api: nil, &block)
|
|
1183
|
+
registry = PluginRegistry.loading_registry
|
|
1184
|
+
raise "Kward.plugin can only be called while loading a plugin" unless registry
|
|
1185
|
+
|
|
1186
|
+
host = registry.register_plugin_identity(id: id, version: version, api: api, path: PluginRegistry.loading_path)
|
|
1187
|
+
dsl = PluginRegistry::DSL.new(registry, PluginRegistry.loading_path, host: host)
|
|
1188
|
+
block.arity == 1 ? block.call(dsl) : dsl.instance_eval(&block)
|
|
1189
|
+
end
|
|
1190
|
+
end
|