kward 0.67.1 → 0.69.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/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +54 -0
- data/Gemfile.lock +8 -2
- data/README.md +37 -30
- data/Rakefile +14 -1
- data/doc/authentication.md +84 -43
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +27 -2
- data/doc/extensibility.md +90 -129
- data/doc/getting-started.md +53 -57
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -99
- data/doc/releasing.md +10 -9
- data/doc/rpc.md +7 -7
- data/doc/usage.md +125 -141
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +30 -3
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +229 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +227 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +134 -0
- data/lib/kward/cli/rendering.rb +378 -0
- data/lib/kward/cli/runtime_helpers.rb +170 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +669 -0
- data/lib/kward/cli/slash_commands.rb +114 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +157 -0
- data/lib/kward/cli.rb +52 -2792
- data/lib/kward/cli_transcript_formatter.rb +40 -12
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +31 -9
- data/lib/kward/config_files.rb +78 -34
- data/lib/kward/conversation.rb +110 -13
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +144 -14
- data/lib/kward/message_access.rb +29 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +14 -10
- data/lib/kward/model/model_info.rb +160 -4
- data/lib/kward/model/payloads.rb +254 -22
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +387 -25
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +63 -7
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +43 -11
- data/lib/kward/rpc/session_manager.rb +139 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +50 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +154 -25
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +3 -2
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +42 -4
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +20 -17
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +27 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +56 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require "base64"
|
|
2
1
|
require "securerandom"
|
|
3
2
|
require "thread"
|
|
4
3
|
require "time"
|
|
@@ -13,6 +12,8 @@ require_relative "../events"
|
|
|
13
12
|
require_relative "../export_path"
|
|
14
13
|
require_relative "../memory/manager"
|
|
15
14
|
require_relative "../message_access"
|
|
15
|
+
require_relative "../message_text"
|
|
16
|
+
require_relative "../session_tree_tool_display"
|
|
16
17
|
require_relative "../model/model_info"
|
|
17
18
|
require_relative "../plugin_registry"
|
|
18
19
|
require_relative "../prompts/commands"
|
|
@@ -23,17 +24,36 @@ require_relative "../tools/tool_call"
|
|
|
23
24
|
require_relative "../tools/registry"
|
|
24
25
|
require_relative "../transcript_export"
|
|
25
26
|
require_relative "../workspace"
|
|
27
|
+
require_relative "attachment_normalizer"
|
|
28
|
+
require_relative "config_manager"
|
|
26
29
|
require_relative "prompt_bridge"
|
|
27
30
|
require_relative "runtime_payloads"
|
|
31
|
+
require_relative "session_metrics"
|
|
32
|
+
require_relative "session_tree"
|
|
33
|
+
require_relative "session_tree_rows"
|
|
28
34
|
require_relative "tool_event_normalizer"
|
|
29
35
|
require_relative "transcript_normalizer"
|
|
30
36
|
|
|
37
|
+
# Namespace for the Kward CLI agent runtime.
|
|
31
38
|
module Kward
|
|
39
|
+
# JSON-RPC backend namespace used by UI clients.
|
|
32
40
|
module RPC
|
|
41
|
+
# Owns RPC-visible session lifecycle, async turn queues, and frontend events.
|
|
42
|
+
#
|
|
43
|
+
# `Server` handles JSON-RPC framing/dispatch; `SessionManager` handles the
|
|
44
|
+
# product state behind those methods. It creates/resumes `SessionStore`
|
|
45
|
+
# sessions, builds agents with RPC prompt bridges, serializes turn events for
|
|
46
|
+
# clients, coordinates cancellation and follow-up queues, and integrates
|
|
47
|
+
# memory/plugin hooks for RPC sessions.
|
|
48
|
+
#
|
|
49
|
+
# Keep JSON-RPC wire shape normalization in the `RPC::*Normalizer` classes,
|
|
50
|
+
# persistence in `SessionStore`, and model/tool behavior in `Agent` and
|
|
51
|
+
# `ToolRegistry`. This class should coordinate those pieces rather than own
|
|
52
|
+
# their low-level mechanics.
|
|
33
53
|
class SessionManager
|
|
34
54
|
RECENT_EVENT_LIMIT = 1_000
|
|
35
|
-
RPC_ATTACHMENT_MAX_BYTES =
|
|
36
|
-
RPC_IMAGE_MIME_TYPES =
|
|
55
|
+
RPC_ATTACHMENT_MAX_BYTES = AttachmentNormalizer::MAX_BYTES
|
|
56
|
+
RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
|
|
37
57
|
STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
|
|
38
58
|
FOOTER_REFRESH_INTERVAL = 1.0
|
|
39
59
|
WORKER_STOP = Object.new.freeze
|
|
@@ -41,17 +61,32 @@ module Kward
|
|
|
41
61
|
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
42
62
|
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, keyword_init: true)
|
|
43
63
|
|
|
44
|
-
|
|
64
|
+
# Creates an object for RPC session lifecycle and turn coordination.
|
|
65
|
+
def initialize(
|
|
66
|
+
server:,
|
|
67
|
+
client: Client.new,
|
|
68
|
+
config_dir: ConfigFiles.config_dir,
|
|
69
|
+
config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
|
|
70
|
+
context_usage: ContextUsage.new,
|
|
71
|
+
session_trash: SessionTrash.new
|
|
72
|
+
)
|
|
45
73
|
@server = server
|
|
46
74
|
@client = client
|
|
47
75
|
@config_dir = config_dir
|
|
76
|
+
@config_manager = config_manager
|
|
48
77
|
@context_usage = context_usage
|
|
78
|
+
@session_metrics = SessionMetrics.new(context_usage: context_usage)
|
|
49
79
|
@session_trash = session_trash
|
|
50
80
|
@sessions = {}
|
|
51
81
|
@turns = {}
|
|
52
82
|
@mutex = Mutex.new
|
|
53
83
|
end
|
|
54
84
|
|
|
85
|
+
# Creates a new RPC session or resumes the remembered session when allowed.
|
|
86
|
+
#
|
|
87
|
+
# Returns the normalized session payload expected by RPC clients. The RPC
|
|
88
|
+
# session id is separate from the persisted session id so one persisted file
|
|
89
|
+
# can be closed and reopened by different client connections.
|
|
55
90
|
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
|
|
56
91
|
workspace_root = validate_workspace_root(workspace_root)
|
|
57
92
|
store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
|
|
@@ -61,7 +96,7 @@ module Kward
|
|
|
61
96
|
end
|
|
62
97
|
|
|
63
98
|
conversation = new_conversation(workspace_root: workspace_root)
|
|
64
|
-
session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
99
|
+
session = store.create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
65
100
|
session.rename(name) unless name.to_s.strip.empty?
|
|
66
101
|
session.attach(conversation)
|
|
67
102
|
rpc_session = build_rpc_session(store, session, conversation, workspace_root)
|
|
@@ -80,6 +115,7 @@ module Kward
|
|
|
80
115
|
session, conversation = store.load(
|
|
81
116
|
location[:path],
|
|
82
117
|
workspace: configured_workspace(root),
|
|
118
|
+
provider: current_model[:provider],
|
|
83
119
|
model: current_model_id,
|
|
84
120
|
reasoning_effort: current_reasoning_effort
|
|
85
121
|
)
|
|
@@ -102,12 +138,14 @@ module Kward
|
|
|
102
138
|
.map { |info| session_info_payload(info, workspace_root: root) }
|
|
103
139
|
end
|
|
104
140
|
|
|
141
|
+
# Renames the persisted session attached to an RPC session id.
|
|
105
142
|
def rename_session(session_id:, name:)
|
|
106
143
|
rpc_session = fetch_session(session_id)
|
|
107
144
|
rpc_session.session.rename(name)
|
|
108
145
|
session_payload(rpc_session)
|
|
109
146
|
end
|
|
110
147
|
|
|
148
|
+
# Creates an independent copy of the current conversation branch.
|
|
111
149
|
def clone_session(session_id:)
|
|
112
150
|
source = fetch_session(session_id)
|
|
113
151
|
session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
|
|
@@ -118,6 +156,7 @@ module Kward
|
|
|
118
156
|
session_payload(rpc_session)
|
|
119
157
|
end
|
|
120
158
|
|
|
159
|
+
# Compacts an RPC session and emits start/end events for UI progress.
|
|
121
160
|
def compact_session(session_id:, custom_instructions: "")
|
|
122
161
|
rpc_session = fetch_session(session_id)
|
|
123
162
|
emit_session_event(rpc_session, "compactionStart", {})
|
|
@@ -135,10 +174,11 @@ module Kward
|
|
|
135
174
|
raise e
|
|
136
175
|
end
|
|
137
176
|
|
|
177
|
+
# Lists user-message entries that can be used as fork points.
|
|
138
178
|
def fork_messages(session_id:)
|
|
139
179
|
rpc_session = fetch_session(session_id)
|
|
140
180
|
{
|
|
141
|
-
messages:
|
|
181
|
+
messages: session_tree_helper(rpc_session).entries.filter_map do |record|
|
|
142
182
|
message = record["message"]
|
|
143
183
|
next unless message.is_a?(Hash) && message_role(message) == "user"
|
|
144
184
|
|
|
@@ -147,10 +187,12 @@ module Kward
|
|
|
147
187
|
}
|
|
148
188
|
end
|
|
149
189
|
|
|
190
|
+
# Creates a new session from history before the selected user message.
|
|
150
191
|
def fork_session(session_id:, entry_id:)
|
|
151
192
|
source = fetch_session(session_id)
|
|
152
|
-
|
|
153
|
-
|
|
193
|
+
tree = session_tree_helper(source)
|
|
194
|
+
entries = tree.entries
|
|
195
|
+
resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
|
|
154
196
|
selected_index = entries.index { |record| record["id"].to_s == resolved_entry_id.to_s }
|
|
155
197
|
selected = selected_index && entries[selected_index]
|
|
156
198
|
raise ArgumentError, "Unknown fork entryId: #{entry_id}" unless selected
|
|
@@ -160,6 +202,7 @@ module Kward
|
|
|
160
202
|
|
|
161
203
|
session, conversation = source.store.create_independent_from_messages(
|
|
162
204
|
entries[0...selected_index].filter_map { |record| record["message"] },
|
|
205
|
+
provider: source.conversation.provider,
|
|
163
206
|
model: source.conversation.model,
|
|
164
207
|
reasoning_effort: source.conversation.reasoning_effort,
|
|
165
208
|
parent_session: source.session
|
|
@@ -175,28 +218,32 @@ module Kward
|
|
|
175
218
|
}
|
|
176
219
|
end
|
|
177
220
|
|
|
221
|
+
# Returns the flattened session tree rows consumed by RPC clients.
|
|
178
222
|
def session_tree(session_id:)
|
|
179
223
|
rpc_session = fetch_session(session_id)
|
|
180
224
|
{ items: flatten_session_tree(rpc_session) }
|
|
181
225
|
end
|
|
182
226
|
|
|
227
|
+
# Persists a label override for one tree entry.
|
|
183
228
|
def set_tree_label(session_id:, entry_id:, label: nil)
|
|
184
229
|
rpc_session = fetch_session(session_id)
|
|
185
230
|
rpc_session.session.append_label_change(entry_id, label)
|
|
186
231
|
{ ok: true }
|
|
187
232
|
end
|
|
188
233
|
|
|
234
|
+
# Moves the active branch to a tree entry, optionally summarizing abandoned history.
|
|
189
235
|
def navigate_tree(session_id:, entry_id:, summarize: false, custom_instructions: nil)
|
|
190
236
|
rpc_session = fetch_session(session_id)
|
|
191
|
-
|
|
192
|
-
|
|
237
|
+
tree = session_tree_helper(rpc_session)
|
|
238
|
+
entries = tree.entries
|
|
239
|
+
resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
|
|
193
240
|
entry = rpc_session.store.session_entry(rpc_session.session.path, resolved_entry_id)
|
|
194
241
|
raise ArgumentError, "Unknown tree entryId: #{entry_id}" unless entry
|
|
195
242
|
|
|
196
|
-
raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless
|
|
243
|
+
raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless tree.selectable_entry?(entry)
|
|
197
244
|
|
|
198
245
|
message = entry["message"]
|
|
199
|
-
user_entry =
|
|
246
|
+
user_entry = tree.user_entry?(entry)
|
|
200
247
|
target_leaf = user_entry ? entry["parentId"] : entry["id"]
|
|
201
248
|
editor_text = user_entry ? full_message_text(message) : nil
|
|
202
249
|
previous_leaf = rpc_session.session.leaf_id
|
|
@@ -217,6 +264,7 @@ module Kward
|
|
|
217
264
|
}.compact
|
|
218
265
|
end
|
|
219
266
|
|
|
267
|
+
# Exports the current transcript in markdown or JSON format.
|
|
220
268
|
def export_session(session_id:, path: nil, format: nil)
|
|
221
269
|
rpc_session = fetch_session(session_id)
|
|
222
270
|
format = export_format(format)
|
|
@@ -226,6 +274,7 @@ module Kward
|
|
|
226
274
|
{ path: path, format: format }
|
|
227
275
|
end
|
|
228
276
|
|
|
277
|
+
# Deletes the backing session file through the configured trash strategy.
|
|
229
278
|
def delete_session(session_id:)
|
|
230
279
|
rpc_session = fetch_session(session_id)
|
|
231
280
|
path = rpc_session.session.path
|
|
@@ -234,12 +283,14 @@ module Kward
|
|
|
234
283
|
{ deleted: deleted, path: path }
|
|
235
284
|
end
|
|
236
285
|
|
|
286
|
+
# Stops workers and removes an RPC session from the live session map.
|
|
237
287
|
def close_session(session_id:)
|
|
238
288
|
rpc_session = fetch_session(session_id)
|
|
239
289
|
close_rpc_session(rpc_session)
|
|
240
290
|
{ closed: true }
|
|
241
291
|
end
|
|
242
292
|
|
|
293
|
+
# Closes idle empty sessions left behind by UI lifecycle transitions.
|
|
243
294
|
def cleanup_unused_sessions
|
|
244
295
|
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
245
296
|
rpc_sessions.reverse_each do |rpc_session|
|
|
@@ -250,11 +301,18 @@ module Kward
|
|
|
250
301
|
{ closed: true }
|
|
251
302
|
end
|
|
252
303
|
|
|
304
|
+
# Returns the normalized transcript for the active RPC session.
|
|
253
305
|
def transcript(session_id:)
|
|
254
306
|
rpc_session = fetch_session(session_id)
|
|
255
307
|
{ session: session_payload(rpc_session), messages: TranscriptNormalizer.new(rpc_session.conversation.messages).normalize }
|
|
256
308
|
end
|
|
257
309
|
|
|
310
|
+
# Queues or starts an async model turn for an RPC session.
|
|
311
|
+
#
|
|
312
|
+
# `streaming_behavior` controls busy-session behavior: create a new turn,
|
|
313
|
+
# queue a follow-up, or steer the running turn when the active provider
|
|
314
|
+
# supports native steering. The returned turn id is used for status,
|
|
315
|
+
# cancellation, and event replay.
|
|
258
316
|
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
|
|
259
317
|
rpc_session = fetch_session(session_id)
|
|
260
318
|
normalized_attachments = normalize_attachments(attachments)
|
|
@@ -439,13 +497,30 @@ module Kward
|
|
|
439
497
|
normalize_model(provider: provider, id: model, model: model, contextWindow: context_window, current: true)
|
|
440
498
|
end
|
|
441
499
|
|
|
500
|
+
def session_model(rpc_session)
|
|
501
|
+
current = current_model
|
|
502
|
+
provider = rpc_session.conversation.provider || current[:provider]
|
|
503
|
+
model = rpc_session.conversation.model || current[:id]
|
|
504
|
+
reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
|
|
505
|
+
reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
|
|
506
|
+
context_window = current[:contextWindow] if provider == current[:provider] && model == current[:id]
|
|
507
|
+
normalize_model(
|
|
508
|
+
provider: provider,
|
|
509
|
+
id: model,
|
|
510
|
+
model: model,
|
|
511
|
+
reasoningEffort: reasoning_effort,
|
|
512
|
+
contextWindow: context_window,
|
|
513
|
+
current: true
|
|
514
|
+
)
|
|
515
|
+
end
|
|
516
|
+
|
|
442
517
|
def in_flight_steer_supported?
|
|
443
518
|
supports_in_flight_steer?
|
|
444
519
|
end
|
|
445
520
|
|
|
446
521
|
def runtime_state(session_id:)
|
|
447
522
|
rpc_session = fetch_session(session_id)
|
|
448
|
-
model =
|
|
523
|
+
model = session_model(rpc_session)
|
|
449
524
|
compaction_settings = self.compaction_settings
|
|
450
525
|
auto_compaction_reserve_tokens = compaction_reserve_tokens(
|
|
451
526
|
context_window: model[:contextWindow],
|
|
@@ -459,7 +534,7 @@ module Kward
|
|
|
459
534
|
steering_supported: supports_in_flight_steer?,
|
|
460
535
|
auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
|
|
461
536
|
active_persona_label: active_persona_label(rpc_session),
|
|
462
|
-
message_count: message_count(rpc_session.conversation),
|
|
537
|
+
message_count: @session_metrics.message_count(rpc_session.conversation),
|
|
463
538
|
pending_count: pending_turn_count(rpc_session.id),
|
|
464
539
|
compaction_enabled: compaction_settings.enabled,
|
|
465
540
|
workspace_guardrails_enabled: workspace_guardrails_enabled?
|
|
@@ -469,8 +544,8 @@ module Kward
|
|
|
469
544
|
def runtime_stats(session_id:)
|
|
470
545
|
rpc_session = fetch_session(session_id)
|
|
471
546
|
session = session_payload(rpc_session)
|
|
472
|
-
counts = message_stats(rpc_session.conversation)
|
|
473
|
-
model =
|
|
547
|
+
counts = @session_metrics.message_stats(rpc_session.conversation)
|
|
548
|
+
model = session_model(rpc_session)
|
|
474
549
|
compaction_settings = self.compaction_settings
|
|
475
550
|
auto_compaction_reserve_tokens = compaction_reserve_tokens(
|
|
476
551
|
context_window: model[:contextWindow],
|
|
@@ -481,7 +556,7 @@ module Kward
|
|
|
481
556
|
counts: counts,
|
|
482
557
|
model: model,
|
|
483
558
|
auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
|
|
484
|
-
context_usage: context_usage(rpc_session, model),
|
|
559
|
+
context_usage: @session_metrics.context_usage(rpc_session, model, client: @client),
|
|
485
560
|
compaction_enabled: compaction_settings.enabled
|
|
486
561
|
)
|
|
487
562
|
end
|
|
@@ -501,7 +576,13 @@ module Kward
|
|
|
501
576
|
sessions.each do |rpc_session|
|
|
502
577
|
rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
|
|
503
578
|
rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
|
|
504
|
-
|
|
579
|
+
if registry.footer_renderer
|
|
580
|
+
start_footer_worker(rpc_session)
|
|
581
|
+
emit_footer_update(rpc_session)
|
|
582
|
+
else
|
|
583
|
+
stop_footer_worker(rpc_session)
|
|
584
|
+
clear_footer_update(rpc_session)
|
|
585
|
+
end
|
|
505
586
|
end
|
|
506
587
|
end
|
|
507
588
|
|
|
@@ -529,6 +610,7 @@ module Kward
|
|
|
529
610
|
def new_conversation(workspace_root: Dir.pwd)
|
|
530
611
|
Conversation.new(
|
|
531
612
|
workspace_root: workspace_root,
|
|
613
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
532
614
|
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
533
615
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
|
|
534
616
|
plugin_registry: plugin_registry
|
|
@@ -540,8 +622,7 @@ module Kward
|
|
|
540
622
|
reasoning_effort = current_reasoning_effort
|
|
541
623
|
sessions = @mutex.synchronize { @sessions.values }
|
|
542
624
|
sessions.each do |rpc_session|
|
|
543
|
-
rpc_session.conversation.update_runtime_context!(model: model, reasoning_effort: reasoning_effort)
|
|
544
|
-
rpc_session.session.update_runtime(model: model, reasoning_effort: reasoning_effort)
|
|
625
|
+
rpc_session.conversation.update_runtime_context!(provider: current_model[:provider], model: model, reasoning_effort: reasoning_effort)
|
|
545
626
|
end
|
|
546
627
|
end
|
|
547
628
|
|
|
@@ -621,42 +702,6 @@ module Kward
|
|
|
621
702
|
@mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
|
|
622
703
|
end
|
|
623
704
|
|
|
624
|
-
def message_count(conversation)
|
|
625
|
-
conversation.messages.count { |message| message_role(message) != "system" }
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def context_usage(rpc_session, model)
|
|
629
|
-
context_parts = if @client.respond_to?(:current_context_parts)
|
|
630
|
-
@client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
|
|
631
|
-
else
|
|
632
|
-
{ provider: model[:provider], model: model[:id], messages: rpc_session.conversation.messages, tools: rpc_session.tool_registry.schemas }
|
|
633
|
-
end
|
|
634
|
-
@context_usage.call(
|
|
635
|
-
provider: model[:provider],
|
|
636
|
-
model: model[:id],
|
|
637
|
-
context_window: model[:contextWindow],
|
|
638
|
-
context_parts: context_parts
|
|
639
|
-
)
|
|
640
|
-
end
|
|
641
|
-
|
|
642
|
-
def message_stats(conversation)
|
|
643
|
-
conversation.messages.each_with_object({ userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0, totalMessages: 0 }) do |message, counts|
|
|
644
|
-
role = message_role(message)
|
|
645
|
-
next if role == "system"
|
|
646
|
-
|
|
647
|
-
counts[:totalMessages] += 1
|
|
648
|
-
case role
|
|
649
|
-
when "user"
|
|
650
|
-
counts[:userMessages] += 1
|
|
651
|
-
when "assistant"
|
|
652
|
-
counts[:assistantMessages] += 1
|
|
653
|
-
counts[:toolCalls] += tool_calls(message).length
|
|
654
|
-
when "tool", "toolResult"
|
|
655
|
-
counts[:toolResults] += 1
|
|
656
|
-
end
|
|
657
|
-
end
|
|
658
|
-
end
|
|
659
|
-
|
|
660
705
|
def tool_calls(message)
|
|
661
706
|
MessageAccess.tool_calls(message)
|
|
662
707
|
end
|
|
@@ -669,24 +714,15 @@ module Kward
|
|
|
669
714
|
MessageAccess.content(message)
|
|
670
715
|
end
|
|
671
716
|
|
|
672
|
-
def
|
|
673
|
-
|
|
674
|
-
end
|
|
675
|
-
|
|
676
|
-
def resolve_tree_entry_id(entries, entry_id)
|
|
677
|
-
id = entry_id.to_s
|
|
678
|
-
return id if entries.any? { |record| record["id"].to_s == id }
|
|
679
|
-
|
|
680
|
-
match = id.match(/\Amessage:(\d+)\z/)
|
|
681
|
-
return entries[match[1].to_i]&.dig("id") if match
|
|
682
|
-
|
|
683
|
-
id
|
|
717
|
+
def session_tree_helper(rpc_session)
|
|
718
|
+
SessionTree.new(rpc_session)
|
|
684
719
|
end
|
|
685
720
|
|
|
686
721
|
def reload_rpc_session(rpc_session)
|
|
687
722
|
session, conversation = rpc_session.store.load(
|
|
688
723
|
rpc_session.session.path,
|
|
689
724
|
workspace: configured_workspace(rpc_session.workspace_root),
|
|
725
|
+
provider: current_model[:provider],
|
|
690
726
|
model: current_model_id,
|
|
691
727
|
reasoning_effort: current_reasoning_effort
|
|
692
728
|
)
|
|
@@ -700,242 +736,19 @@ module Kward
|
|
|
700
736
|
def flatten_session_tree(rpc_session)
|
|
701
737
|
roots = rpc_session.store.session_tree(rpc_session.session.path)
|
|
702
738
|
current_leaf = rpc_session.session.leaf_id || rpc_session.store.current_leaf(rpc_session.session.path)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
|
|
710
|
-
entry = node[:source]["entry"] || {}
|
|
711
|
-
entry_id = entry["id"].to_s
|
|
712
|
-
formatted = tree_entry_display(entry, tool_calls_by_id)
|
|
713
|
-
display_indent = multiple_roots ? [indent - 1, 0].max : indent
|
|
714
|
-
result << {
|
|
715
|
-
entryId: entry_id,
|
|
716
|
-
parentId: entry["parentId"],
|
|
717
|
-
role: formatted[:role],
|
|
718
|
-
text: formatted[:text],
|
|
719
|
-
current: !current_leaf.to_s.empty? && entry_id == current_leaf.to_s,
|
|
720
|
-
depth: display_indent,
|
|
721
|
-
isLast: is_last,
|
|
722
|
-
ancestorContinues: gutters.map { |gutter| gutter[:show] },
|
|
723
|
-
activePath: active_path.include?(entry_id),
|
|
724
|
-
selectable: selectable_tree_entry?(entry),
|
|
725
|
-
label: node[:source]["label"] || entry["resolvedLabel"],
|
|
726
|
-
labelTimestamp: node[:source]["labelTimestamp"],
|
|
727
|
-
prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
|
|
728
|
-
}.compact
|
|
729
|
-
|
|
730
|
-
children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
|
|
731
|
-
multiple_children = children.length > 1
|
|
732
|
-
child_indent = if multiple_children
|
|
733
|
-
indent + 1
|
|
734
|
-
elsif just_branched && indent.positive?
|
|
735
|
-
indent + 1
|
|
736
|
-
else
|
|
737
|
-
indent
|
|
738
|
-
end
|
|
739
|
-
connector_position = [display_indent - 1, 0].max
|
|
740
|
-
child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
|
|
741
|
-
children.each_with_index do |child, index|
|
|
742
|
-
walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
|
|
743
|
-
end
|
|
744
|
-
end
|
|
745
|
-
|
|
746
|
-
visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
|
|
747
|
-
walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
|
|
748
|
-
end
|
|
749
|
-
result
|
|
750
|
-
end
|
|
751
|
-
|
|
752
|
-
def user_tree_entry?(entry)
|
|
753
|
-
message = entry["message"]
|
|
754
|
-
message.is_a?(Hash) && message_role(message) == "user"
|
|
755
|
-
end
|
|
756
|
-
|
|
757
|
-
def selectable_tree_entry?(entry)
|
|
758
|
-
!entry["id"].to_s.empty? && ["message", "compaction", "branch_summary"].include?(entry["type"])
|
|
759
|
-
end
|
|
760
|
-
|
|
761
|
-
def nearest_visible_parent_by_id(user_entries, entries)
|
|
762
|
-
user_ids = user_entries.map { |entry| entry["id"].to_s }.to_h { |id| [id, true] }
|
|
763
|
-
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
764
|
-
user_entries.each_with_object({}) do |entry, parents|
|
|
765
|
-
parent_id = entry["parentId"]
|
|
766
|
-
while parent_id && by_id[parent_id.to_s] && !user_ids[parent_id.to_s]
|
|
767
|
-
parent_id = by_id[parent_id.to_s]["parentId"]
|
|
768
|
-
end
|
|
769
|
-
parents[entry["id"].to_s] = user_ids[parent_id.to_s] ? parent_id.to_s : nil
|
|
770
|
-
end
|
|
771
|
-
end
|
|
772
|
-
|
|
773
|
-
def active_path_ids(entries, leaf_id)
|
|
774
|
-
by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
|
|
775
|
-
ids = []
|
|
776
|
-
current = by_id[leaf_id.to_s]
|
|
777
|
-
while current
|
|
778
|
-
ids << current["id"].to_s
|
|
779
|
-
current = by_id[current["parentId"].to_s]
|
|
780
|
-
end
|
|
781
|
-
ids
|
|
782
|
-
end
|
|
783
|
-
|
|
784
|
-
def tree_active_path(roots, leaf_id)
|
|
785
|
-
by_id = tree_entries_by_id(roots)
|
|
786
|
-
ids = []
|
|
787
|
-
current = by_id[leaf_id.to_s]
|
|
788
|
-
while current
|
|
789
|
-
ids << current["id"].to_s
|
|
790
|
-
current = by_id[current["parentId"].to_s]
|
|
791
|
-
end
|
|
792
|
-
ids
|
|
793
|
-
end
|
|
794
|
-
|
|
795
|
-
def tree_entries_by_id(roots)
|
|
796
|
-
roots.each_with_object({}) do |root, map|
|
|
797
|
-
stack = [root]
|
|
798
|
-
until stack.empty?
|
|
799
|
-
node = stack.pop
|
|
800
|
-
entry = node["entry"] || {}
|
|
801
|
-
map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
|
|
802
|
-
stack.concat(Array(node["children"]))
|
|
803
|
-
end
|
|
804
|
-
end
|
|
805
|
-
end
|
|
806
|
-
|
|
807
|
-
def visible_tree_nodes(node, current_leaf)
|
|
808
|
-
children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child, current_leaf) }
|
|
809
|
-
return children if hidden_tree_entry?(node["entry"] || {}, current_leaf)
|
|
810
|
-
|
|
811
|
-
[{ source: node, children: children }]
|
|
812
|
-
end
|
|
813
|
-
|
|
814
|
-
def hidden_tree_entry?(entry, current_leaf)
|
|
815
|
-
return false if current_leaf && entry["id"].to_s == current_leaf.to_s
|
|
816
|
-
return false unless entry["type"] == "message"
|
|
817
|
-
|
|
818
|
-
message = entry["message"]
|
|
819
|
-
return false unless message.is_a?(Hash) && message_role(message) == "assistant"
|
|
820
|
-
|
|
821
|
-
content = message_content(message)
|
|
822
|
-
content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
|
|
823
|
-
(content_tool_calls && !tree_text_content?(content)) || (!tool_calls(message).empty? && full_message_text(message).empty?)
|
|
824
|
-
end
|
|
825
|
-
|
|
826
|
-
def tree_text_content?(content)
|
|
827
|
-
Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
|
|
828
|
-
end
|
|
829
|
-
|
|
830
|
-
def tree_contains_active_path?(node, active_path)
|
|
831
|
-
entry_id = (node[:source]["entry"] || {})["id"].to_s
|
|
832
|
-
active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
def tree_tool_calls(roots)
|
|
836
|
-
roots.each_with_object({}) do |root, tool_calls_by_id|
|
|
837
|
-
stack = [root]
|
|
838
|
-
until stack.empty?
|
|
839
|
-
node = stack.pop
|
|
840
|
-
entry = node["entry"] || {}
|
|
841
|
-
message = entry["message"]
|
|
842
|
-
if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
|
|
843
|
-
tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
|
|
844
|
-
end
|
|
845
|
-
stack.concat(Array(node["children"]))
|
|
846
|
-
end
|
|
847
|
-
end
|
|
848
|
-
end
|
|
849
|
-
|
|
850
|
-
def tree_entry_display(entry, tool_calls_by_id = {})
|
|
851
|
-
case entry["type"]
|
|
852
|
-
when "message"
|
|
853
|
-
message = entry["message"] || {}
|
|
854
|
-
role = message_role(message).to_s
|
|
855
|
-
return { role: "tool", text: format_tool_result(message, tool_calls_by_id) } if ["tool", "toolResult"].include?(role)
|
|
856
|
-
return { role: role.empty? ? "message" : role, text: display_message_text(message) }
|
|
857
|
-
when "compaction"
|
|
858
|
-
return { role: "compaction", text: display_message_text(entry["message"] || {}) }
|
|
859
|
-
when "branch_summary"
|
|
860
|
-
return { role: "summary", text: truncate_tree_text(entry["summary"]) }
|
|
861
|
-
end
|
|
862
|
-
|
|
863
|
-
{ role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
|
|
864
|
-
end
|
|
865
|
-
|
|
866
|
-
def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
|
|
867
|
-
return "" if display_indent.to_i <= 0
|
|
868
|
-
|
|
869
|
-
connector_position = show_connector ? display_indent - 1 : -1
|
|
870
|
-
(0...(display_indent * 3)).map do |index|
|
|
871
|
-
level = index / 3
|
|
872
|
-
position = index % 3
|
|
873
|
-
gutter = gutters.find { |candidate| candidate[:position] == level }
|
|
874
|
-
|
|
875
|
-
if gutter
|
|
876
|
-
position.zero? && gutter[:show] ? "│" : " "
|
|
877
|
-
elsif show_connector && level == connector_position
|
|
878
|
-
if position.zero?
|
|
879
|
-
is_last ? "└" : "├"
|
|
880
|
-
elsif position == 1
|
|
881
|
-
foldable ? "⊟" : "─"
|
|
882
|
-
else
|
|
883
|
-
" "
|
|
884
|
-
end
|
|
885
|
-
else
|
|
886
|
-
" "
|
|
887
|
-
end
|
|
888
|
-
end.join
|
|
889
|
-
end
|
|
890
|
-
|
|
891
|
-
def format_tool_result(message, tool_calls_by_id)
|
|
892
|
-
tool_call = tool_calls_by_id[tree_message_tool_call_id(message).to_s]
|
|
893
|
-
return format_tool_call(tool_call) if tool_call
|
|
894
|
-
|
|
895
|
-
name = tree_message_tool_name(message).to_s
|
|
896
|
-
name = "tool" if name.empty?
|
|
897
|
-
"[#{name}]"
|
|
898
|
-
end
|
|
899
|
-
|
|
900
|
-
def tree_message_tool_call_id(message)
|
|
901
|
-
MessageAccess.tool_call_id(message) || ToolCall.value(message, :toolCallId)
|
|
902
|
-
end
|
|
903
|
-
|
|
904
|
-
def tree_message_tool_name(message)
|
|
905
|
-
MessageAccess.name(message) || ToolCall.value(message, :toolName)
|
|
906
|
-
end
|
|
907
|
-
|
|
908
|
-
def format_tool_call(tool_call)
|
|
909
|
-
name = ToolCall.display_name(tool_call)
|
|
910
|
-
args = ToolCall.arguments(tool_call)
|
|
911
|
-
case name
|
|
912
|
-
when "read"
|
|
913
|
-
path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
|
|
914
|
-
offset = args["offset"] || args[:offset]
|
|
915
|
-
limit = args["limit"] || args[:limit]
|
|
916
|
-
display = path.to_s
|
|
917
|
-
if offset || limit
|
|
918
|
-
start_line = offset || 1
|
|
919
|
-
end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
|
|
920
|
-
display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
|
|
921
|
-
end
|
|
922
|
-
"[read: #{display}]"
|
|
923
|
-
when "write", "edit"
|
|
924
|
-
path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
|
|
925
|
-
"[#{name}: #{path}]"
|
|
926
|
-
when "bash"
|
|
927
|
-
command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
|
|
928
|
-
"[bash: #{command.length > 50 ? "#{command.slice(0, 50)}..." : command}]"
|
|
929
|
-
else
|
|
930
|
-
serialized = JSON.dump(args)
|
|
931
|
-
"[#{name}: #{serialized.length > 40 ? "#{serialized.slice(0, 40)}..." : serialized}]"
|
|
932
|
-
end
|
|
739
|
+
tree = session_tree_helper(rpc_session)
|
|
740
|
+
SessionTreeRows.new(
|
|
741
|
+
roots: roots,
|
|
742
|
+
current_leaf: current_leaf,
|
|
743
|
+
selectable: ->(entry) { tree.selectable_entry?(entry) }
|
|
744
|
+
).rows
|
|
933
745
|
end
|
|
934
746
|
|
|
935
747
|
def summarize_branch(rpc_session, from_id:, to_id:, custom_instructions: nil)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
748
|
+
tree = session_tree_helper(rpc_session)
|
|
749
|
+
entries = tree.entries
|
|
750
|
+
active = tree.active_path_ids(entries, from_id)
|
|
751
|
+
target = tree.active_path_ids(entries, to_id)
|
|
939
752
|
target_lookup = target.to_h { |id| [id, true] }
|
|
940
753
|
abandoned = active.reject { |id| target_lookup[id] }
|
|
941
754
|
messages = entries.select { |entry| abandoned.include?(entry["id"].to_s) }.filter_map { |entry| entry["message"] }
|
|
@@ -959,13 +772,7 @@ module Kward
|
|
|
959
772
|
end
|
|
960
773
|
|
|
961
774
|
def full_message_text(message)
|
|
962
|
-
|
|
963
|
-
text = if content.is_a?(Array)
|
|
964
|
-
content.filter_map { |part| part["text"] || part[:text] }.join("\n")
|
|
965
|
-
else
|
|
966
|
-
content.to_s
|
|
967
|
-
end
|
|
968
|
-
text.strip
|
|
775
|
+
MessageText.full_text(message)
|
|
969
776
|
end
|
|
970
777
|
|
|
971
778
|
def supports_in_flight_steer?
|
|
@@ -1012,42 +819,7 @@ module Kward
|
|
|
1012
819
|
end
|
|
1013
820
|
|
|
1014
821
|
def normalize_attachments(attachments)
|
|
1015
|
-
|
|
1016
|
-
raise ArgumentError, "attachments must be an array" unless attachments.is_a?(Array)
|
|
1017
|
-
|
|
1018
|
-
attachments.map { |attachment| normalize_attachment(attachment) }
|
|
1019
|
-
end
|
|
1020
|
-
|
|
1021
|
-
def normalize_attachment(attachment)
|
|
1022
|
-
raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
|
|
1023
|
-
|
|
1024
|
-
type = ToolCall.value(attachment, :type).to_s
|
|
1025
|
-
raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
|
|
1026
|
-
|
|
1027
|
-
mime_type = normalize_attachment_mime_type(ToolCall.value(attachment, :mimeType) || ToolCall.value(attachment, :mime_type) || ToolCall.value(attachment, :media_type))
|
|
1028
|
-
raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
|
|
1029
|
-
|
|
1030
|
-
data = ToolCall.value(attachment, :data).to_s
|
|
1031
|
-
raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
|
|
1032
|
-
raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
|
|
1033
|
-
declared_size = ToolCall.value(attachment, :sizeBytes) || ToolCall.value(attachment, :size_bytes)
|
|
1034
|
-
raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
|
|
1035
|
-
|
|
1036
|
-
decoded_size = Base64.strict_decode64(data).bytesize
|
|
1037
|
-
raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
|
|
1038
|
-
|
|
1039
|
-
result = { type: "image", data: data, mimeType: mime_type }
|
|
1040
|
-
name = ToolCall.value(attachment, :name)
|
|
1041
|
-
result[:alt] = name.to_s unless name.to_s.empty?
|
|
1042
|
-
result
|
|
1043
|
-
rescue ArgumentError => e
|
|
1044
|
-
raise e if e.message.start_with?("Unsupported", "Image attachment", "attachment")
|
|
1045
|
-
|
|
1046
|
-
raise ArgumentError, "Image attachment data must be valid base64"
|
|
1047
|
-
end
|
|
1048
|
-
|
|
1049
|
-
def normalize_attachment_mime_type(mime_type)
|
|
1050
|
-
mime_type.to_s.downcase
|
|
822
|
+
AttachmentNormalizer.new(max_bytes: RPC_ATTACHMENT_MAX_BYTES, mime_types: RPC_IMAGE_MIME_TYPES).normalize(attachments)
|
|
1051
823
|
end
|
|
1052
824
|
|
|
1053
825
|
def plugin_registry
|
|
@@ -1058,6 +830,11 @@ module Kward
|
|
|
1058
830
|
PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
|
|
1059
831
|
end
|
|
1060
832
|
|
|
833
|
+
# Wires together the per-RPC-session runtime objects.
|
|
834
|
+
#
|
|
835
|
+
# This is the RPC counterpart to the CLI interactive setup: attach plugin
|
|
836
|
+
# context, create a prompt bridge for UI questions/footer output, advertise
|
|
837
|
+
# tools with the workspace guardrail policy, and build the shared `Agent`.
|
|
1061
838
|
def build_rpc_session(store, session, conversation, workspace_root)
|
|
1062
839
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
1063
840
|
id = SecureRandom.uuid
|
|
@@ -1093,11 +870,11 @@ module Kward
|
|
|
1093
870
|
end
|
|
1094
871
|
|
|
1095
872
|
def workspace_guardrails_enabled?
|
|
1096
|
-
|
|
873
|
+
@config_manager.workspace_guardrails_enabled?
|
|
1097
874
|
end
|
|
1098
875
|
|
|
1099
876
|
def session_auto_resume_enabled?
|
|
1100
|
-
|
|
877
|
+
@config_manager.session_auto_resume_enabled?
|
|
1101
878
|
end
|
|
1102
879
|
|
|
1103
880
|
def config_path
|
|
@@ -1200,6 +977,11 @@ module Kward
|
|
|
1200
977
|
rpc_session.worker = nil if rpc_session.worker == Thread.current
|
|
1201
978
|
end
|
|
1202
979
|
|
|
980
|
+
# Executes one queued turn and emits normalized RPC events.
|
|
981
|
+
#
|
|
982
|
+
# This method is intentionally the only place that calls `Agent#ask` for RPC
|
|
983
|
+
# turns. Keep event translation near this boundary so CLI rendering and RPC
|
|
984
|
+
# protocol details do not leak into `Agent`.
|
|
1203
985
|
def run_turn(rpc_session, turn)
|
|
1204
986
|
rpc_session.running_turn_id = turn.id
|
|
1205
987
|
turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
|
|
@@ -1364,7 +1146,7 @@ module Kward
|
|
|
1364
1146
|
|
|
1365
1147
|
def emit_footer_update(rpc_session)
|
|
1366
1148
|
renderer = plugin_registry.footer_renderer
|
|
1367
|
-
return unless renderer
|
|
1149
|
+
return clear_footer_update(rpc_session) unless renderer
|
|
1368
1150
|
|
|
1369
1151
|
text = begin
|
|
1370
1152
|
context = PluginRegistry::Context.new(
|
|
@@ -1384,6 +1166,13 @@ module Kward
|
|
|
1384
1166
|
@server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
|
|
1385
1167
|
end
|
|
1386
1168
|
|
|
1169
|
+
def clear_footer_update(rpc_session)
|
|
1170
|
+
return if rpc_session.last_footer_text.to_s.empty?
|
|
1171
|
+
|
|
1172
|
+
rpc_session.last_footer_text = ""
|
|
1173
|
+
@server.notify("ui/footer", { sessionId: rpc_session.id, text: "" })
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1387
1176
|
def emit_turn_event(turn, type, payload)
|
|
1388
1177
|
event = {
|
|
1389
1178
|
sequence: turn.next_sequence,
|
|
@@ -1434,6 +1223,9 @@ module Kward
|
|
|
1434
1223
|
name: info.name,
|
|
1435
1224
|
firstMessage: info.first_message.to_s,
|
|
1436
1225
|
messageCount: info.message_count.to_i,
|
|
1226
|
+
provider: info.provider,
|
|
1227
|
+
model: info.model,
|
|
1228
|
+
reasoningEffort: info.reasoning_effort,
|
|
1437
1229
|
parentId: info.parent_id,
|
|
1438
1230
|
parentPath: info.parent_path,
|
|
1439
1231
|
depth: info.depth.to_i,
|