kward 0.77.0 → 0.79.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 +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "thread"
|
|
3
|
+
require_relative "../cancellation"
|
|
4
|
+
require_relative "../events"
|
|
5
|
+
require_relative "../image_attachments"
|
|
6
|
+
require_relative "../message_access"
|
|
7
|
+
require_relative "../plugin_registry"
|
|
8
|
+
require_relative "../tools/tool_call"
|
|
9
|
+
require_relative "../tab_driver"
|
|
10
|
+
require_relative "attachment_normalizer"
|
|
11
|
+
require_relative "transcript_normalizer"
|
|
12
|
+
|
|
13
|
+
# Namespace for the Kward CLI agent runtime.
|
|
14
|
+
module Kward
|
|
15
|
+
module RPC
|
|
16
|
+
# Coordinates optional RPC access to plugin-owned conversational runtimes.
|
|
17
|
+
# Unlike SessionManager, this manager never creates workspace sessions,
|
|
18
|
+
# agents, or tool registries: each plugin driver owns its chat and storage.
|
|
19
|
+
class PluginChatManager
|
|
20
|
+
EVENT_LIMIT = 1_000
|
|
21
|
+
WORKER_STOP = Object.new.freeze
|
|
22
|
+
|
|
23
|
+
Chat = Struct.new(:id, :type, :driver, :queue, :worker, :running_turn_id, keyword_init: true)
|
|
24
|
+
Turn = Struct.new(:id, :chat_id, :input, :display_input, :status, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :mutex, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
def initialize(server:, client: Client.new, plugin_registry_provider: nil)
|
|
27
|
+
@server = server
|
|
28
|
+
@client = client
|
|
29
|
+
@plugin_registry_provider = plugin_registry_provider
|
|
30
|
+
@chats = {}
|
|
31
|
+
@turns = {}
|
|
32
|
+
@subscriptions = {}
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def supported_types
|
|
37
|
+
plugin_registry.tab_types.select(&:rpc)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def list
|
|
41
|
+
{ chats: supported_types.map { |type| type_payload(type) } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def open(type_id:)
|
|
45
|
+
type = supported_types.find { |entry| entry.id == type_id.to_s } || raise(ArgumentError, "Unknown RPC plugin chat: #{type_id}")
|
|
46
|
+
chat = chat_for(type)
|
|
47
|
+
return chat_payload(chat) if chat.driver.respond_to?(:transcript_page)
|
|
48
|
+
|
|
49
|
+
chat_payload(chat).merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def transcript(chat_id:, limit: nil, before: nil)
|
|
53
|
+
chat = fetch_chat(chat_id)
|
|
54
|
+
page = transcript_page(chat.driver, limit: limit, before: before)
|
|
55
|
+
{
|
|
56
|
+
chat: chat_payload(chat),
|
|
57
|
+
messages: TranscriptNormalizer.new(page.fetch(:messages)).normalize,
|
|
58
|
+
hasMore: page.fetch(:has_more),
|
|
59
|
+
nextBefore: page[:next_before]
|
|
60
|
+
}.compact
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def subscribe(chat_id:)
|
|
64
|
+
chat = fetch_chat(chat_id)
|
|
65
|
+
@mutex.synchronize { @subscriptions[chat.id] = true }
|
|
66
|
+
{ chat: chat_payload(chat), subscribed: true }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unsubscribe(chat_id:)
|
|
70
|
+
chat = fetch_chat(chat_id)
|
|
71
|
+
@mutex.synchronize { @subscriptions.delete(chat.id) }
|
|
72
|
+
{ chat: chat_payload(chat), subscribed: false }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def start_turn(chat_id:, input:, attachments: [])
|
|
76
|
+
chat = fetch_chat(chat_id)
|
|
77
|
+
normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
|
|
78
|
+
turn = Turn.new(
|
|
79
|
+
id: SecureRandom.uuid,
|
|
80
|
+
chat_id: chat.id,
|
|
81
|
+
input: input_with_attachments(input, normalized_attachments),
|
|
82
|
+
display_input: input.to_s,
|
|
83
|
+
status: "queued",
|
|
84
|
+
cancellation: Cancellation.new,
|
|
85
|
+
created_at: Time.now.utc.iso8601(3),
|
|
86
|
+
events: [],
|
|
87
|
+
next_sequence: 1,
|
|
88
|
+
mutex: Mutex.new
|
|
89
|
+
)
|
|
90
|
+
@mutex.synchronize { @turns[turn.id] = turn }
|
|
91
|
+
chat.queue << turn.id
|
|
92
|
+
ensure_worker(chat)
|
|
93
|
+
emit_event(turn, "turnQueued", { status: "queued" })
|
|
94
|
+
turn_payload(turn)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def cancel_turn(turn_id:)
|
|
98
|
+
turn = fetch_turn(turn_id)
|
|
99
|
+
queued = turn.mutex.synchronize do
|
|
100
|
+
turn.cancellation.cancel!
|
|
101
|
+
turn.status == "queued"
|
|
102
|
+
end
|
|
103
|
+
emit_event(turn, "turnCancelRequested", {})
|
|
104
|
+
finish_turn(turn, "canceled") if queued
|
|
105
|
+
turn_payload(turn)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def turn_status(turn_id:)
|
|
109
|
+
turn_payload(fetch_turn(turn_id))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def turn_events(turn_id:, after_sequence: 0)
|
|
113
|
+
turn = fetch_turn(turn_id)
|
|
114
|
+
events = turn.mutex.synchronize { turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i } }
|
|
115
|
+
{ turn: turn_payload(turn), events: events }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def list_turns(chat_id: nil, active: false)
|
|
119
|
+
turns = @mutex.synchronize { @turns.values.dup }
|
|
120
|
+
turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
|
|
121
|
+
turns.select! { |turn| %w[queued running].include?(turn.status) } if active
|
|
122
|
+
{ turns: turns.map { |turn| turn_payload(turn) } }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def shutdown
|
|
126
|
+
chats = @mutex.synchronize { @chats.values.dup }
|
|
127
|
+
chats.each do |chat|
|
|
128
|
+
chat.queue << WORKER_STOP if chat.worker&.alive?
|
|
129
|
+
chat.worker&.join(0.2)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def plugin_registry
|
|
136
|
+
return @plugin_registry_provider.call if @plugin_registry_provider
|
|
137
|
+
|
|
138
|
+
@plugin_registry ||= PluginRegistry.load
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def chat_for(type)
|
|
142
|
+
@mutex.synchronize do
|
|
143
|
+
@chats[type.id] ||= begin
|
|
144
|
+
descriptor = { "kind" => "plugin", "plugin_tab_type" => type.id, "label" => type.title }
|
|
145
|
+
driver = type.handler.call(PluginTabHost.new(client: @client, workspace_root: Dir.pwd), descriptor)
|
|
146
|
+
raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
|
|
147
|
+
|
|
148
|
+
Chat.new(id: type.id, type: type, driver: driver, queue: Queue.new)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def fetch_chat(chat_id)
|
|
154
|
+
@mutex.synchronize { @chats[chat_id.to_s] } || open(type_id: chat_id).then { @mutex.synchronize { @chats[chat_id.to_s] } }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fetch_turn(turn_id)
|
|
158
|
+
@mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def ensure_worker(chat)
|
|
162
|
+
return if chat.worker&.alive?
|
|
163
|
+
|
|
164
|
+
chat.worker = Thread.new do
|
|
165
|
+
loop do
|
|
166
|
+
turn_id = chat.queue.pop
|
|
167
|
+
break if turn_id.equal?(WORKER_STOP)
|
|
168
|
+
|
|
169
|
+
turn = fetch_turn(turn_id)
|
|
170
|
+
run_turn(chat, turn) unless turn.cancellation.cancelled?
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
chat.worker.report_on_exception = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def run_turn(chat, turn)
|
|
177
|
+
turn.mutex.synchronize do
|
|
178
|
+
return if terminal?(turn)
|
|
179
|
+
|
|
180
|
+
turn.status = "running"
|
|
181
|
+
turn.started_at = Time.now.utc.iso8601(3)
|
|
182
|
+
end
|
|
183
|
+
chat.running_turn_id = turn.id
|
|
184
|
+
emit_event(turn, "turnStarted", { status: "running" })
|
|
185
|
+
chat.driver.submit(turn.input, display_input: turn.display_input, cancellation: turn.cancellation) do |event|
|
|
186
|
+
handle_driver_event(chat, turn, event)
|
|
187
|
+
end
|
|
188
|
+
finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
|
|
189
|
+
rescue Cancellation::CancelledError
|
|
190
|
+
finish_turn(turn, "canceled")
|
|
191
|
+
rescue StandardError => e
|
|
192
|
+
turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
|
|
193
|
+
finish_turn(turn, "failed")
|
|
194
|
+
ensure
|
|
195
|
+
chat.running_turn_id = nil if chat.running_turn_id == turn.id
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def handle_driver_event(chat, turn, event)
|
|
199
|
+
notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
|
|
200
|
+
|
|
201
|
+
type, payload = case event
|
|
202
|
+
when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
|
|
203
|
+
when Events::ReasoningBoundary then ["reasoningBoundary", {}]
|
|
204
|
+
when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
|
|
205
|
+
when Events::AssistantMessage then ["assistantMessage", { message: TranscriptNormalizer.new([event.message]).normalize.first }]
|
|
206
|
+
when Events::Retry then ["modelRetry", retry_event_payload(event)]
|
|
207
|
+
when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
|
|
208
|
+
when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
|
|
209
|
+
when Events::Answer then ["answer", { content: event.content }]
|
|
210
|
+
end
|
|
211
|
+
emit_event(turn, type, payload) if type
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def notify_plugin_tab_transcript_event(chat, event)
|
|
215
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
216
|
+
|
|
217
|
+
context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: Dir.pwd)
|
|
218
|
+
plugin_registry.notify_transcript_event(event, context)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def retry_event_payload(event)
|
|
222
|
+
{ provider: event.provider, model: event.model, attempt: event.attempt, maxAttempts: event.max_attempts, delaySeconds: event.delay_seconds, error: event.error, requestBytes: event.request_bytes }.compact
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def tool_call_payload(tool_call)
|
|
226
|
+
{ toolCallId: ToolCall.id(tool_call), toolName: ToolCall.name(tool_call), args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call)) }.compact
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def tool_result_payload(tool_call, content)
|
|
230
|
+
{ toolCallId: ToolCall.id(tool_call), toolName: ToolCall.name(tool_call), result: { content: content.to_s, isError: content.to_s.start_with?("Unknown", "Invalid", "Research tool failed") } }.compact
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def finish_turn(turn, status)
|
|
234
|
+
event = turn.mutex.synchronize do
|
|
235
|
+
next nil if terminal?(turn)
|
|
236
|
+
|
|
237
|
+
turn.status = status
|
|
238
|
+
turn.finished_at = Time.now.utc.iso8601(3)
|
|
239
|
+
append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
|
|
240
|
+
end
|
|
241
|
+
notify_event(event) if event
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def emit_event(turn, type, payload)
|
|
245
|
+
event = turn.mutex.synchronize { append_event(turn, type, payload) }
|
|
246
|
+
notify_event(event)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def append_event(turn, type, payload)
|
|
250
|
+
event = {
|
|
251
|
+
sequence: turn.next_sequence,
|
|
252
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
253
|
+
chatId: turn.chat_id,
|
|
254
|
+
turnId: turn.id,
|
|
255
|
+
type: type,
|
|
256
|
+
payload: payload
|
|
257
|
+
}
|
|
258
|
+
turn.next_sequence += 1
|
|
259
|
+
turn.events << event
|
|
260
|
+
turn.events.shift while turn.events.length > EVENT_LIMIT
|
|
261
|
+
event
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def notify_event(event)
|
|
265
|
+
subscribed = @mutex.synchronize { @subscriptions[event[:chatId]] }
|
|
266
|
+
@server.notify("pluginChat/event", event) if subscribed
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def input_with_attachments(input, attachments)
|
|
270
|
+
return input.to_s if attachments.empty?
|
|
271
|
+
|
|
272
|
+
[{ type: "text", text: input.to_s }] + attachments.map do |attachment|
|
|
273
|
+
{ type: "image", media_type: attachment[:mimeType], data: attachment[:data], alt: attachment[:alt] }
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def terminal?(turn)
|
|
278
|
+
%w[completed failed canceled].include?(turn.status)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def type_payload(type)
|
|
282
|
+
{ id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def transcript_page(driver, limit:, before:)
|
|
286
|
+
return { messages: driver.messages, has_more: false } unless limit && driver.respond_to?(:transcript_page)
|
|
287
|
+
|
|
288
|
+
driver.transcript_page(limit: limit, before: before)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def chat_payload(chat)
|
|
292
|
+
type_payload(chat.type).merge(id: chat.id, subscribed: @mutex.synchronize { @subscriptions[chat.id] == true })
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def turn_payload(turn)
|
|
296
|
+
turn.mutex.synchronize do
|
|
297
|
+
{ id: turn.id, chatId: turn.chat_id, status: turn.status, createdAt: turn.created_at, startedAt: turn.started_at, finishedAt: turn.finished_at, error: turn.error }.compact
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative "../memory/manager"
|
|
|
5
5
|
require_relative "../plugin_registry"
|
|
6
6
|
require_relative "../prompts/commands"
|
|
7
7
|
require_relative "../tools/registry"
|
|
8
|
-
require_relative "../
|
|
8
|
+
require_relative "../workspace_factory"
|
|
9
9
|
require_relative "../telemetry/logger"
|
|
10
10
|
require_relative "../telemetry/stats"
|
|
11
11
|
require_relative "auth_manager"
|
|
@@ -13,6 +13,7 @@ require_relative "config_manager"
|
|
|
13
13
|
require_relative "mcp_status"
|
|
14
14
|
require_relative "redactor"
|
|
15
15
|
require_relative "session_manager"
|
|
16
|
+
require_relative "plugin_chat_manager"
|
|
16
17
|
require_relative "transport"
|
|
17
18
|
|
|
18
19
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -57,6 +58,7 @@ module Kward
|
|
|
57
58
|
"sessions/export", "sessions/delete", "sessions/close", "sessions/transcript", "sessions/active"
|
|
58
59
|
].freeze
|
|
59
60
|
TURN_METHODS = ["turns/start", "turns/cancel", "turns/status", "turns/events", "turns/list", "turns/listActive"].freeze
|
|
61
|
+
PLUGIN_CHAT_METHODS = ["pluginChats/list", "pluginChats/open", "pluginChats/transcript", "pluginChats/subscribe", "pluginChats/unsubscribe", "pluginChats/turns/start", "pluginChats/turns/cancel", "pluginChats/turns/status", "pluginChats/turns/events", "pluginChats/turns/list", "pluginChats/turns/listActive"].freeze
|
|
60
62
|
MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
|
|
61
63
|
RUNTIME_METHODS = ["runtime/state", "runtime/stats"].freeze
|
|
62
64
|
RUNTIME_SETTING_METHODS = ["runtime/updateSetting", "runtime/reload"].freeze
|
|
@@ -71,6 +73,7 @@ module Kward
|
|
|
71
73
|
"memory/why", "memory/summarize"
|
|
72
74
|
].freeze
|
|
73
75
|
COMMAND_METHODS = ["commands/list", "commands/run"].freeze
|
|
76
|
+
SKILL_CAPTURE_METHODS = ["skills/captureSessions", "skills/captureDraft", "skills/saveCapturedDraft"].freeze
|
|
74
77
|
STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
|
|
75
78
|
CONFIG_METHODS = ["config/read", "config/update"].freeze
|
|
76
79
|
LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
|
|
@@ -92,12 +95,14 @@ module Kward
|
|
|
92
95
|
prompts: PROMPT_METHODS,
|
|
93
96
|
sessions: SESSION_METHODS,
|
|
94
97
|
turns: TURN_METHODS,
|
|
98
|
+
plugin_chats: PLUGIN_CHAT_METHODS,
|
|
95
99
|
models: MODEL_METHODS,
|
|
96
100
|
runtime: RUNTIME_METHODS,
|
|
97
101
|
runtime_settings: RUNTIME_SETTING_METHODS,
|
|
98
102
|
auth: AUTH_METHODS,
|
|
99
103
|
memory: MEMORY_METHODS,
|
|
100
104
|
commands: COMMAND_METHODS,
|
|
105
|
+
skill_capture: SKILL_CAPTURE_METHODS,
|
|
101
106
|
startup_resources: STARTUP_RESOURCE_METHODS,
|
|
102
107
|
config: CONFIG_METHODS,
|
|
103
108
|
logging: LOGGING_METHODS,
|
|
@@ -119,6 +124,11 @@ module Kward
|
|
|
119
124
|
@client = client
|
|
120
125
|
@config_manager = ConfigManager.new
|
|
121
126
|
@session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
|
|
127
|
+
@plugin_chat_manager = PluginChatManager.new(
|
|
128
|
+
server: self,
|
|
129
|
+
client: client,
|
|
130
|
+
plugin_registry_provider: -> { @session_manager.plugin_registry }
|
|
131
|
+
)
|
|
122
132
|
@auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
|
|
123
133
|
@shutdown = false
|
|
124
134
|
end
|
|
@@ -140,6 +150,7 @@ module Kward
|
|
|
140
150
|
end
|
|
141
151
|
end
|
|
142
152
|
ensure
|
|
153
|
+
@plugin_chat_manager.shutdown
|
|
143
154
|
@session_manager.shutdown_sessions
|
|
144
155
|
end
|
|
145
156
|
|
|
@@ -242,6 +253,12 @@ module Kward
|
|
|
242
253
|
commands_list(params)
|
|
243
254
|
when COMMAND_METHODS[1]
|
|
244
255
|
commands_run(params)
|
|
256
|
+
when SKILL_CAPTURE_METHODS[0]
|
|
257
|
+
{ sessions: @session_manager.skill_capture_sessions }
|
|
258
|
+
when SKILL_CAPTURE_METHODS[1]
|
|
259
|
+
@session_manager.capture_skill(session_path: params.fetch("sessionPath"))
|
|
260
|
+
when SKILL_CAPTURE_METHODS[2]
|
|
261
|
+
@session_manager.save_captured_skill(content: params.fetch("content"), overwrite: params["overwrite"] == true)
|
|
245
262
|
when STARTUP_RESOURCE_METHODS[0]
|
|
246
263
|
startup_resources(params)
|
|
247
264
|
when CONFIG_METHODS[0]
|
|
@@ -349,6 +366,28 @@ module Kward
|
|
|
349
366
|
@session_manager.list_turns(session_id: params["sessionId"])
|
|
350
367
|
when TURN_METHODS[5]
|
|
351
368
|
@session_manager.list_turns(session_id: params["sessionId"], active: true)
|
|
369
|
+
when PLUGIN_CHAT_METHODS[0]
|
|
370
|
+
@plugin_chat_manager.list
|
|
371
|
+
when PLUGIN_CHAT_METHODS[1]
|
|
372
|
+
@plugin_chat_manager.open(type_id: params.fetch("typeId"))
|
|
373
|
+
when PLUGIN_CHAT_METHODS[2]
|
|
374
|
+
@plugin_chat_manager.transcript(chat_id: params.fetch("chatId"), limit: params["limit"], before: params["before"])
|
|
375
|
+
when PLUGIN_CHAT_METHODS[3]
|
|
376
|
+
@plugin_chat_manager.subscribe(chat_id: params.fetch("chatId"))
|
|
377
|
+
when PLUGIN_CHAT_METHODS[4]
|
|
378
|
+
@plugin_chat_manager.unsubscribe(chat_id: params.fetch("chatId"))
|
|
379
|
+
when PLUGIN_CHAT_METHODS[5]
|
|
380
|
+
@plugin_chat_manager.start_turn(chat_id: params.fetch("chatId"), input: params.fetch("input"), attachments: params["attachments"] || [])
|
|
381
|
+
when PLUGIN_CHAT_METHODS[6]
|
|
382
|
+
@plugin_chat_manager.cancel_turn(turn_id: params.fetch("turnId"))
|
|
383
|
+
when PLUGIN_CHAT_METHODS[7]
|
|
384
|
+
@plugin_chat_manager.turn_status(turn_id: params.fetch("turnId"))
|
|
385
|
+
when PLUGIN_CHAT_METHODS[8]
|
|
386
|
+
@plugin_chat_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
|
|
387
|
+
when PLUGIN_CHAT_METHODS[9]
|
|
388
|
+
@plugin_chat_manager.list_turns(chat_id: params["chatId"])
|
|
389
|
+
when PLUGIN_CHAT_METHODS[10]
|
|
390
|
+
@plugin_chat_manager.list_turns(chat_id: params["chatId"], active: true)
|
|
352
391
|
when UI_METHODS[0]
|
|
353
392
|
@session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
|
|
354
393
|
when TOOL_APPROVAL_METHODS[0]
|
|
@@ -392,6 +431,14 @@ module Kward
|
|
|
392
431
|
tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
|
|
393
432
|
updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
|
|
394
433
|
},
|
|
434
|
+
pluginChats: {
|
|
435
|
+
supported: @plugin_chat_manager.supported_types.any?,
|
|
436
|
+
methods: PLUGIN_CHAT_METHODS,
|
|
437
|
+
notification: "pluginChat/event",
|
|
438
|
+
subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
|
|
439
|
+
attachments: { supported: true, method: PLUGIN_CHAT_METHODS[5], encoding: "base64", mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES, maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES },
|
|
440
|
+
types: @plugin_chat_manager.supported_types.map { |type| { id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact }
|
|
441
|
+
},
|
|
395
442
|
turns: {
|
|
396
443
|
mode: "async",
|
|
397
444
|
perSessionConcurrency: 1,
|
|
@@ -485,6 +532,7 @@ module Kward
|
|
|
485
532
|
memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
|
|
486
533
|
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
|
|
487
534
|
commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
535
|
+
skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
|
|
488
536
|
mcp: {
|
|
489
537
|
supported: true,
|
|
490
538
|
transport: "stdio",
|
|
@@ -521,7 +569,8 @@ module Kward
|
|
|
521
569
|
workspaceMutationGuard: "none",
|
|
522
570
|
toolApproval: { supported: true, defaultMode: "none", modes: ["none", "ask"], requestNotification: TOOL_APPROVAL_NOTIFICATION, answerMethod: TOOL_APPROVAL_METHODS.first },
|
|
523
571
|
canRunShell: true,
|
|
524
|
-
canWriteFiles: true
|
|
572
|
+
canWriteFiles: true,
|
|
573
|
+
sandbox: sandbox_capabilities
|
|
525
574
|
},
|
|
526
575
|
export: { supported: true, formats: ["markdown", "html"], defaultFormat: "markdown" },
|
|
527
576
|
logging: {
|
|
@@ -541,6 +590,30 @@ module Kward
|
|
|
541
590
|
}
|
|
542
591
|
end
|
|
543
592
|
|
|
593
|
+
def sandbox_capabilities
|
|
594
|
+
config = @config_manager.read(redacted: false)
|
|
595
|
+
policy = ConfigFiles.sandbox_policy(Dir.pwd, config)
|
|
596
|
+
runner = Sandbox::RunnerFactory.build(policy)
|
|
597
|
+
runner.capabilities.to_h.merge(
|
|
598
|
+
mode: policy.mode,
|
|
599
|
+
network: policy.network,
|
|
600
|
+
scope: "shellCommandsOnly",
|
|
601
|
+
sessionPinning: false,
|
|
602
|
+
oneTimeElevation: false
|
|
603
|
+
)
|
|
604
|
+
rescue ArgumentError => error
|
|
605
|
+
{
|
|
606
|
+
available: false,
|
|
607
|
+
filesystemEnforced: false,
|
|
608
|
+
childNetworkEnforced: false,
|
|
609
|
+
backend: "invalidConfiguration",
|
|
610
|
+
reason: error.message,
|
|
611
|
+
scope: "shellCommandsOnly",
|
|
612
|
+
sessionPinning: false,
|
|
613
|
+
oneTimeElevation: false
|
|
614
|
+
}
|
|
615
|
+
end
|
|
616
|
+
|
|
544
617
|
def workspace_info(root)
|
|
545
618
|
root = @session_manager.validate_workspace_root(root)
|
|
546
619
|
{ root: root, basename: File.basename(root), writable: File.writable?(root) }
|
|
@@ -762,7 +835,7 @@ module Kward
|
|
|
762
835
|
end
|
|
763
836
|
|
|
764
837
|
def configured_workspace(root = Dir.pwd)
|
|
765
|
-
|
|
838
|
+
WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
|
|
766
839
|
end
|
|
767
840
|
|
|
768
841
|
def workspace_guardrails_enabled?
|
|
@@ -15,6 +15,7 @@ require_relative "../memory/turn_context"
|
|
|
15
15
|
require_relative "../message_access"
|
|
16
16
|
require_relative "../message_text"
|
|
17
17
|
require_relative "../session_tree_tool_display"
|
|
18
|
+
require_relative "../skills/capture"
|
|
18
19
|
require_relative "../model/model_info"
|
|
19
20
|
require_relative "../plugin_registry"
|
|
20
21
|
require_relative "../prompts/commands"
|
|
@@ -25,7 +26,7 @@ require_relative "../steering"
|
|
|
25
26
|
require_relative "../tools/tool_call"
|
|
26
27
|
require_relative "../tools/registry"
|
|
27
28
|
require_relative "../transcript_export"
|
|
28
|
-
require_relative "../
|
|
29
|
+
require_relative "../workspace_factory"
|
|
29
30
|
require_relative "attachment_normalizer"
|
|
30
31
|
require_relative "config_manager"
|
|
31
32
|
require_relative "memory_methods"
|
|
@@ -155,6 +156,38 @@ module Kward
|
|
|
155
156
|
{ sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
|
|
156
157
|
end
|
|
157
158
|
|
|
159
|
+
def skill_capture_sessions
|
|
160
|
+
SessionStore.new(config_dir: @config_dir).capture_candidates.map do |session|
|
|
161
|
+
{
|
|
162
|
+
path: session.path,
|
|
163
|
+
workspaceRoot: session.cwd,
|
|
164
|
+
name: session.name,
|
|
165
|
+
firstMessage: session.first_message,
|
|
166
|
+
modifiedAt: session.modified_at.utc.iso8601(3)
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def capture_skill(session_path:)
|
|
172
|
+
capture = Skills::Capture.new(
|
|
173
|
+
session_store: SessionStore.new(config_dir: @config_dir),
|
|
174
|
+
client: @client,
|
|
175
|
+
config_dir: @config_dir
|
|
176
|
+
)
|
|
177
|
+
draft = capture.generate(session_path)
|
|
178
|
+
{ content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def save_captured_skill(content:, overwrite: false)
|
|
182
|
+
capture = Skills::Capture.new(
|
|
183
|
+
session_store: SessionStore.new(config_dir: @config_dir),
|
|
184
|
+
client: @client,
|
|
185
|
+
config_dir: @config_dir
|
|
186
|
+
)
|
|
187
|
+
draft = capture.save(content, overwrite: overwrite)
|
|
188
|
+
{ path: capture.skill_path(draft.name), name: draft.name, description: draft.description }
|
|
189
|
+
end
|
|
190
|
+
|
|
158
191
|
def tool_schemas(session_id: nil)
|
|
159
192
|
registry = if session_id
|
|
160
193
|
fetch_session(session_id).tool_registry
|
|
@@ -164,6 +197,11 @@ module Kward
|
|
|
164
197
|
{ tools: registry.schemas }
|
|
165
198
|
end
|
|
166
199
|
|
|
200
|
+
# Returns the plugin registry shared by RPC sessions and plugin chats.
|
|
201
|
+
def plugin_registry
|
|
202
|
+
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
203
|
+
end
|
|
204
|
+
|
|
167
205
|
# Renames the persisted session attached to an RPC session id.
|
|
168
206
|
def rename_session(session_id:, name:)
|
|
169
207
|
rpc_session = fetch_session(session_id)
|
|
@@ -919,10 +957,6 @@ module Kward
|
|
|
919
957
|
end
|
|
920
958
|
end
|
|
921
959
|
|
|
922
|
-
def plugin_registry
|
|
923
|
-
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
924
|
-
end
|
|
925
|
-
|
|
926
960
|
def reserved_plugin_command_names
|
|
927
961
|
PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
|
|
928
962
|
end
|
|
@@ -968,6 +1002,7 @@ module Kward
|
|
|
968
1002
|
prompt: prompt,
|
|
969
1003
|
allowed_tool_names: allowed_tool_names,
|
|
970
1004
|
tool_approval: tool_approval,
|
|
1005
|
+
approval_for_allowed_tools: !tool_approval.nil?,
|
|
971
1006
|
hook_manager: hook_manager,
|
|
972
1007
|
hook_context: hook_context
|
|
973
1008
|
)
|
|
@@ -1018,7 +1053,7 @@ module Kward
|
|
|
1018
1053
|
end
|
|
1019
1054
|
|
|
1020
1055
|
def configured_workspace(root)
|
|
1021
|
-
|
|
1056
|
+
WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: @config_manager.read(redacted: false))
|
|
1022
1057
|
end
|
|
1023
1058
|
|
|
1024
1059
|
def workspace_guardrails_enabled?
|
|
@@ -1160,6 +1195,8 @@ module Kward
|
|
|
1160
1195
|
started, event = turn.mutex.synchronize do
|
|
1161
1196
|
next [false, nil] if turn.status == "canceled"
|
|
1162
1197
|
|
|
1198
|
+
rpc_session.running_turn_id = turn.id
|
|
1199
|
+
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
1163
1200
|
turn.status = "running"
|
|
1164
1201
|
turn.started_at = now
|
|
1165
1202
|
[true, append_turn_event_locked(turn, "turnStarted", { status: "running" })]
|
|
@@ -1167,8 +1204,6 @@ module Kward
|
|
|
1167
1204
|
return unless started
|
|
1168
1205
|
|
|
1169
1206
|
@server.notify("turn/event", event)
|
|
1170
|
-
rpc_session.running_turn_id = turn.id
|
|
1171
|
-
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
1172
1207
|
|
|
1173
1208
|
if turn.cancel_requested
|
|
1174
1209
|
finish_turn(turn, "canceled")
|
|
@@ -54,10 +54,13 @@ module Kward
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def normalize_user_message(message)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
with_timestamp(
|
|
58
|
+
{
|
|
59
|
+
role: "user",
|
|
60
|
+
content: normalize_content(MessageAccess.content(message))
|
|
61
|
+
},
|
|
62
|
+
message
|
|
63
|
+
)
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def normalize_assistant_message(message)
|
|
@@ -76,7 +79,7 @@ module Kward
|
|
|
76
79
|
result = { role: "assistant", content: content }
|
|
77
80
|
error_message = ToolCall.value(message, :errorMessage) || ToolCall.value(message, :error_message)
|
|
78
81
|
result[:errorMessage] = error_message unless error_message.to_s.empty?
|
|
79
|
-
result
|
|
82
|
+
with_timestamp(result, message)
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
def normalize_tool_result_message(message)
|
|
@@ -96,6 +99,12 @@ module Kward
|
|
|
96
99
|
|
|
97
100
|
details = tool_result_details(message, matching_call, content)
|
|
98
101
|
result[:details] = details unless details.empty?
|
|
102
|
+
with_timestamp(result, message)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def with_timestamp(result, message)
|
|
106
|
+
timestamp = ToolCall.value(message, :timestamp)
|
|
107
|
+
result[:timestamp] = timestamp if timestamp
|
|
99
108
|
result
|
|
100
109
|
end
|
|
101
110
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Namespace for operating-system command sandboxing.
|
|
2
|
+
module Kward
|
|
3
|
+
module Sandbox
|
|
4
|
+
# Describes the enforcement a platform runner can provide.
|
|
5
|
+
class Capabilities
|
|
6
|
+
attr_reader :backend, :reason
|
|
7
|
+
|
|
8
|
+
def initialize(available:, filesystem_enforced:, child_network_enforced:, backend:, reason: nil)
|
|
9
|
+
@available = available == true
|
|
10
|
+
@filesystem_enforced = filesystem_enforced == true
|
|
11
|
+
@child_network_enforced = child_network_enforced == true
|
|
12
|
+
@backend = backend.to_s
|
|
13
|
+
@reason = reason&.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def available?
|
|
17
|
+
@available
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def filesystem_enforced?
|
|
21
|
+
@filesystem_enforced
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def child_network_enforced?
|
|
25
|
+
@child_network_enforced
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
available: available?,
|
|
31
|
+
filesystemEnforced: filesystem_enforced?,
|
|
32
|
+
childNetworkEnforced: child_network_enforced?,
|
|
33
|
+
backend: backend,
|
|
34
|
+
reason: reason
|
|
35
|
+
}.compact
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|