kward 0.76.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/.github/workflows/ci.yml +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +13 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/code-search.md +9 -6
- data/doc/composer.md +219 -0
- data/doc/configuration.md +114 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -22
- data/doc/security.md +184 -0
- data/doc/tabs.md +4 -3
- data/doc/usage.md +15 -32
- data/doc/web-search.md +4 -2
- data/lib/kward/ansi.rb +2 -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/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +24 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +11 -35
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +154 -36
- data/lib/kward/cli.rb +30 -26
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +28 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/image_attachments.rb +1 -1
- 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/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +71 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +20 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +48 -41
- data/lib/kward/rpc/session_manager.rb +76 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +18 -5
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- 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/base.rb +14 -0
- 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 +45 -17
- data/lib/kward/tools/search/code.rb +47 -13
- data/lib/kward/tools/search/web.rb +61 -18
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +11 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
|
@@ -19,13 +19,11 @@ module Kward
|
|
|
19
19
|
{ name: "export", description: "Export the current session as Markdown.", argument_hint: "[path]" },
|
|
20
20
|
{ name: "compact", description: "Compact the current conversation context.", argument_hint: "[instructions]" },
|
|
21
21
|
{ name: "redraw", description: "Refresh the visible terminal.", argument_hint: "" },
|
|
22
|
-
{ name: "settings", description: "Configure
|
|
22
|
+
{ name: "settings", description: "Configure Kward interactively.", argument_hint: "" },
|
|
23
23
|
{ name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
|
|
24
24
|
{ name: "model", description: "Select the default model.", argument_hint: "" },
|
|
25
25
|
{ name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
|
|
26
26
|
{ name: "reload", description: "Reload installed plugins.", argument_hint: "" },
|
|
27
|
-
{ name: "workers", description: "Open the worker pipeline.", argument_hint: "[new|do <task>]" },
|
|
28
|
-
{ name: "queue", description: "Manage the tab-backed worker queue.", argument_hint: "[add|list|open|run|suspend|resume]" },
|
|
29
27
|
{ name: "git", description: "Review uncommitted changes and commit them.", argument_hint: "" },
|
|
30
28
|
{ name: "diff", description: "Open the file changes recorded in this session.", argument_hint: "" },
|
|
31
29
|
{ name: "files", description: "Browse project files.", argument_hint: "" },
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -3,9 +3,25 @@ require_relative "config_files"
|
|
|
3
3
|
# Namespace for the Kward CLI agent runtime.
|
|
4
4
|
module Kward
|
|
5
5
|
# System prompt assembly from config, workspace instructions, memory, and plugins.
|
|
6
|
+
#
|
|
7
|
+
# Call {system_message} for the provider-ready message or {prompt_sections}
|
|
8
|
+
# when a frontend needs to inspect the labeled sources separately.
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
6
11
|
module Prompts
|
|
7
12
|
module_function
|
|
8
13
|
|
|
14
|
+
# Builds the system message sent to a model provider.
|
|
15
|
+
#
|
|
16
|
+
# @param workspace_root [String] active workspace root
|
|
17
|
+
# @param include_workspace_personality [Boolean] include persona and plugin context
|
|
18
|
+
# @param model [String, nil] model used for persona matching
|
|
19
|
+
# @param reasoning_effort [String, nil] reasoning effort used for persona matching
|
|
20
|
+
# @param now [Time] time used for persona modifiers
|
|
21
|
+
# @param memory_context [String, nil] retrieved memory text
|
|
22
|
+
# @param plugin_context [String, nil] trusted plugin-provided instructions
|
|
23
|
+
# @return [Hash] role/content system message
|
|
24
|
+
# @api public
|
|
9
25
|
def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
10
26
|
{
|
|
11
27
|
role: "system",
|
|
@@ -17,6 +33,10 @@ module Kward
|
|
|
17
33
|
prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).map { |section| section[:content] }
|
|
18
34
|
end
|
|
19
35
|
|
|
36
|
+
# Returns labeled prompt sections for inspection by CLI and RPC frontends.
|
|
37
|
+
#
|
|
38
|
+
# @return [Array<Hash>] section hashes with label, content, and optional source
|
|
39
|
+
# @api public
|
|
20
40
|
def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
|
|
21
41
|
sections = [prompt_section("Built-in system prompt", base_prompt)]
|
|
22
42
|
sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
|
|
@@ -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
|
@@ -5,7 +5,6 @@ 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 "../workers"
|
|
9
8
|
require_relative "../workspace"
|
|
10
9
|
require_relative "../telemetry/logger"
|
|
11
10
|
require_relative "../telemetry/stats"
|
|
@@ -14,6 +13,7 @@ require_relative "config_manager"
|
|
|
14
13
|
require_relative "mcp_status"
|
|
15
14
|
require_relative "redactor"
|
|
16
15
|
require_relative "session_manager"
|
|
16
|
+
require_relative "plugin_chat_manager"
|
|
17
17
|
require_relative "transport"
|
|
18
18
|
|
|
19
19
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -33,6 +33,7 @@ module Kward
|
|
|
33
33
|
# `SessionManager`, `AuthManager`, and `ConfigManager`. When adding an RPC
|
|
34
34
|
# feature, update dispatch, capabilities, docs, and tests together so clients
|
|
35
35
|
# can trust `initialize` as the source of supported behavior.
|
|
36
|
+
# @api public
|
|
36
37
|
class Server
|
|
37
38
|
PROTOCOL_VERSION = 1
|
|
38
39
|
JSONRPC_VERSION = "2.0"
|
|
@@ -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
|
|
@@ -70,7 +72,6 @@ module Kward
|
|
|
70
72
|
"memory/forget", "memory/promote", "memory/relax", "memory/inspect",
|
|
71
73
|
"memory/why", "memory/summarize"
|
|
72
74
|
].freeze
|
|
73
|
-
WORKER_METHODS = ["workers/list", "workers/show"].freeze
|
|
74
75
|
COMMAND_METHODS = ["commands/list", "commands/run"].freeze
|
|
75
76
|
STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
|
|
76
77
|
CONFIG_METHODS = ["config/read", "config/update"].freeze
|
|
@@ -93,12 +94,12 @@ module Kward
|
|
|
93
94
|
prompts: PROMPT_METHODS,
|
|
94
95
|
sessions: SESSION_METHODS,
|
|
95
96
|
turns: TURN_METHODS,
|
|
97
|
+
plugin_chats: PLUGIN_CHAT_METHODS,
|
|
96
98
|
models: MODEL_METHODS,
|
|
97
99
|
runtime: RUNTIME_METHODS,
|
|
98
100
|
runtime_settings: RUNTIME_SETTING_METHODS,
|
|
99
101
|
auth: AUTH_METHODS,
|
|
100
102
|
memory: MEMORY_METHODS,
|
|
101
|
-
workers: WORKER_METHODS,
|
|
102
103
|
commands: COMMAND_METHODS,
|
|
103
104
|
startup_resources: STARTUP_RESOURCE_METHODS,
|
|
104
105
|
config: CONFIG_METHODS,
|
|
@@ -110,15 +111,19 @@ module Kward
|
|
|
110
111
|
RPC_METHODS = METHOD_GROUPS.values.flatten.freeze
|
|
111
112
|
|
|
112
113
|
# Creates the RPC server and its stateful managers.
|
|
113
|
-
|
|
114
|
+
#
|
|
115
|
+
# @param input [IO] framed JSON-RPC input stream
|
|
116
|
+
# @param output [IO] framed JSON-RPC output stream
|
|
117
|
+
# @param error_output [IO, nil] redacted diagnostic stream
|
|
118
|
+
# @param client [Client] model-provider client shared by sessions
|
|
119
|
+
def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new)
|
|
114
120
|
@transport = Transport.new(input: input, output: output)
|
|
115
121
|
@error_output = error_output
|
|
116
122
|
@client = client
|
|
117
123
|
@config_manager = ConfigManager.new
|
|
118
124
|
@session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
|
|
125
|
+
@plugin_chat_manager = PluginChatManager.new(server: self, client: client)
|
|
119
126
|
@auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
|
|
120
|
-
@worker_store = Workers::Store.new
|
|
121
|
-
@experimental_workers = experimental_workers
|
|
122
127
|
@shutdown = false
|
|
123
128
|
end
|
|
124
129
|
|
|
@@ -139,6 +144,7 @@ module Kward
|
|
|
139
144
|
end
|
|
140
145
|
end
|
|
141
146
|
ensure
|
|
147
|
+
@plugin_chat_manager.shutdown
|
|
142
148
|
@session_manager.shutdown_sessions
|
|
143
149
|
end
|
|
144
150
|
|
|
@@ -162,6 +168,10 @@ module Kward
|
|
|
162
168
|
})
|
|
163
169
|
end
|
|
164
170
|
|
|
171
|
+
# Writes a redacted server diagnostic without exposing error payload data.
|
|
172
|
+
#
|
|
173
|
+
# @param error [Exception]
|
|
174
|
+
# @return [void]
|
|
165
175
|
def log_error(error)
|
|
166
176
|
@error_output.puts("Kward RPC error: #{Redactor.redact_string(error.message)}") if @error_output
|
|
167
177
|
end
|
|
@@ -277,12 +287,6 @@ module Kward
|
|
|
277
287
|
@session_manager.memory_why(session_id: params["sessionId"])
|
|
278
288
|
when MEMORY_METHODS[13]
|
|
279
289
|
@session_manager.memory_summarize(session_id: params.fetch("sessionId"))
|
|
280
|
-
when WORKER_METHODS[0]
|
|
281
|
-
require_experimental_workers!
|
|
282
|
-
workers_list(params)
|
|
283
|
-
when WORKER_METHODS[1]
|
|
284
|
-
require_experimental_workers!
|
|
285
|
-
workers_show(params)
|
|
286
290
|
when AUTH_METHODS[0]
|
|
287
291
|
@auth_manager.status
|
|
288
292
|
when AUTH_METHODS[1]
|
|
@@ -350,6 +354,28 @@ module Kward
|
|
|
350
354
|
@session_manager.list_turns(session_id: params["sessionId"])
|
|
351
355
|
when TURN_METHODS[5]
|
|
352
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)
|
|
353
379
|
when UI_METHODS[0]
|
|
354
380
|
@session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
|
|
355
381
|
when TOOL_APPROVAL_METHODS[0]
|
|
@@ -393,6 +419,14 @@ module Kward
|
|
|
393
419
|
tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "kward-tree-items-v1" },
|
|
394
420
|
updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
|
|
395
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
|
+
},
|
|
396
430
|
turns: {
|
|
397
431
|
mode: "async",
|
|
398
432
|
perSessionConcurrency: 1,
|
|
@@ -429,7 +463,7 @@ module Kward
|
|
|
429
463
|
reasoning: { start: false, delta: true, boundary: true, end: false },
|
|
430
464
|
modelRetry: { supported: true, event: "modelRetry" },
|
|
431
465
|
steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
|
|
432
|
-
tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
|
|
466
|
+
tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, firstChangedLine: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
|
|
433
467
|
errors: true,
|
|
434
468
|
sessionUpdates: false
|
|
435
469
|
},
|
|
@@ -484,8 +518,7 @@ module Kward
|
|
|
484
518
|
logout: true
|
|
485
519
|
},
|
|
486
520
|
memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
|
|
487
|
-
|
|
488
|
-
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: ["workers"] },
|
|
521
|
+
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
|
|
489
522
|
commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
490
523
|
mcp: {
|
|
491
524
|
supported: true,
|
|
@@ -543,18 +576,6 @@ module Kward
|
|
|
543
576
|
}
|
|
544
577
|
end
|
|
545
578
|
|
|
546
|
-
def workers_capability
|
|
547
|
-
return { supported: false, reason: "experimentalWorkersFlagRequired", flag: "--experimental-workers" } unless @experimental_workers
|
|
548
|
-
|
|
549
|
-
{ supported: true, methods: WORKER_METHODS, roles: ["implementation", "request"], statuses: Workers::Worker::STATUSES, transcriptStorage: "sessions", metadataStorage: "json" }
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
def require_experimental_workers!
|
|
553
|
-
return if @experimental_workers
|
|
554
|
-
|
|
555
|
-
raise NoMethodError, "workers require --experimental-workers"
|
|
556
|
-
end
|
|
557
|
-
|
|
558
579
|
def workspace_info(root)
|
|
559
580
|
root = @session_manager.validate_workspace_root(root)
|
|
560
581
|
{ root: root, basename: File.basename(root), writable: File.writable?(root) }
|
|
@@ -715,20 +736,6 @@ module Kward
|
|
|
715
736
|
{ sections: sections }
|
|
716
737
|
end
|
|
717
738
|
|
|
718
|
-
def workers_list(params)
|
|
719
|
-
include_archived = params["includeArchived"] == true
|
|
720
|
-
workers = @worker_store.list(include_archived: include_archived)
|
|
721
|
-
{ workers: workers }
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
def workers_show(params)
|
|
725
|
-
id = params.fetch("id").to_s.delete_prefix("#")
|
|
726
|
-
worker = @worker_store.find(id)
|
|
727
|
-
return { worker: worker } if worker
|
|
728
|
-
|
|
729
|
-
raise ArgumentError, "Unknown worker: #{id}"
|
|
730
|
-
end
|
|
731
|
-
|
|
732
739
|
def auth_login_with_api_key(params)
|
|
733
740
|
result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
|
|
734
741
|
@session_manager.refresh_client_config
|