kward 0.79.0 → 0.80.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/.github/workflows/ci.yml +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "cancellation"
|
|
5
|
+
require_relative "events"
|
|
6
|
+
require_relative "plugin_registry"
|
|
7
|
+
require_relative "tab_driver"
|
|
8
|
+
require_relative "tools/tool_call"
|
|
9
|
+
|
|
10
|
+
# Frontend-neutral runtime for trusted plugin-owned conversational drivers.
|
|
11
|
+
module Kward
|
|
12
|
+
# Coordinates plugin chat drivers without depending on RPC or the terminal UI.
|
|
13
|
+
# Frontends use this runtime for queuing, cancellation, event replay, and
|
|
14
|
+
# transcript access while the plugin remains responsible for its own storage,
|
|
15
|
+
# model settings, and domain behavior.
|
|
16
|
+
class PluginChatRuntime
|
|
17
|
+
EVENT_LIMIT = 1_000
|
|
18
|
+
WORKER_STOP = Object.new.freeze
|
|
19
|
+
|
|
20
|
+
Chat = Struct.new(
|
|
21
|
+
:id,
|
|
22
|
+
:type,
|
|
23
|
+
:driver,
|
|
24
|
+
:queue,
|
|
25
|
+
:worker,
|
|
26
|
+
:running_turn_id,
|
|
27
|
+
:scope_key,
|
|
28
|
+
:descriptor,
|
|
29
|
+
:workspace_root,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
)
|
|
32
|
+
Turn = Struct.new(
|
|
33
|
+
:id,
|
|
34
|
+
:chat_id,
|
|
35
|
+
:input,
|
|
36
|
+
:display_input,
|
|
37
|
+
:context,
|
|
38
|
+
:status,
|
|
39
|
+
:cancellation,
|
|
40
|
+
:created_at,
|
|
41
|
+
:started_at,
|
|
42
|
+
:finished_at,
|
|
43
|
+
:events,
|
|
44
|
+
:next_sequence,
|
|
45
|
+
:error,
|
|
46
|
+
:mutex,
|
|
47
|
+
keyword_init: true
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def initialize(client:, plugin_registry_provider: nil, message_normalizer: nil)
|
|
51
|
+
@client = client
|
|
52
|
+
@plugin_registry_provider = plugin_registry_provider
|
|
53
|
+
@message_normalizer = message_normalizer
|
|
54
|
+
@chats = {}
|
|
55
|
+
@turns = {}
|
|
56
|
+
@event_listeners = []
|
|
57
|
+
@mutex = Mutex.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def supported_types(surface: :rpc)
|
|
61
|
+
case surface.to_sym
|
|
62
|
+
when :rpc
|
|
63
|
+
plugin_registry.tab_types.select(&:rpc)
|
|
64
|
+
when :transport
|
|
65
|
+
plugin_registry.transport_tab_types
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "Unknown plugin chat surface: #{surface}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def subscribe_events(&block)
|
|
72
|
+
raise ArgumentError, "plugin chat event subscription requires a block" unless block
|
|
73
|
+
|
|
74
|
+
@mutex.synchronize { @event_listeners << block }
|
|
75
|
+
block
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def open(type_id:, surface: :rpc, scope_key: nil, descriptor: {}, workspace_root: Dir.pwd)
|
|
79
|
+
type = supported_types(surface: surface).find { |entry| entry.id == type_id.to_s }
|
|
80
|
+
raise ArgumentError, "Unknown #{surface} plugin chat: #{type_id}" unless type
|
|
81
|
+
|
|
82
|
+
chat_for(type, scope_key: scope_key, descriptor: descriptor, workspace_root: workspace_root)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def chat(chat_id)
|
|
86
|
+
@mutex.synchronize { @chats[chat_id.to_s] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start_turn(chat_id:, input:, display_input: nil, context: nil)
|
|
90
|
+
chat = fetch_chat(chat_id)
|
|
91
|
+
turn = Turn.new(
|
|
92
|
+
id: SecureRandom.uuid,
|
|
93
|
+
chat_id: chat.id,
|
|
94
|
+
input: input,
|
|
95
|
+
display_input: display_input.nil? ? input.to_s : display_input.to_s,
|
|
96
|
+
context: context || {},
|
|
97
|
+
status: "queued",
|
|
98
|
+
cancellation: Cancellation.new,
|
|
99
|
+
created_at: Time.now.utc.iso8601(3),
|
|
100
|
+
events: [],
|
|
101
|
+
next_sequence: 1,
|
|
102
|
+
mutex: Mutex.new
|
|
103
|
+
)
|
|
104
|
+
@mutex.synchronize { @turns[turn.id] = turn }
|
|
105
|
+
chat.queue << turn.id
|
|
106
|
+
ensure_worker(chat)
|
|
107
|
+
emit_event(turn, "turnQueued", { status: "queued" })
|
|
108
|
+
turn
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cancel_turn(turn_id:)
|
|
112
|
+
turn = fetch_turn(turn_id)
|
|
113
|
+
queued = turn.mutex.synchronize do
|
|
114
|
+
turn.cancellation.cancel!
|
|
115
|
+
turn.status == "queued"
|
|
116
|
+
end
|
|
117
|
+
emit_event(turn, "turnCancelRequested", {})
|
|
118
|
+
finish_turn(turn, "canceled") if queued
|
|
119
|
+
turn
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def turn_status(turn_id:)
|
|
123
|
+
fetch_turn(turn_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def turn_events(turn_id:, after_sequence: 0)
|
|
127
|
+
turn = fetch_turn(turn_id)
|
|
128
|
+
turn.mutex.synchronize do
|
|
129
|
+
turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def list_turns(chat_id: nil, active: false)
|
|
134
|
+
turns = @mutex.synchronize { @turns.values.dup }
|
|
135
|
+
turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
|
|
136
|
+
turns.select! { |turn| %w[queued running].include?(turn.status) } if active
|
|
137
|
+
turns
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def shutdown
|
|
141
|
+
chats = @mutex.synchronize { @chats.values.dup }
|
|
142
|
+
chats.each do |chat|
|
|
143
|
+
chat.queue << WORKER_STOP if chat.worker&.alive?
|
|
144
|
+
chat.worker&.join(0.2)
|
|
145
|
+
end
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Converts normalized image attachment hashes into the input shape accepted
|
|
150
|
+
# by plugin chat drivers. RPC and transport frontends can normalize their
|
|
151
|
+
# own boundary formats before calling this helper.
|
|
152
|
+
def input_with_attachments(input, attachments)
|
|
153
|
+
attachments = Array(attachments)
|
|
154
|
+
return input.to_s if attachments.empty?
|
|
155
|
+
|
|
156
|
+
[{ type: "text", text: input.to_s }] + attachments.map do |attachment|
|
|
157
|
+
{
|
|
158
|
+
type: "image",
|
|
159
|
+
media_type: attachment.fetch(:mimeType) { attachment.fetch("mimeType") },
|
|
160
|
+
data: attachment.fetch(:data) { attachment.fetch("data") },
|
|
161
|
+
alt: attachment[:alt] || attachment["alt"]
|
|
162
|
+
}.compact
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def plugin_registry
|
|
169
|
+
return @plugin_registry_provider.call if @plugin_registry_provider
|
|
170
|
+
|
|
171
|
+
@plugin_registry ||= PluginRegistry.load
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def chat_for(type, scope_key:, descriptor:, workspace_root:)
|
|
175
|
+
scope_key = normalize_scope_key(scope_key)
|
|
176
|
+
chat_id = chat_id_for(type, scope_key)
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
@chats[chat_id] ||= begin
|
|
179
|
+
descriptor = {
|
|
180
|
+
"kind" => "plugin",
|
|
181
|
+
"plugin_tab_type" => type.id,
|
|
182
|
+
"label" => type.title,
|
|
183
|
+
"scope_key" => scope_key
|
|
184
|
+
}.merge(descriptor.transform_keys(&:to_s))
|
|
185
|
+
host = PluginTabHost.new(client: @client, workspace_root: workspace_root)
|
|
186
|
+
driver = type.handler.call(host, descriptor)
|
|
187
|
+
raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
|
|
188
|
+
|
|
189
|
+
Chat.new(
|
|
190
|
+
id: chat_id,
|
|
191
|
+
type: type,
|
|
192
|
+
driver: driver,
|
|
193
|
+
queue: Queue.new,
|
|
194
|
+
scope_key: scope_key,
|
|
195
|
+
descriptor: descriptor,
|
|
196
|
+
workspace_root: workspace_root
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_scope_key(scope_key)
|
|
203
|
+
value = scope_key.to_s
|
|
204
|
+
value.empty? ? "default" : value
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def chat_id_for(type, scope_key)
|
|
208
|
+
return type.id if type.singleton == :global
|
|
209
|
+
return type.id if scope_key == "default"
|
|
210
|
+
|
|
211
|
+
"#{type.id}:#{Digest::SHA256.hexdigest(scope_key)[0, 16]}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def fetch_chat(chat_id)
|
|
215
|
+
chat(chat_id) || raise(ArgumentError, "Unknown plugin chat: #{chat_id}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fetch_turn(turn_id)
|
|
219
|
+
@mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ensure_worker(chat)
|
|
223
|
+
return if chat.worker&.alive?
|
|
224
|
+
|
|
225
|
+
chat.worker = Thread.new do
|
|
226
|
+
loop do
|
|
227
|
+
turn_id = chat.queue.pop
|
|
228
|
+
break if turn_id.equal?(WORKER_STOP)
|
|
229
|
+
|
|
230
|
+
turn = fetch_turn(turn_id)
|
|
231
|
+
run_turn(chat, turn) unless turn.cancellation.cancelled?
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
chat.worker.report_on_exception = false
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def run_turn(chat, turn)
|
|
238
|
+
turn.mutex.synchronize do
|
|
239
|
+
return if terminal?(turn)
|
|
240
|
+
|
|
241
|
+
turn.status = "running"
|
|
242
|
+
turn.started_at = Time.now.utc.iso8601(3)
|
|
243
|
+
end
|
|
244
|
+
chat.running_turn_id = turn.id
|
|
245
|
+
emit_event(turn, "turnStarted", { status: "running" })
|
|
246
|
+
submit_options = {
|
|
247
|
+
display_input: turn.display_input,
|
|
248
|
+
cancellation: turn.cancellation
|
|
249
|
+
}
|
|
250
|
+
if !turn.context.empty? && accepts_context?(chat.driver)
|
|
251
|
+
submit_options[:context] = turn.context
|
|
252
|
+
end
|
|
253
|
+
chat.driver.submit(turn.input, **submit_options) do |event|
|
|
254
|
+
handle_driver_event(chat, turn, event)
|
|
255
|
+
end
|
|
256
|
+
finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
|
|
257
|
+
rescue Cancellation::CancelledError
|
|
258
|
+
finish_turn(turn, "canceled")
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
|
|
261
|
+
finish_turn(turn, "failed")
|
|
262
|
+
ensure
|
|
263
|
+
chat.running_turn_id = nil if chat.running_turn_id == turn.id
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def accepts_context?(driver)
|
|
267
|
+
driver.method(:submit).parameters.any? do |kind, name|
|
|
268
|
+
kind == :keyrest || ((kind == :key || kind == :keyreq) && name == :context)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def handle_driver_event(chat, turn, event)
|
|
273
|
+
notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
|
|
274
|
+
|
|
275
|
+
type, payload = case event
|
|
276
|
+
when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
|
|
277
|
+
when Events::ReasoningBoundary then ["reasoningBoundary", {}]
|
|
278
|
+
when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
|
|
279
|
+
when Events::AssistantMessage then ["assistantMessage", { message: normalized_assistant_message(event.message) }]
|
|
280
|
+
when Events::Retry then ["modelRetry", retry_event_payload(event)]
|
|
281
|
+
when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
|
|
282
|
+
when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
|
|
283
|
+
when Events::Answer then ["answer", { content: event.content }]
|
|
284
|
+
end
|
|
285
|
+
emit_event(turn, type, payload) if type
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def notify_plugin_tab_transcript_event(chat, event)
|
|
289
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
290
|
+
|
|
291
|
+
context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: chat.workspace_root)
|
|
292
|
+
plugin_registry.notify_transcript_event(event, context)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def normalized_assistant_message(message)
|
|
296
|
+
@message_normalizer ? @message_normalizer.call(message) : message
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def retry_event_payload(event)
|
|
300
|
+
{
|
|
301
|
+
provider: event.provider,
|
|
302
|
+
model: event.model,
|
|
303
|
+
attempt: event.attempt,
|
|
304
|
+
maxAttempts: event.max_attempts,
|
|
305
|
+
delaySeconds: event.delay_seconds,
|
|
306
|
+
error: event.error,
|
|
307
|
+
requestBytes: event.request_bytes
|
|
308
|
+
}.compact
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def tool_call_payload(tool_call)
|
|
312
|
+
{
|
|
313
|
+
toolCallId: ToolCall.id(tool_call),
|
|
314
|
+
toolName: ToolCall.name(tool_call),
|
|
315
|
+
args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call))
|
|
316
|
+
}.compact
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def tool_result_payload(tool_call, content)
|
|
320
|
+
{
|
|
321
|
+
toolCallId: ToolCall.id(tool_call),
|
|
322
|
+
toolName: ToolCall.name(tool_call),
|
|
323
|
+
result: {
|
|
324
|
+
content: content.to_s,
|
|
325
|
+
isError: content.to_s.start_with?("Unknown", "Invalid", "Research tool failed")
|
|
326
|
+
}
|
|
327
|
+
}.compact
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def finish_turn(turn, status)
|
|
331
|
+
event = turn.mutex.synchronize do
|
|
332
|
+
next nil if terminal?(turn)
|
|
333
|
+
|
|
334
|
+
turn.status = status
|
|
335
|
+
turn.finished_at = Time.now.utc.iso8601(3)
|
|
336
|
+
append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
|
|
337
|
+
end
|
|
338
|
+
notify_event(event) if event
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def emit_event(turn, type, payload)
|
|
342
|
+
event = turn.mutex.synchronize { append_event(turn, type, payload) }
|
|
343
|
+
notify_event(event)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def append_event(turn, type, payload)
|
|
347
|
+
event = {
|
|
348
|
+
sequence: turn.next_sequence,
|
|
349
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
350
|
+
chatId: turn.chat_id,
|
|
351
|
+
turnId: turn.id,
|
|
352
|
+
type: type,
|
|
353
|
+
payload: payload
|
|
354
|
+
}
|
|
355
|
+
turn.next_sequence += 1
|
|
356
|
+
turn.events << event
|
|
357
|
+
turn.events.shift while turn.events.length > EVENT_LIMIT
|
|
358
|
+
event
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def notify_event(event)
|
|
362
|
+
listeners = @mutex.synchronize { @event_listeners.dup }
|
|
363
|
+
listeners.each do |listener|
|
|
364
|
+
listener.call(event)
|
|
365
|
+
rescue StandardError
|
|
366
|
+
nil
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def terminal?(turn)
|
|
371
|
+
%w[completed failed canceled].include?(turn.status)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative "config_files"
|
|
2
2
|
require_relative "deep_copy"
|
|
3
3
|
require_relative "hooks"
|
|
4
|
+
require_relative "transport"
|
|
4
5
|
|
|
5
6
|
# Namespace for the Kward CLI agent runtime.
|
|
6
7
|
module Kward
|
|
@@ -36,7 +37,11 @@ module Kward
|
|
|
36
37
|
|
|
37
38
|
# Registered plugin-owned tab runtime. Its factory receives a
|
|
38
39
|
# `PluginTabHost` and its persisted descriptor, then returns a driver.
|
|
39
|
-
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
|
|
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)
|
|
40
45
|
|
|
41
46
|
# Read-only event passed to plugin transcript observers.
|
|
42
47
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
@@ -269,21 +274,38 @@ module Kward
|
|
|
269
274
|
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
270
275
|
end
|
|
271
276
|
|
|
272
|
-
# Registers a plugin-owned
|
|
273
|
-
#
|
|
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.
|
|
274
279
|
# The factory receives a `PluginTabHost` and a descriptor hash.
|
|
275
280
|
#
|
|
276
281
|
# @param name [String] command name used by `/tab open <name>`
|
|
277
282
|
# @param id [String] stable persisted tab type identifier
|
|
278
283
|
# @param title [String] default tab label
|
|
279
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
|
|
280
288
|
# @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
|
|
281
289
|
# @yieldparam host [PluginTabHost] supported host dependencies
|
|
282
290
|
# @yieldparam descriptor [Hash] persisted tab descriptor
|
|
283
291
|
# @return [void]
|
|
284
292
|
# @api public
|
|
285
|
-
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
|
|
286
|
-
@registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
|
|
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)
|
|
287
309
|
end
|
|
288
310
|
end
|
|
289
311
|
|
|
@@ -305,6 +327,8 @@ module Kward
|
|
|
305
327
|
@interactive_commands = {}
|
|
306
328
|
@tab_types = {}
|
|
307
329
|
@tab_types_by_id = {}
|
|
330
|
+
@transports = {}
|
|
331
|
+
@transports_by_id = {}
|
|
308
332
|
@footer = nil
|
|
309
333
|
@footer_path = nil
|
|
310
334
|
@transcript_event_handlers = []
|
|
@@ -347,6 +371,22 @@ module Kward
|
|
|
347
371
|
@tab_types_by_id[id.to_s]
|
|
348
372
|
end
|
|
349
373
|
|
|
374
|
+
def transport_tab_types
|
|
375
|
+
@tab_types.values.select(&:transport)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def transports
|
|
379
|
+
@transports.values
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def transport_for(name)
|
|
383
|
+
@transports[name.to_s]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def transport_for_id(id)
|
|
387
|
+
@transports_by_id[id.to_s]
|
|
388
|
+
end
|
|
389
|
+
|
|
350
390
|
def footer_renderer
|
|
351
391
|
@footer
|
|
352
392
|
end
|
|
@@ -464,7 +504,7 @@ module Kward
|
|
|
464
504
|
)
|
|
465
505
|
end
|
|
466
506
|
|
|
467
|
-
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
|
|
507
|
+
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transport: false, local: true, transcript_events: false, path: nil, &handler)
|
|
468
508
|
name = name.to_s
|
|
469
509
|
id = id.to_s
|
|
470
510
|
raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
@@ -476,11 +516,30 @@ module Kward
|
|
|
476
516
|
return nil
|
|
477
517
|
end
|
|
478
518
|
|
|
479
|
-
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, transcript_events: transcript_events == true, path: path, handler: handler)
|
|
519
|
+
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)
|
|
480
520
|
@tab_types[name] = tab_type
|
|
481
521
|
@tab_types_by_id[id] = tab_type
|
|
482
522
|
end
|
|
483
523
|
|
|
524
|
+
def register_transport(name, id:, capabilities: nil, execution_profile: nil, path: nil, &handler)
|
|
525
|
+
name = name.to_s
|
|
526
|
+
id = id.to_s
|
|
527
|
+
raise "Plugin transport name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
528
|
+
raise "Plugin transport id is required" if id.empty?
|
|
529
|
+
raise "Plugin transport #{name} requires a handler" unless handler
|
|
530
|
+
|
|
531
|
+
if @transports.key?(name) || @transports_by_id.key?(id)
|
|
532
|
+
warn "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
|
|
533
|
+
return nil
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
capabilities = normalize_transport_capabilities(capabilities)
|
|
537
|
+
execution_profile = normalize_execution_profile(execution_profile)
|
|
538
|
+
transport = TransportType.new(id: id, name: name, capabilities: capabilities, execution_profile: execution_profile, path: path, handler: handler)
|
|
539
|
+
@transports[name] = transport
|
|
540
|
+
@transports_by_id[id] = transport
|
|
541
|
+
end
|
|
542
|
+
|
|
484
543
|
def register_footer(path: nil, &renderer)
|
|
485
544
|
raise "Plugin footer requires a renderer" unless renderer
|
|
486
545
|
|
|
@@ -520,6 +579,20 @@ module Kward
|
|
|
520
579
|
|
|
521
580
|
private
|
|
522
581
|
|
|
582
|
+
def normalize_execution_profile(profile)
|
|
583
|
+
return nil if profile.nil?
|
|
584
|
+
return profile if profile.is_a?(Transport::ExecutionProfile)
|
|
585
|
+
raise ArgumentError, "Plugin transport execution_profile must be a Transport::ExecutionProfile"
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def normalize_transport_capabilities(capabilities)
|
|
589
|
+
return Transport.capabilities if capabilities.nil?
|
|
590
|
+
return capabilities if capabilities.is_a?(Transport::Capabilities)
|
|
591
|
+
raise ArgumentError, "Plugin transport capabilities must be a hash or Transport::Capabilities" unless capabilities.is_a?(Hash)
|
|
592
|
+
|
|
593
|
+
Transport.capabilities(**capabilities.transform_keys(&:to_sym))
|
|
594
|
+
end
|
|
595
|
+
|
|
523
596
|
def transcript_event_for(event)
|
|
524
597
|
case event.class.name
|
|
525
598
|
when "Kward::Events::ReasoningDelta"
|
data/lib/kward/private_file.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
require "json"
|
|
3
|
+
require "tempfile"
|
|
3
4
|
|
|
4
5
|
# Namespace for the Kward CLI agent runtime.
|
|
5
6
|
module Kward
|
|
@@ -9,12 +10,17 @@ module Kward
|
|
|
9
10
|
|
|
10
11
|
def write_json(path, data)
|
|
11
12
|
path = File.expand_path(path)
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
directory = File.dirname(path)
|
|
14
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
15
|
+
|
|
16
|
+
Tempfile.create([".#{File.basename(path)}.", ".tmp"], directory, mode: 0o600) do |file|
|
|
14
17
|
file.write(JSON.pretty_generate(data))
|
|
15
18
|
file.write("\n")
|
|
19
|
+
file.flush
|
|
20
|
+
file.fsync
|
|
21
|
+
file.close
|
|
22
|
+
File.rename(file.path, path)
|
|
16
23
|
end
|
|
17
|
-
File.chmod(0o600, path)
|
|
18
24
|
end
|
|
19
25
|
end
|
|
20
26
|
end
|
|
@@ -37,6 +37,7 @@ module Kward
|
|
|
37
37
|
def tool_approval_title(tool_name)
|
|
38
38
|
case tool_name.to_s
|
|
39
39
|
when "run_shell_command" then "Shell command"
|
|
40
|
+
when "git_commit" then "Git commit"
|
|
40
41
|
when "write_file" then "Write file"
|
|
41
42
|
when "edit_file" then "Edit file"
|
|
42
43
|
when "fetch_content", "fetch_raw" then "Network request"
|
|
@@ -48,6 +49,7 @@ module Kward
|
|
|
48
49
|
def tool_approval_details(tool_name, args)
|
|
49
50
|
action = case tool_name.to_s
|
|
50
51
|
when "run_shell_command" then "run this shell command"
|
|
52
|
+
when "git_commit" then "create this Git commit"
|
|
51
53
|
when "write_file" then "write this file"
|
|
52
54
|
when "edit_file" then "edit this file"
|
|
53
55
|
when "read_file", "read_skill" then "read these resources"
|
|
@@ -323,6 +323,8 @@ module Kward
|
|
|
323
323
|
code.match?(/:\s*(?:[#].*)?\z/)
|
|
324
324
|
when :html
|
|
325
325
|
editor_html_line_opens_indent?(code)
|
|
326
|
+
when :erb
|
|
327
|
+
editor_erb_line_opens_indent?(code)
|
|
326
328
|
when *C_LIKE_INDENT_LANGUAGES
|
|
327
329
|
false
|
|
328
330
|
else
|
|
@@ -364,6 +366,46 @@ module Kward
|
|
|
364
366
|
true
|
|
365
367
|
end
|
|
366
368
|
|
|
369
|
+
def editor_erb_line_opens_indent?(code)
|
|
370
|
+
segments, = editor_erb_segments(code.to_s, :template)
|
|
371
|
+
segments.any? do |kind, text|
|
|
372
|
+
case kind
|
|
373
|
+
when :ruby
|
|
374
|
+
editor_ruby_line_opens_indent?(text.strip)
|
|
375
|
+
when :template
|
|
376
|
+
editor_html_line_opens_indent?(text.strip)
|
|
377
|
+
else
|
|
378
|
+
false
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def editor_erb_line_closer?(code)
|
|
384
|
+
segments, = editor_erb_segments(code.to_s, :template)
|
|
385
|
+
segments.any? do |kind, text|
|
|
386
|
+
case kind
|
|
387
|
+
when :ruby
|
|
388
|
+
text.strip.match?(/\Aend\z/)
|
|
389
|
+
when :template
|
|
390
|
+
text.strip.match?(/\A<\/[A-Za-z][\w:-]*>\z/)
|
|
391
|
+
else
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def editor_matching_erb_indent(line_index = nil)
|
|
398
|
+
stack = []
|
|
399
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
400
|
+
code = line[:code].strip
|
|
401
|
+
next if code.empty?
|
|
402
|
+
|
|
403
|
+
stack.pop if editor_erb_line_closer?(code)
|
|
404
|
+
stack << line[:indent] if editor_erb_line_opens_indent?(code)
|
|
405
|
+
end
|
|
406
|
+
stack.last || ""
|
|
407
|
+
end
|
|
408
|
+
|
|
367
409
|
def editor_closing_punctuation?(text)
|
|
368
410
|
PUNCTUATION_PAIRS.key?(text) && PUNCTUATION_INDENT_LANGUAGES.include?(editor_syntax_language)
|
|
369
411
|
end
|
|
@@ -424,6 +466,8 @@ module Kward
|
|
|
424
466
|
code.match?(/\A[ \t]*(?:#{Regexp.union(SHELL_DEDENT_KEYWORDS)})\z/)
|
|
425
467
|
when :html
|
|
426
468
|
code.match?(/\A[ \t]*<\/[A-Za-z][\w:-]*>\z/)
|
|
469
|
+
when :erb
|
|
470
|
+
editor_erb_line_closer?(code)
|
|
427
471
|
else
|
|
428
472
|
false
|
|
429
473
|
end
|
|
@@ -439,6 +483,8 @@ module Kward
|
|
|
439
483
|
editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS, line_index)
|
|
440
484
|
when :html
|
|
441
485
|
editor_matching_html_indent(line_index)
|
|
486
|
+
when :erb
|
|
487
|
+
editor_matching_erb_indent(line_index)
|
|
442
488
|
end
|
|
443
489
|
end
|
|
444
490
|
|