kward 0.77.0 → 0.78.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 +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +39 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -0
- data/doc/security.md +4 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- 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/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +14 -0
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +29 -9
- data/lib/kward/config_files.rb +10 -0
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +53 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +2 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +35 -0
- data/lib/kward/rpc/session_manager.rb +1 -0
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- 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 +37 -4
- 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/templates/default/kward_navigation.rb +2 -1
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +7 -1
|
@@ -0,0 +1,299 @@
|
|
|
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)
|
|
27
|
+
@server = server
|
|
28
|
+
@client = client
|
|
29
|
+
@chats = {}
|
|
30
|
+
@turns = {}
|
|
31
|
+
@subscriptions = {}
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def supported_types
|
|
36
|
+
plugin_registry.tab_types.select(&:rpc)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def list
|
|
40
|
+
{ chats: supported_types.map { |type| type_payload(type) } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def open(type_id:)
|
|
44
|
+
type = supported_types.find { |entry| entry.id == type_id.to_s } || raise(ArgumentError, "Unknown RPC plugin chat: #{type_id}")
|
|
45
|
+
chat = chat_for(type)
|
|
46
|
+
return chat_payload(chat) if chat.driver.respond_to?(:transcript_page)
|
|
47
|
+
|
|
48
|
+
chat_payload(chat).merge(messages: TranscriptNormalizer.new(chat.driver.messages).normalize)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def transcript(chat_id:, limit: nil, before: nil)
|
|
52
|
+
chat = fetch_chat(chat_id)
|
|
53
|
+
page = transcript_page(chat.driver, limit: limit, before: before)
|
|
54
|
+
{
|
|
55
|
+
chat: chat_payload(chat),
|
|
56
|
+
messages: TranscriptNormalizer.new(page.fetch(:messages)).normalize,
|
|
57
|
+
hasMore: page.fetch(:has_more),
|
|
58
|
+
nextBefore: page[:next_before]
|
|
59
|
+
}.compact
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def subscribe(chat_id:)
|
|
63
|
+
chat = fetch_chat(chat_id)
|
|
64
|
+
@mutex.synchronize { @subscriptions[chat.id] = true }
|
|
65
|
+
{ chat: chat_payload(chat), subscribed: true }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def unsubscribe(chat_id:)
|
|
69
|
+
chat = fetch_chat(chat_id)
|
|
70
|
+
@mutex.synchronize { @subscriptions.delete(chat.id) }
|
|
71
|
+
{ chat: chat_payload(chat), subscribed: false }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def start_turn(chat_id:, input:, attachments: [])
|
|
75
|
+
chat = fetch_chat(chat_id)
|
|
76
|
+
normalized_attachments = AttachmentNormalizer.new.normalize(attachments)
|
|
77
|
+
turn = Turn.new(
|
|
78
|
+
id: SecureRandom.uuid,
|
|
79
|
+
chat_id: chat.id,
|
|
80
|
+
input: input_with_attachments(input, normalized_attachments),
|
|
81
|
+
display_input: input.to_s,
|
|
82
|
+
status: "queued",
|
|
83
|
+
cancellation: Cancellation.new,
|
|
84
|
+
created_at: Time.now.utc.iso8601(3),
|
|
85
|
+
events: [],
|
|
86
|
+
next_sequence: 1,
|
|
87
|
+
mutex: Mutex.new
|
|
88
|
+
)
|
|
89
|
+
@mutex.synchronize { @turns[turn.id] = turn }
|
|
90
|
+
chat.queue << turn.id
|
|
91
|
+
ensure_worker(chat)
|
|
92
|
+
emit_event(turn, "turnQueued", { status: "queued" })
|
|
93
|
+
turn_payload(turn)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cancel_turn(turn_id:)
|
|
97
|
+
turn = fetch_turn(turn_id)
|
|
98
|
+
queued = turn.mutex.synchronize do
|
|
99
|
+
turn.cancellation.cancel!
|
|
100
|
+
turn.status == "queued"
|
|
101
|
+
end
|
|
102
|
+
emit_event(turn, "turnCancelRequested", {})
|
|
103
|
+
finish_turn(turn, "canceled") if queued
|
|
104
|
+
turn_payload(turn)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def turn_status(turn_id:)
|
|
108
|
+
turn_payload(fetch_turn(turn_id))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def turn_events(turn_id:, after_sequence: 0)
|
|
112
|
+
turn = fetch_turn(turn_id)
|
|
113
|
+
events = turn.mutex.synchronize { turn.events.select { |event| event[:sequence].to_i > after_sequence.to_i } }
|
|
114
|
+
{ turn: turn_payload(turn), events: events }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def list_turns(chat_id: nil, active: false)
|
|
118
|
+
turns = @mutex.synchronize { @turns.values.dup }
|
|
119
|
+
turns.select! { |turn| turn.chat_id == chat_id.to_s } if chat_id
|
|
120
|
+
turns.select! { |turn| %w[queued running].include?(turn.status) } if active
|
|
121
|
+
{ turns: turns.map { |turn| turn_payload(turn) } }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def shutdown
|
|
125
|
+
chats = @mutex.synchronize { @chats.values.dup }
|
|
126
|
+
chats.each do |chat|
|
|
127
|
+
chat.queue << WORKER_STOP if chat.worker&.alive?
|
|
128
|
+
chat.worker&.join(0.2)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def plugin_registry
|
|
135
|
+
@plugin_registry ||= PluginRegistry.load
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def chat_for(type)
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
@chats[type.id] ||= begin
|
|
141
|
+
descriptor = { "kind" => "plugin", "plugin_tab_type" => type.id, "label" => type.title }
|
|
142
|
+
driver = type.handler.call(PluginTabHost.new(client: @client, workspace_root: Dir.pwd), descriptor)
|
|
143
|
+
raise "Plugin chat #{type.id.inspect} did not return a tab driver." unless driver
|
|
144
|
+
|
|
145
|
+
Chat.new(id: type.id, type: type, driver: driver, queue: Queue.new)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def fetch_chat(chat_id)
|
|
151
|
+
@mutex.synchronize { @chats[chat_id.to_s] } || open(type_id: chat_id).then { @mutex.synchronize { @chats[chat_id.to_s] } }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def fetch_turn(turn_id)
|
|
155
|
+
@mutex.synchronize { @turns[turn_id.to_s] } || raise(ArgumentError, "Unknown plugin chat turn: #{turn_id}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def ensure_worker(chat)
|
|
159
|
+
return if chat.worker&.alive?
|
|
160
|
+
|
|
161
|
+
chat.worker = Thread.new do
|
|
162
|
+
loop do
|
|
163
|
+
turn_id = chat.queue.pop
|
|
164
|
+
break if turn_id.equal?(WORKER_STOP)
|
|
165
|
+
|
|
166
|
+
turn = fetch_turn(turn_id)
|
|
167
|
+
run_turn(chat, turn) unless turn.cancellation.cancelled?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
chat.worker.report_on_exception = false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def run_turn(chat, turn)
|
|
174
|
+
turn.mutex.synchronize do
|
|
175
|
+
return if terminal?(turn)
|
|
176
|
+
|
|
177
|
+
turn.status = "running"
|
|
178
|
+
turn.started_at = Time.now.utc.iso8601(3)
|
|
179
|
+
end
|
|
180
|
+
chat.running_turn_id = turn.id
|
|
181
|
+
emit_event(turn, "turnStarted", { status: "running" })
|
|
182
|
+
chat.driver.submit(turn.input, display_input: turn.display_input, cancellation: turn.cancellation) do |event|
|
|
183
|
+
handle_driver_event(chat, turn, event)
|
|
184
|
+
end
|
|
185
|
+
finish_turn(turn, turn.cancellation.cancelled? ? "canceled" : "completed")
|
|
186
|
+
rescue Cancellation::CancelledError
|
|
187
|
+
finish_turn(turn, "canceled")
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
turn.mutex.synchronize { turn.error = { message: e.message, code: e.class.name, fatal: false } }
|
|
190
|
+
finish_turn(turn, "failed")
|
|
191
|
+
ensure
|
|
192
|
+
chat.running_turn_id = nil if chat.running_turn_id == turn.id
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def handle_driver_event(chat, turn, event)
|
|
196
|
+
notify_plugin_tab_transcript_event(chat, event) if chat.type.transcript_events
|
|
197
|
+
|
|
198
|
+
type, payload = case event
|
|
199
|
+
when Events::ReasoningDelta then ["reasoningDelta", { delta: event.delta }]
|
|
200
|
+
when Events::ReasoningBoundary then ["reasoningBoundary", {}]
|
|
201
|
+
when Events::AssistantDelta then ["assistantDelta", { delta: event.delta }]
|
|
202
|
+
when Events::AssistantMessage then ["assistantMessage", { message: TranscriptNormalizer.new([event.message]).normalize.first }]
|
|
203
|
+
when Events::Retry then ["modelRetry", retry_event_payload(event)]
|
|
204
|
+
when Events::ToolCall then ["toolCall", tool_call_payload(event.tool_call)]
|
|
205
|
+
when Events::ToolResult then ["toolResult", tool_result_payload(event.tool_call, event.content)]
|
|
206
|
+
when Events::Answer then ["answer", { content: event.content }]
|
|
207
|
+
end
|
|
208
|
+
emit_event(turn, type, payload) if type
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def notify_plugin_tab_transcript_event(chat, event)
|
|
212
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
213
|
+
|
|
214
|
+
context = PluginRegistry::Context.new(conversation: chat.driver, workspace_root: Dir.pwd)
|
|
215
|
+
plugin_registry.notify_transcript_event(event, context)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def retry_event_payload(event)
|
|
219
|
+
{ 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
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def tool_call_payload(tool_call)
|
|
223
|
+
{ toolCallId: ToolCall.id(tool_call), toolName: ToolCall.name(tool_call), args: ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call)) }.compact
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def tool_result_payload(tool_call, content)
|
|
227
|
+
{ 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
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def finish_turn(turn, status)
|
|
231
|
+
event = turn.mutex.synchronize do
|
|
232
|
+
next nil if terminal?(turn)
|
|
233
|
+
|
|
234
|
+
turn.status = status
|
|
235
|
+
turn.finished_at = Time.now.utc.iso8601(3)
|
|
236
|
+
append_event(turn, "turnFinished", { status: status, error: turn.error }.compact)
|
|
237
|
+
end
|
|
238
|
+
notify_event(event) if event
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def emit_event(turn, type, payload)
|
|
242
|
+
event = turn.mutex.synchronize { append_event(turn, type, payload) }
|
|
243
|
+
notify_event(event)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def append_event(turn, type, payload)
|
|
247
|
+
event = {
|
|
248
|
+
sequence: turn.next_sequence,
|
|
249
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
250
|
+
chatId: turn.chat_id,
|
|
251
|
+
turnId: turn.id,
|
|
252
|
+
type: type,
|
|
253
|
+
payload: payload
|
|
254
|
+
}
|
|
255
|
+
turn.next_sequence += 1
|
|
256
|
+
turn.events << event
|
|
257
|
+
turn.events.shift while turn.events.length > EVENT_LIMIT
|
|
258
|
+
event
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def notify_event(event)
|
|
262
|
+
subscribed = @mutex.synchronize { @subscriptions[event[:chatId]] }
|
|
263
|
+
@server.notify("pluginChat/event", event) if subscribed
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def input_with_attachments(input, attachments)
|
|
267
|
+
return input.to_s if attachments.empty?
|
|
268
|
+
|
|
269
|
+
[{ type: "text", text: input.to_s }] + attachments.map do |attachment|
|
|
270
|
+
{ type: "image", media_type: attachment[:mimeType], data: attachment[:data], alt: attachment[:alt] }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def terminal?(turn)
|
|
275
|
+
%w[completed failed canceled].include?(turn.status)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def type_payload(type)
|
|
279
|
+
{ id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def transcript_page(driver, limit:, before:)
|
|
283
|
+
return { messages: driver.messages, has_more: false } unless limit && driver.respond_to?(:transcript_page)
|
|
284
|
+
|
|
285
|
+
driver.transcript_page(limit: limit, before: before)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def chat_payload(chat)
|
|
289
|
+
type_payload(chat.type).merge(id: chat.id, subscribed: @mutex.synchronize { @subscriptions[chat.id] == true })
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def turn_payload(turn)
|
|
293
|
+
turn.mutex.synchronize do
|
|
294
|
+
{ 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
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -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
|
|
@@ -92,6 +94,7 @@ module Kward
|
|
|
92
94
|
prompts: PROMPT_METHODS,
|
|
93
95
|
sessions: SESSION_METHODS,
|
|
94
96
|
turns: TURN_METHODS,
|
|
97
|
+
plugin_chats: PLUGIN_CHAT_METHODS,
|
|
95
98
|
models: MODEL_METHODS,
|
|
96
99
|
runtime: RUNTIME_METHODS,
|
|
97
100
|
runtime_settings: RUNTIME_SETTING_METHODS,
|
|
@@ -119,6 +122,7 @@ module Kward
|
|
|
119
122
|
@client = client
|
|
120
123
|
@config_manager = ConfigManager.new
|
|
121
124
|
@session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
|
|
125
|
+
@plugin_chat_manager = PluginChatManager.new(server: self, client: client)
|
|
122
126
|
@auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
|
|
123
127
|
@shutdown = false
|
|
124
128
|
end
|
|
@@ -140,6 +144,7 @@ module Kward
|
|
|
140
144
|
end
|
|
141
145
|
end
|
|
142
146
|
ensure
|
|
147
|
+
@plugin_chat_manager.shutdown
|
|
143
148
|
@session_manager.shutdown_sessions
|
|
144
149
|
end
|
|
145
150
|
|
|
@@ -349,6 +354,28 @@ module Kward
|
|
|
349
354
|
@session_manager.list_turns(session_id: params["sessionId"])
|
|
350
355
|
when TURN_METHODS[5]
|
|
351
356
|
@session_manager.list_turns(session_id: params["sessionId"], active: true)
|
|
357
|
+
when PLUGIN_CHAT_METHODS[0]
|
|
358
|
+
@plugin_chat_manager.list
|
|
359
|
+
when PLUGIN_CHAT_METHODS[1]
|
|
360
|
+
@plugin_chat_manager.open(type_id: params.fetch("typeId"))
|
|
361
|
+
when PLUGIN_CHAT_METHODS[2]
|
|
362
|
+
@plugin_chat_manager.transcript(chat_id: params.fetch("chatId"), limit: params["limit"], before: params["before"])
|
|
363
|
+
when PLUGIN_CHAT_METHODS[3]
|
|
364
|
+
@plugin_chat_manager.subscribe(chat_id: params.fetch("chatId"))
|
|
365
|
+
when PLUGIN_CHAT_METHODS[4]
|
|
366
|
+
@plugin_chat_manager.unsubscribe(chat_id: params.fetch("chatId"))
|
|
367
|
+
when PLUGIN_CHAT_METHODS[5]
|
|
368
|
+
@plugin_chat_manager.start_turn(chat_id: params.fetch("chatId"), input: params.fetch("input"), attachments: params["attachments"] || [])
|
|
369
|
+
when PLUGIN_CHAT_METHODS[6]
|
|
370
|
+
@plugin_chat_manager.cancel_turn(turn_id: params.fetch("turnId"))
|
|
371
|
+
when PLUGIN_CHAT_METHODS[7]
|
|
372
|
+
@plugin_chat_manager.turn_status(turn_id: params.fetch("turnId"))
|
|
373
|
+
when PLUGIN_CHAT_METHODS[8]
|
|
374
|
+
@plugin_chat_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
|
|
375
|
+
when PLUGIN_CHAT_METHODS[9]
|
|
376
|
+
@plugin_chat_manager.list_turns(chat_id: params["chatId"])
|
|
377
|
+
when PLUGIN_CHAT_METHODS[10]
|
|
378
|
+
@plugin_chat_manager.list_turns(chat_id: params["chatId"], active: true)
|
|
352
379
|
when UI_METHODS[0]
|
|
353
380
|
@session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
|
|
354
381
|
when TOOL_APPROVAL_METHODS[0]
|
|
@@ -392,6 +419,14 @@ module Kward
|
|
|
392
419
|
tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
|
|
393
420
|
updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
|
|
394
421
|
},
|
|
422
|
+
pluginChats: {
|
|
423
|
+
supported: @plugin_chat_manager.supported_types.any?,
|
|
424
|
+
methods: PLUGIN_CHAT_METHODS,
|
|
425
|
+
notification: "pluginChat/event",
|
|
426
|
+
subscriptions: { supported: true, methods: PLUGIN_CHAT_METHODS.values_at(3, 4), requiredForLiveEvents: true },
|
|
427
|
+
attachments: { supported: true, method: PLUGIN_CHAT_METHODS[5], encoding: "base64", mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES, maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES },
|
|
428
|
+
types: @plugin_chat_manager.supported_types.map { |type| { id: type.id, name: type.name, title: type.title, singleton: type.singleton }.compact }
|
|
429
|
+
},
|
|
395
430
|
turns: {
|
|
396
431
|
mode: "async",
|
|
397
432
|
perSessionConcurrency: 1,
|
|
@@ -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
|
|
|
@@ -6,6 +6,7 @@ require "tmpdir"
|
|
|
6
6
|
require "uri"
|
|
7
7
|
require "zlib"
|
|
8
8
|
require_relative "config_files"
|
|
9
|
+
require_relative "http"
|
|
9
10
|
|
|
10
11
|
# Namespace for the Kward CLI agent runtime.
|
|
11
12
|
module Kward
|
|
@@ -56,8 +57,9 @@ module Kward
|
|
|
56
57
|
|
|
57
58
|
def download(url)
|
|
58
59
|
uri = URI(url)
|
|
60
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(uri))
|
|
59
61
|
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
|
|
60
|
-
http.
|
|
62
|
+
http.request(request)
|
|
61
63
|
end
|
|
62
64
|
raise "Starter pack download failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
63
65
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
4
|
+
# drivers implement the same small surface without becoming Kward sessions.
|
|
5
|
+
class SessionTabDriver
|
|
6
|
+
attr_reader :session, :agent
|
|
7
|
+
|
|
8
|
+
def initialize(session:, agent:)
|
|
9
|
+
@session = session
|
|
10
|
+
@agent = agent
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def messages
|
|
14
|
+
agent.conversation.messages
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def conversation
|
|
18
|
+
agent.conversation
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def submit(input, display_input:, cancellation:, steering: nil, &block)
|
|
22
|
+
options = { cancellation: cancellation }
|
|
23
|
+
options[:display_input] = display_input unless display_input.nil?
|
|
24
|
+
options[:steering] = steering if steering
|
|
25
|
+
agent.ask(input, **options, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def descriptor
|
|
29
|
+
{ "kind" => "session", "session_path" => session.path }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def session?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def supports_steering?
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def assistant_label
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Represents a persisted plugin tab whose provider plugin is unavailable.
|
|
46
|
+
# Its descriptor is retained so reinstalling the plugin restores the tab.
|
|
47
|
+
class UnavailableTabDriver
|
|
48
|
+
attr_reader :descriptor
|
|
49
|
+
|
|
50
|
+
def initialize(descriptor:, message:)
|
|
51
|
+
@descriptor = descriptor
|
|
52
|
+
@message = message
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def messages
|
|
56
|
+
[{ role: "assistant", content: @message }]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def submit(*)
|
|
60
|
+
raise @message
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def session?
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def supports_steering?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assistant_label
|
|
72
|
+
"Plugin"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Dependencies made available to a plugin tab factory. The host deliberately
|
|
77
|
+
# exposes provider transport and frontend-neutral facts, not CLI internals or
|
|
78
|
+
# workspace session state.
|
|
79
|
+
class PluginTabHost
|
|
80
|
+
attr_reader :client, :workspace_root
|
|
81
|
+
|
|
82
|
+
def initialize(client:, workspace_root:)
|
|
83
|
+
@client = client
|
|
84
|
+
@workspace_root = workspace_root
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/kward/tab_store.rb
CHANGED
|
@@ -5,32 +5,45 @@ require_relative "private_file"
|
|
|
5
5
|
|
|
6
6
|
# Namespace for the Kward CLI agent runtime.
|
|
7
7
|
module Kward
|
|
8
|
-
# Persists the terminal UI's open
|
|
8
|
+
# Persists the terminal UI's open tabs per workspace.
|
|
9
9
|
class TabStore
|
|
10
|
+
VERSION = 2
|
|
11
|
+
|
|
10
12
|
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
|
|
11
13
|
@config_dir = config_dir
|
|
12
14
|
@cwd = File.expand_path(cwd)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def load
|
|
16
|
-
return
|
|
18
|
+
return empty_state unless File.file?(path)
|
|
17
19
|
|
|
18
20
|
data = JSON.parse(File.read(path))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{ "session_paths" => paths, "labels" => labels, "active_index" => active_index }
|
|
21
|
+
return typed_state(data) if data["tabs"].is_a?(Array)
|
|
22
|
+
|
|
23
|
+
legacy_state(data)
|
|
23
24
|
rescue JSON::ParserError
|
|
24
|
-
|
|
25
|
+
empty_state
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
# `session_paths` remains supported for callers and layouts written before
|
|
29
|
+
# typed tab descriptors were introduced.
|
|
30
|
+
def save(session_paths: nil, tabs: nil, active_index:, labels: [])
|
|
31
|
+
tabs ||= Array(session_paths).filter_map.with_index do |session_path, index|
|
|
32
|
+
path = session_path.to_s
|
|
33
|
+
next if path.empty?
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
"kind" => "session",
|
|
37
|
+
"session_path" => path,
|
|
38
|
+
"label" => Array(labels)[index].to_s
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
tabs = normalize_tabs(tabs)
|
|
29
42
|
PrivateFile.write_json(path, {
|
|
43
|
+
"version" => VERSION,
|
|
30
44
|
"cwd" => @cwd,
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
|
|
45
|
+
"tabs" => tabs,
|
|
46
|
+
"active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
|
|
34
47
|
})
|
|
35
48
|
end
|
|
36
49
|
|
|
@@ -40,6 +53,55 @@ module Kward
|
|
|
40
53
|
|
|
41
54
|
private
|
|
42
55
|
|
|
56
|
+
def empty_state
|
|
57
|
+
{ "tabs" => [], "session_paths" => [], "labels" => [], "active_index" => 0 }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def typed_state(data)
|
|
61
|
+
tabs = normalize_tabs(data["tabs"])
|
|
62
|
+
state_for(tabs, data["active_index"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def legacy_state(data)
|
|
66
|
+
paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
|
|
67
|
+
labels = Array(data["labels"]).map(&:to_s)
|
|
68
|
+
tabs = paths.each_with_index.map do |session_path, index|
|
|
69
|
+
{ "kind" => "session", "session_path" => session_path, "label" => labels[index].to_s }
|
|
70
|
+
end
|
|
71
|
+
state_for(tabs, data["active_index"])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def state_for(tabs, active_index)
|
|
75
|
+
{
|
|
76
|
+
"tabs" => tabs,
|
|
77
|
+
"session_paths" => tabs.filter_map { |tab| tab["session_path"] if tab["kind"] == "session" },
|
|
78
|
+
"labels" => tabs.map { |tab| tab["label"].to_s },
|
|
79
|
+
"active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_tabs(tabs)
|
|
84
|
+
session_paths = {}
|
|
85
|
+
|
|
86
|
+
Array(tabs).filter_map do |tab|
|
|
87
|
+
next unless tab.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
normalized = tab.transform_keys(&:to_s)
|
|
90
|
+
kind = normalized["kind"].to_s
|
|
91
|
+
next if kind.empty?
|
|
92
|
+
next if kind == "session" && normalized["session_path"].to_s.empty?
|
|
93
|
+
|
|
94
|
+
if kind == "session"
|
|
95
|
+
session_path = File.expand_path(normalized["session_path"].to_s, @cwd)
|
|
96
|
+
next if session_paths[session_path]
|
|
97
|
+
|
|
98
|
+
session_paths[session_path] = true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
normalized
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
43
105
|
def workspace_key
|
|
44
106
|
Digest::SHA256.hexdigest(@cwd)[0, 24]
|
|
45
107
|
end
|
|
@@ -37,7 +37,11 @@ module Kward
|
|
|
37
37
|
},
|
|
38
38
|
path: {
|
|
39
39
|
type: "string",
|
|
40
|
-
description: "Repo-relative path."
|
|
40
|
+
description: "Repo-relative path; inferred from GitHub blob URLs when omitted."
|
|
41
|
+
},
|
|
42
|
+
ref: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Git branch, tag, or commit; inferred from GitHub blob URLs when omitted."
|
|
41
45
|
},
|
|
42
46
|
start_line: {
|
|
43
47
|
type: "integer",
|
|
@@ -63,7 +67,9 @@ module Kward
|
|
|
63
67
|
# Executes the tool and returns model-facing output text.
|
|
64
68
|
def call(args, _conversation, cancellation: nil)
|
|
65
69
|
cancellation&.raise_if_cancelled!
|
|
66
|
-
@code_search.call(args)
|
|
70
|
+
return @code_search.call(args) unless cancellation
|
|
71
|
+
|
|
72
|
+
@code_search.call(args, cancellation: cancellation)
|
|
67
73
|
end
|
|
68
74
|
end
|
|
69
75
|
end
|