kward 0.66.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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,1122 @@
1
+ require "base64"
2
+ require "securerandom"
3
+ require "thread"
4
+ require "time"
5
+ require_relative "../agent"
6
+ require_relative "../cancellation"
7
+ require_relative "../model/client"
8
+ require_relative "../compactor"
9
+ require_relative "../config_files"
10
+ require_relative "../model/context_usage"
11
+ require_relative "../conversation"
12
+ require_relative "../events"
13
+ require_relative "../export_path"
14
+ require_relative "../memory/manager"
15
+ require_relative "../message_access"
16
+ require_relative "../model/model_info"
17
+ require_relative "../plugin_registry"
18
+ require_relative "../prompts/commands"
19
+ require_relative "../session_store"
20
+ require_relative "../steering"
21
+ require_relative "../tools/tool_call"
22
+ require_relative "../tools/registry"
23
+ require_relative "../transcript_export"
24
+ require_relative "../workspace"
25
+ require_relative "prompt_bridge"
26
+ require_relative "tool_event_normalizer"
27
+ require_relative "transcript_normalizer"
28
+
29
+ module Kward
30
+ module RPC
31
+ class SessionManager
32
+ RECENT_EVENT_LIMIT = 1_000
33
+ RPC_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
34
+ RPC_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
35
+ STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
36
+ WORKER_STOP = Object.new.freeze
37
+
38
+ RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, keyword_init: true)
39
+ Turn = Struct.new(:id, :session_id, :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)
40
+
41
+ def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new)
42
+ @server = server
43
+ @client = client
44
+ @config_dir = config_dir
45
+ @context_usage = context_usage
46
+ @sessions = {}
47
+ @turns = {}
48
+ @mutex = Mutex.new
49
+ end
50
+
51
+ def create_session(workspace_root: Dir.pwd, name: nil)
52
+ workspace_root = validate_workspace_root(workspace_root)
53
+ store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
54
+ conversation = new_conversation(workspace_root: workspace_root)
55
+ session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
56
+ session.rename(name) unless name.to_s.strip.empty?
57
+ session.attach(conversation)
58
+ rpc_session = build_rpc_session(store, session, conversation, workspace_root)
59
+ remember_session(rpc_session)
60
+ cleanup_other_unused_sessions(rpc_session)
61
+ session_payload(rpc_session)
62
+ end
63
+
64
+ def resume_session(path:, workspace_root: nil)
65
+ root = validate_workspace_root(workspace_root || Dir.pwd)
66
+ store = SessionStore.new(config_dir: @config_dir, cwd: root)
67
+ location = store.session_location(path)
68
+ root = validate_workspace_root(location[:cwd])
69
+ store = SessionStore.new(config_dir: @config_dir, cwd: root)
70
+ session, conversation = store.load(
71
+ location[:path],
72
+ workspace: Workspace.new(root: root),
73
+ model: current_model_id,
74
+ reasoning_effort: current_reasoning_effort
75
+ )
76
+ rpc_session = build_rpc_session(store, session, conversation, root)
77
+ remember_session(rpc_session)
78
+ cleanup_other_unused_sessions(rpc_session)
79
+ session_payload(rpc_session)
80
+ end
81
+
82
+ def list_sessions(workspace_root: Dir.pwd, limit: 20)
83
+ root = validate_workspace_root(workspace_root)
84
+ store = SessionStore.new(config_dir: @config_dir, cwd: root)
85
+ limit = limit.to_i <= 0 ? 20 : limit.to_i
86
+ store.recent_tree(limit: limit + active_session_count(root))
87
+ .reject { |info| active_empty_unnamed_session_info?(info, root) }
88
+ .first(limit)
89
+ .map { |info| session_info_payload(info, workspace_root: root) }
90
+ end
91
+
92
+ def rename_session(session_id:, name:)
93
+ rpc_session = fetch_session(session_id)
94
+ rpc_session.session.rename(name)
95
+ session_payload(rpc_session)
96
+ end
97
+
98
+ def clone_session(session_id:)
99
+ source = fetch_session(session_id)
100
+ session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
101
+ rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
102
+ remember_session(rpc_session)
103
+ cleanup_other_unused_sessions(rpc_session)
104
+ session_payload(rpc_session)
105
+ end
106
+
107
+ def compact_session(session_id:, custom_instructions: "")
108
+ rpc_session = fetch_session(session_id)
109
+ emit_session_event(rpc_session, "compactionStart", {})
110
+ result = Compactor.new(conversation: rpc_session.conversation, client: @client, settings: compaction_settings).compact(custom_instructions: custom_instructions)
111
+ payload = {
112
+ summary: result.summary,
113
+ firstKeptEntryId: result.first_kept_entry_id,
114
+ tokensBefore: result.tokens_before,
115
+ details: result.details
116
+ }.compact
117
+ emit_session_event(rpc_session, "compactionEnd", { result: payload, aborted: false, willRetry: false, errorMessage: nil })
118
+ payload
119
+ rescue StandardError => e
120
+ emit_session_event(rpc_session, "compactionEnd", { result: nil, aborted: true, willRetry: false, errorMessage: e.message }) if rpc_session
121
+ raise e
122
+ end
123
+
124
+ def fork_messages(session_id:)
125
+ rpc_session = fetch_session(session_id)
126
+ {
127
+ messages: entry_messages(rpc_session.conversation).each_with_index.filter_map do |message, index|
128
+ next unless message_role(message) == "user"
129
+
130
+ { entryId: entry_id(index), text: display_message_text(message) }
131
+ end
132
+ }
133
+ end
134
+
135
+ def fork_session(session_id:, entry_id:)
136
+ source = fetch_session(session_id)
137
+ index = entry_index(entry_id)
138
+ messages = entry_messages(source.conversation)
139
+ selected = messages[index] || raise(ArgumentError, "Unknown fork entryId: #{entry_id}")
140
+ raise ArgumentError, "Entry is not forkable: #{entry_id}" unless message_role(selected) == "user"
141
+
142
+ session, conversation = source.store.create_independent_from_messages(
143
+ messages[0...index],
144
+ model: source.conversation.model,
145
+ reasoning_effort: source.conversation.reasoning_effort,
146
+ parent_session: source.session
147
+ )
148
+
149
+ # ensure forked sessions retain the original persona context
150
+ rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
151
+ remember_session(rpc_session)
152
+ cleanup_other_unused_sessions(rpc_session)
153
+ {
154
+ session: session_payload(rpc_session),
155
+ text: full_message_text(selected),
156
+ cancelled: false
157
+ }
158
+ end
159
+
160
+ def export_session(session_id:, path: nil, format: nil)
161
+ rpc_session = fetch_session(session_id)
162
+ format = export_format(format)
163
+ path = export_path(rpc_session, path, format)
164
+ content = export_content(rpc_session.conversation, format)
165
+ File.write(path, content)
166
+ { path: path, format: format }
167
+ end
168
+
169
+ def delete_session(session_id:)
170
+ rpc_session = fetch_session(session_id)
171
+ path = rpc_session.session.path
172
+ close_session(session_id: session_id)
173
+ File.delete(path) if File.exist?(path)
174
+ { deleted: true, path: path }
175
+ end
176
+
177
+ def close_session(session_id:)
178
+ rpc_session = fetch_session(session_id)
179
+ close_rpc_session(rpc_session)
180
+ { closed: true }
181
+ end
182
+
183
+ def cleanup_unused_sessions
184
+ rpc_sessions = @mutex.synchronize { @sessions.values.dup }
185
+ rpc_sessions.reverse_each do |rpc_session|
186
+ next unless session_idle?(rpc_session)
187
+
188
+ close_rpc_session(rpc_session)
189
+ end
190
+ { closed: true }
191
+ end
192
+
193
+ def transcript(session_id:)
194
+ rpc_session = fetch_session(session_id)
195
+ { session: session_payload(rpc_session), messages: TranscriptNormalizer.new(rpc_session.conversation.messages).normalize }
196
+ end
197
+
198
+ def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
199
+ rpc_session = fetch_session(session_id)
200
+ normalized_attachments = normalize_attachments(attachments)
201
+ plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
202
+ content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
203
+ streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
204
+ if streaming_behavior == "steer"
205
+ steered_turn = steer_running_turn(rpc_session, content)
206
+ return steered_turn if steered_turn
207
+
208
+ streaming_behavior = "followUp"
209
+ end
210
+ turn = Turn.new(
211
+ id: SecureRandom.uuid,
212
+ session_id: rpc_session.id,
213
+ input: content,
214
+ status: "queued",
215
+ cancel_requested: false,
216
+ cancellation: Cancellation.new,
217
+ created_at: now,
218
+ events: [],
219
+ next_sequence: 1,
220
+ streaming_behavior: streaming_behavior,
221
+ plugin_command_name: plugin_command&.name,
222
+ plugin_arguments: plugin_arguments
223
+ )
224
+ @mutex.synchronize { @turns[turn.id] = turn }
225
+ rpc_session.queue << turn.id
226
+ ensure_worker(rpc_session)
227
+ emit_turn_event(turn, "turnQueued", { status: "queued" })
228
+ turn_payload(turn)
229
+ end
230
+
231
+ def cancel_turn(turn_id:)
232
+ turn = fetch_turn(turn_id)
233
+ turn.cancel_requested = true
234
+ turn.cancellation&.cancel!
235
+ emit_turn_event(turn, "turnCancelRequested", {})
236
+ if turn.status == "queued"
237
+ finish_turn(turn, "canceled")
238
+ end
239
+ turn_payload(turn)
240
+ end
241
+
242
+ def turn_status(turn_id:)
243
+ turn_payload(fetch_turn(turn_id))
244
+ end
245
+
246
+ def turn_events(turn_id:, after_sequence: 0)
247
+ turn = fetch_turn(turn_id)
248
+ after_sequence = after_sequence.to_i
249
+ {
250
+ turn: turn_payload(turn),
251
+ events: turn.events.select { |event| event[:sequence].to_i > after_sequence }
252
+ }
253
+ end
254
+
255
+ def answer_question(session_id:, question_request_id:, answers:)
256
+ rpc_session = fetch_session(session_id)
257
+ rpc_session.prompt.answer(question_request_id, answers)
258
+ { ok: true }
259
+ end
260
+
261
+ def run_command(session_id:, command:, arguments: "")
262
+ name = command.to_s.delete_prefix("/")
263
+ return { ok: false, error: "unsupported", reason: "notImplemented" } if name == "crew"
264
+ return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
265
+
266
+ run_plugin_command(session_id: session_id, command: name, arguments: arguments)
267
+ end
268
+
269
+ def memory_manager
270
+ Memory::Manager.for_config_dir(@config_dir)
271
+ end
272
+
273
+ def memory_status
274
+ manager = memory_manager
275
+ { enabled: manager.enabled?, autoSummary: manager.auto_summary_enabled?, paths: manager.paths }
276
+ end
277
+
278
+ def memory_enable
279
+ memory_manager.enable
280
+ { enabled: true }
281
+ end
282
+
283
+ def memory_disable
284
+ memory_manager.disable
285
+ { enabled: false }
286
+ end
287
+
288
+ def memory_auto_summary_enable
289
+ memory_manager.auto_summary_enable
290
+ { autoSummary: true }
291
+ end
292
+
293
+ def memory_auto_summary_disable
294
+ memory_manager.auto_summary_disable
295
+ { autoSummary: false }
296
+ end
297
+
298
+ def memory_list(include_inactive: false)
299
+ memory_manager.list(include_inactive: include_inactive)
300
+ end
301
+
302
+ def memory_add(text:, scope: nil, tags: [])
303
+ { memory: memory_manager.add_soft(text, scope: scope || "global", tags: tags) }
304
+ end
305
+
306
+ def memory_add_core(text:, scope: nil, tags: [])
307
+ { memory: memory_manager.add_core(text, scope: scope || "global", tags: tags) }
308
+ end
309
+
310
+ def memory_forget(id:)
311
+ { forgotten: memory_manager.forget_memory(id) }
312
+ end
313
+
314
+ def memory_promote(id:)
315
+ { memory: memory_manager.promote_soft_to_core(id) }
316
+ end
317
+
318
+ def memory_inspect
319
+ memory_manager.inspect_memory
320
+ end
321
+
322
+ def memory_why(session_id: nil)
323
+ if session_id
324
+ rpc_session = fetch_session(session_id)
325
+ return rpc_session.conversation.last_memory_retrieval || memory_manager.explain_retrieval
326
+ end
327
+ memory_manager.explain_retrieval
328
+ end
329
+
330
+ def memory_summarize(session_id:)
331
+ rpc_session = fetch_session(session_id)
332
+ records = memory_manager.summarize_conversation(rpc_session.conversation, client: @client)
333
+ persist_memory_state(rpc_session)
334
+ { memories: records }
335
+ end
336
+
337
+ def run_plugin_command(session_id:, command:, arguments: "")
338
+ rpc_session = fetch_session(session_id)
339
+ command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
340
+ output = []
341
+ context = PluginRegistry::Context.new(
342
+ conversation: rpc_session.conversation,
343
+ args: arguments.to_s,
344
+ session: rpc_session.session,
345
+ workspace_root: rpc_session.workspace_root,
346
+ say_callback: lambda { |message| output << message.to_s }
347
+ )
348
+ result = command.handler.call(arguments.to_s, context)
349
+ output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
350
+ { command: command.name, output: output, result: result.nil? ? nil : result.to_s }
351
+ end
352
+
353
+ def plugin_commands
354
+ plugin_registry.commands
355
+ end
356
+
357
+ def available_models
358
+ models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
359
+ normalized = models.map { |model| normalize_model(model) }
360
+ current = current_model
361
+ normalized << current if normalized.none? { |model| model[:provider] == current[:provider] && model[:id] == current[:id] }
362
+ normalized
363
+ end
364
+
365
+ def openrouter_catalog
366
+ models = @client.respond_to?(:openrouter_catalog) ? Array(@client.openrouter_catalog) : []
367
+ models.map { |model| normalize_model(model) }
368
+ end
369
+
370
+ def current_model
371
+ provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
372
+ model = @client.respond_to?(:current_model) ? @client.current_model : nil
373
+ context_window = @client.respond_to?(:current_context_window) ? @client.current_context_window : nil
374
+ normalize_model(provider: provider, id: model, model: model, contextWindow: context_window, current: true)
375
+ end
376
+
377
+ def in_flight_steer_supported?
378
+ supports_in_flight_steer?
379
+ end
380
+
381
+ def runtime_state(session_id:)
382
+ rpc_session = fetch_session(session_id)
383
+ model = current_model
384
+ compaction_settings = self.compaction_settings
385
+ auto_compaction_reserve_tokens = compaction_reserve_tokens(
386
+ context_window: model[:contextWindow],
387
+ compaction_settings: compaction_settings
388
+ )
389
+ session = session_payload(rpc_session)
390
+ pending_count = pending_turn_count(rpc_session.id)
391
+ {
392
+ model: model,
393
+ thinkingLevel: model[:reasoningEffort],
394
+ isStreaming: streaming?(rpc_session),
395
+ isCompacting: false,
396
+ steeringMode: supports_in_flight_steer? ? "in-flight" : "one-at-a-time",
397
+ followUpMode: "one-at-a-time",
398
+ sessionFile: session[:path],
399
+ sessionId: session[:persistentId],
400
+ rpcSessionId: session[:id],
401
+ persistentSessionId: session[:persistentId],
402
+ sessionName: session[:name],
403
+ autoCompactionEnabled: compaction_settings.enabled,
404
+ autoCompactionReserveTokens: auto_compaction_reserve_tokens,
405
+ autoRetryEnabled: false,
406
+ defaultProvider: model[:provider],
407
+ defaultModel: default_model_label(model),
408
+ defaultThinkingLevel: model[:reasoningEffort],
409
+ hideThinkingBlock: false,
410
+ quietStartup: false,
411
+ transport: "kward-rpc",
412
+ imageAutoResize: false,
413
+ blockImages: false,
414
+ enabledModels: [],
415
+ enableSkillCommands: true,
416
+ messageCount: message_count(rpc_session.conversation),
417
+ pendingMessageCount: pending_count
418
+ }.compact
419
+ end
420
+
421
+ def runtime_stats(session_id:)
422
+ rpc_session = fetch_session(session_id)
423
+ session = session_payload(rpc_session)
424
+ counts = message_stats(rpc_session.conversation)
425
+ model = current_model
426
+ compaction_settings = self.compaction_settings
427
+ auto_compaction_reserve_tokens = compaction_reserve_tokens(
428
+ context_window: model[:contextWindow],
429
+ compaction_settings: compaction_settings
430
+ )
431
+ {
432
+ sessionFile: session[:path],
433
+ sessionId: session[:persistentId],
434
+ rpcSessionId: session[:id],
435
+ persistentSessionId: session[:persistentId],
436
+ sessionName: session[:name],
437
+ userMessages: counts[:userMessages],
438
+ assistantMessages: counts[:assistantMessages],
439
+ toolCalls: counts[:toolCalls],
440
+ toolResults: counts[:toolResults],
441
+ totalMessages: counts[:totalMessages],
442
+ usingSubscription: model[:provider] == "Codex",
443
+ autoCompactionEnabled: compaction_settings.enabled,
444
+ autoCompactionReserveTokens: auto_compaction_reserve_tokens,
445
+ contextUsage: context_usage(rpc_session, model)
446
+ }.compact
447
+ end
448
+
449
+ def refresh_client_config
450
+ @client.reload_config if @client.respond_to?(:reload_config)
451
+ refresh_session_runtime_contexts
452
+ refresh_session_tool_registries
453
+ end
454
+
455
+ def session_payload(rpc_session)
456
+ {
457
+ id: rpc_session.id,
458
+ persistentId: rpc_session.session.id,
459
+ path: rpc_session.session.path,
460
+ workspaceRoot: rpc_session.workspace_root,
461
+ cwd: rpc_session.session.cwd.to_s.empty? ? rpc_session.workspace_root : rpc_session.session.cwd,
462
+ name: rpc_session.session.name,
463
+ createdAt: rpc_session.session.created_at&.utc&.iso8601(3),
464
+ modifiedAt: session_modified_at(rpc_session.session)&.utc&.iso8601(3),
465
+ parentId: rpc_session.session.parent_id,
466
+ parentPath: rpc_session.session.parent_path
467
+ }
468
+ end
469
+
470
+ def session_modified_at(session)
471
+ File.exist?(session.path) ? File.mtime(session.path) : nil
472
+ end
473
+
474
+ def validate_workspace_root(root)
475
+ expanded = File.expand_path(root.to_s.empty? ? Dir.pwd : root.to_s)
476
+ raise "Workspace root is not an existing directory: #{expanded}" unless File.directory?(expanded)
477
+
478
+ File.realpath(expanded)
479
+ end
480
+
481
+ private
482
+
483
+ def new_conversation(workspace_root: Dir.pwd)
484
+ Conversation.new(
485
+ workspace_root: workspace_root,
486
+ model: (@client.current_model if @client.respond_to?(:current_model)),
487
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
488
+ plugin_registry: plugin_registry
489
+ )
490
+ end
491
+
492
+ def refresh_session_runtime_contexts
493
+ model = current_model_id
494
+ reasoning_effort = current_reasoning_effort
495
+ sessions = @mutex.synchronize { @sessions.values }
496
+ sessions.each do |rpc_session|
497
+ rpc_session.conversation.update_runtime_context!(model: model, reasoning_effort: reasoning_effort)
498
+ rpc_session.session.update_runtime(model: model, reasoning_effort: reasoning_effort)
499
+ end
500
+ end
501
+
502
+ def refresh_session_tool_registries
503
+ sessions = @mutex.synchronize { @sessions.values }
504
+ sessions.each { |rpc_session| rebuild_session_tools(rpc_session) }
505
+ end
506
+
507
+ def rebuild_session_tools(rpc_session)
508
+ tool_registry = build_tool_registry(rpc_session.workspace_root, rpc_session.prompt)
509
+ rpc_session.tool_registry = tool_registry
510
+ rpc_session.agent = Agent.new(
511
+ client: @client,
512
+ tool_registry: tool_registry,
513
+ conversation: rpc_session.conversation
514
+ )
515
+ end
516
+
517
+ def compaction_settings
518
+ path = File.join(@config_dir, "config.json")
519
+ Kward::Compaction::Settings.from_config(ConfigFiles.read_config(path))
520
+ rescue StandardError
521
+ Kward::Compaction::Settings.new
522
+ end
523
+
524
+ def compaction_reserve_tokens(context_window:, compaction_settings:)
525
+ return nil unless compaction_settings&.enabled
526
+ return nil unless context_window
527
+
528
+ Kward::Compactor.auto_compaction_reserve_tokens(
529
+ context_window: context_window,
530
+ configured_reserve_tokens: compaction_settings&.reserve_tokens
531
+ )
532
+ end
533
+
534
+ def current_model_id
535
+ @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
536
+ end
537
+
538
+ def current_reasoning_effort
539
+ @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
540
+ end
541
+
542
+ def normalize_model(model)
543
+ ModelInfo.normalize(
544
+ model,
545
+ current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
546
+ current_model: (@client.current_model if @client.respond_to?(:current_model)),
547
+ current_reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
548
+ )
549
+ end
550
+
551
+ def default_model_label(model)
552
+ return nil if model[:provider].to_s.empty? || model[:id].to_s.empty?
553
+
554
+ "#{model[:provider]}/#{model[:id]}"
555
+ end
556
+
557
+ def streaming?(rpc_session)
558
+ turn_id = rpc_session.running_turn_id
559
+ return false unless turn_id
560
+
561
+ @mutex.synchronize { @turns[turn_id]&.status == "running" }
562
+ end
563
+
564
+ def pending_turn_count(session_id)
565
+ @mutex.synchronize do
566
+ @turns.values.count { |turn| turn.session_id == session_id && ["queued", "running"].include?(turn.status) }
567
+ end
568
+ end
569
+
570
+ def session_idle?(rpc_session)
571
+ pending_turn_count(rpc_session.id).zero?
572
+ end
573
+
574
+ def active_session_count(workspace_root)
575
+ @mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
576
+ end
577
+
578
+ def active_empty_unnamed_session_info?(info, workspace_root)
579
+ rpc_sessions = @mutex.synchronize { @sessions.values.dup }
580
+ rpc_sessions.any? do |rpc_session|
581
+ rpc_session.workspace_root == workspace_root &&
582
+ File.expand_path(rpc_session.session.path) == File.expand_path(info.path) &&
583
+ session_idle?(rpc_session) &&
584
+ info.name.to_s.strip.empty? &&
585
+ info.message_count.to_i.zero?
586
+ end
587
+ end
588
+
589
+ def message_count(conversation)
590
+ conversation.messages.count { |message| message_role(message) != "system" }
591
+ end
592
+
593
+ def context_usage(rpc_session, model)
594
+ context_parts = if @client.respond_to?(:current_context_parts)
595
+ @client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
596
+ else
597
+ { provider: model[:provider], model: model[:id], messages: rpc_session.conversation.messages, tools: rpc_session.tool_registry.schemas }
598
+ end
599
+ @context_usage.call(
600
+ provider: model[:provider],
601
+ model: model[:id],
602
+ context_window: model[:contextWindow],
603
+ context_parts: context_parts
604
+ )
605
+ end
606
+
607
+ def message_stats(conversation)
608
+ conversation.messages.each_with_object({ userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0, totalMessages: 0 }) do |message, counts|
609
+ role = message_role(message)
610
+ next if role == "system"
611
+
612
+ counts[:totalMessages] += 1
613
+ case role
614
+ when "user"
615
+ counts[:userMessages] += 1
616
+ when "assistant"
617
+ counts[:assistantMessages] += 1
618
+ counts[:toolCalls] += tool_calls(message).length
619
+ when "tool", "toolResult"
620
+ counts[:toolResults] += 1
621
+ end
622
+ end
623
+ end
624
+
625
+ def tool_calls(message)
626
+ MessageAccess.tool_calls(message)
627
+ end
628
+
629
+ def message_role(message)
630
+ MessageAccess.role(message)
631
+ end
632
+
633
+ def message_content(message)
634
+ MessageAccess.content(message)
635
+ end
636
+
637
+ def entry_messages(conversation)
638
+ conversation.messages.reject { |message| message_role(message) == "system" }
639
+ end
640
+
641
+ def entry_id(index)
642
+ "message:#{index}"
643
+ end
644
+
645
+ def entry_index(entry_id)
646
+ match = entry_id.to_s.match(/\Amessage:(\d+)\z/)
647
+ raise ArgumentError, "Invalid entryId: #{entry_id}" unless match
648
+
649
+ match[1].to_i
650
+ end
651
+
652
+ def display_message_text(message)
653
+ full_message_text(message).gsub(/\s+/, " ").strip.slice(0, 120).to_s
654
+ end
655
+
656
+ def full_message_text(message)
657
+ content = message["content"] || message[:content]
658
+ text = if content.is_a?(Array)
659
+ content.filter_map { |part| part["text"] || part[:text] }.join("\n")
660
+ else
661
+ content.to_s
662
+ end
663
+ text.strip
664
+ end
665
+
666
+ def supports_in_flight_steer?
667
+ @client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
668
+ end
669
+
670
+ def default_streaming_behavior(rpc_session, streaming_behavior)
671
+ behavior = streaming_behavior.to_s
672
+ return behavior unless behavior.empty?
673
+ return "steer" if supports_in_flight_steer? && streaming?(rpc_session)
674
+
675
+ "newTurn"
676
+ end
677
+
678
+ def validate_streaming_behavior(streaming_behavior, rpc_session: nil)
679
+ behavior = streaming_behavior.to_s.empty? ? "newTurn" : streaming_behavior.to_s
680
+ raise ArgumentError, "Unsupported streamingBehavior: #{behavior}" unless STREAMING_BEHAVIORS.include?(behavior)
681
+ raise ArgumentError, "Unsupported streamingBehavior: steer" if behavior == "steer" && !supports_in_flight_steer?
682
+ raise ArgumentError, "Unsupported streamingBehavior: steer" if behavior == "steer" && (!rpc_session || !streaming?(rpc_session))
683
+
684
+ behavior
685
+ end
686
+
687
+ def user_turn_content(input, attachments)
688
+ return input.to_s if attachments.empty?
689
+
690
+ [{ type: "text", text: input.to_s }] + attachments
691
+ end
692
+
693
+ def expand_prompt_input(input)
694
+ return input unless input.is_a?(String)
695
+
696
+ PromptCommands.expand(input) || input
697
+ end
698
+
699
+ def plugin_command_turn(input, attachments)
700
+ return [nil, ""] unless input.is_a?(String)
701
+ return [nil, ""] unless attachments.empty?
702
+
703
+ command, arguments = PromptCommands.parse(input)
704
+ return [nil, ""] unless command
705
+
706
+ [plugin_registry.command_for(command), arguments]
707
+ end
708
+
709
+ def normalize_attachments(attachments)
710
+ return [] if attachments.nil?
711
+ raise ArgumentError, "attachments must be an array" unless attachments.is_a?(Array)
712
+
713
+ attachments.map { |attachment| normalize_attachment(attachment) }
714
+ end
715
+
716
+ def normalize_attachment(attachment)
717
+ raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
718
+
719
+ type = value(attachment, :type).to_s
720
+ raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
721
+
722
+ mime_type = normalize_attachment_mime_type(value(attachment, :mimeType) || value(attachment, :mime_type) || value(attachment, :media_type))
723
+ raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
724
+
725
+ data = value(attachment, :data).to_s
726
+ raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
727
+ raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
728
+ declared_size = value(attachment, :sizeBytes) || value(attachment, :size_bytes)
729
+ raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
730
+
731
+ decoded_size = Base64.strict_decode64(data).bytesize
732
+ raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
733
+
734
+ result = { type: "image", data: data, mimeType: mime_type }
735
+ name = value(attachment, :name)
736
+ result[:alt] = name.to_s unless name.to_s.empty?
737
+ result
738
+ rescue ArgumentError => e
739
+ raise e if e.message.start_with?("Unsupported", "Image attachment", "attachment")
740
+
741
+ raise ArgumentError, "Image attachment data must be valid base64"
742
+ end
743
+
744
+ def normalize_attachment_mime_type(mime_type)
745
+ mime_type.to_s.downcase
746
+ end
747
+
748
+ def value(object, key)
749
+ return nil unless object.respond_to?(:key?)
750
+ return object[key] if object.key?(key)
751
+ return object[key.to_s] if object.key?(key.to_s)
752
+
753
+ nil
754
+ end
755
+
756
+ def plugin_registry
757
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
758
+ end
759
+
760
+ def reserved_plugin_command_names
761
+ PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
762
+ end
763
+
764
+ def build_rpc_session(store, session, conversation, workspace_root)
765
+ conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
766
+ id = SecureRandom.uuid
767
+ prompt = PromptBridge.new(server: @server, session_id: id)
768
+ tool_registry = build_tool_registry(workspace_root, prompt)
769
+ agent = Agent.new(
770
+ client: @client,
771
+ tool_registry: tool_registry,
772
+ conversation: conversation
773
+ )
774
+ RpcSession.new(
775
+ id: id,
776
+ workspace_root: workspace_root,
777
+ store: store,
778
+ session: session,
779
+ conversation: conversation,
780
+ agent: agent,
781
+ tool_registry: tool_registry,
782
+ prompt: prompt,
783
+ plugin_output: [],
784
+ queue: Queue.new,
785
+ worker: nil,
786
+ running_turn_id: nil
787
+ )
788
+ end
789
+
790
+ def build_tool_registry(workspace_root, prompt)
791
+ ToolRegistry.new(workspace: Workspace.new(root: workspace_root), prompt: prompt)
792
+ end
793
+
794
+ def remember_session(rpc_session)
795
+ @mutex.synchronize { @sessions[rpc_session.id] = rpc_session }
796
+ end
797
+
798
+ def fetch_session(session_id)
799
+ @mutex.synchronize { @sessions[session_id.to_s] } || raise("Unknown session: #{session_id}")
800
+ end
801
+
802
+ def fetch_turn(turn_id)
803
+ @mutex.synchronize { @turns[turn_id.to_s] } || raise("Unknown turn: #{turn_id}")
804
+ end
805
+
806
+ def ensure_worker(rpc_session)
807
+ return if rpc_session.worker&.alive?
808
+
809
+ rpc_session.worker = Thread.new { worker_loop(rpc_session) }
810
+ end
811
+
812
+ def close_rpc_session(rpc_session)
813
+ @mutex.synchronize { @sessions.delete(rpc_session.id) }
814
+ stop_worker(rpc_session)
815
+ rpc_session.session.delete_if_unused if rpc_session.session.respond_to?(:delete_if_unused)
816
+ end
817
+
818
+ def cleanup_other_unused_sessions(current_session)
819
+ rpc_sessions = @mutex.synchronize { @sessions.values.dup }
820
+ rpc_sessions.each do |rpc_session|
821
+ next if rpc_session.id == current_session.id
822
+ next if rpc_session.session.path == current_session.session.path
823
+ next unless session_idle?(rpc_session)
824
+ next unless rpc_session.session.respond_to?(:delete_if_unused)
825
+ next unless rpc_session.session.delete_if_unused
826
+
827
+ @mutex.synchronize { @sessions.delete(rpc_session.id) }
828
+ stop_worker(rpc_session)
829
+ end
830
+ end
831
+
832
+ def stop_worker(rpc_session)
833
+ worker = rpc_session.worker
834
+ return unless worker&.alive?
835
+ return if worker == Thread.current
836
+
837
+ rpc_session.queue << WORKER_STOP
838
+ end
839
+
840
+ def worker_loop(rpc_session)
841
+ loop do
842
+ turn_id = rpc_session.queue.pop
843
+ break if turn_id.equal?(WORKER_STOP)
844
+
845
+ begin
846
+ turn = fetch_turn(turn_id)
847
+ next if turn.status == "canceled"
848
+
849
+ run_turn(rpc_session, turn)
850
+ rescue StandardError => e
851
+ @server.log_error(e)
852
+ end
853
+ end
854
+ ensure
855
+ rpc_session.worker = nil if rpc_session.worker == Thread.current
856
+ end
857
+
858
+ def run_turn(rpc_session, turn)
859
+ rpc_session.running_turn_id = turn.id
860
+ turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
861
+ turn.status = "running"
862
+ turn.started_at = now
863
+ emit_turn_event(turn, "turnStarted", { status: "running" })
864
+
865
+ if turn.cancel_requested
866
+ finish_turn(turn, "canceled")
867
+ return
868
+ end
869
+
870
+ if turn.plugin_command_name
871
+ run_plugin_turn(rpc_session, turn)
872
+ else
873
+ prepare_memory_context(rpc_session.conversation, turn.input)
874
+ rpc_session.agent.ask(turn.input, cancellation: turn.cancellation, steering: turn.steering) do |event|
875
+ next if turn.cancel_requested
876
+
877
+ notify_plugin_transcript_event(rpc_session, event)
878
+ handle_agent_event(turn, event)
879
+ end
880
+ persist_memory_state(rpc_session)
881
+ finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
882
+ end
883
+ rescue Cancellation::CancelledError
884
+ finish_turn(turn, "canceled")
885
+ rescue StandardError => e
886
+ turn.error = turn_error_payload(e)
887
+ emit_turn_event(turn, "error", turn.error)
888
+ finish_turn(turn, "failed")
889
+ ensure
890
+ turn.steering = nil
891
+ rpc_session.running_turn_id = nil
892
+ end
893
+
894
+ def build_steering(_turn)
895
+ Steering.new
896
+ end
897
+
898
+ def prepare_memory_context(conversation, input)
899
+ manager = memory_manager
900
+ retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
901
+ conversation.last_memory_retrieval = retrieval
902
+ conversation.memory_context = manager.memory_block(retrieval)
903
+ conversation.refresh_system_message!
904
+ rescue StandardError => e
905
+ @server.log_error(e)
906
+ nil
907
+ end
908
+
909
+ def persist_memory_state(rpc_session)
910
+ rpc_session.session.update_memory_state(session_memories: rpc_session.conversation.session_memories, last_retrieval: rpc_session.conversation.last_memory_retrieval)
911
+ rescue StandardError
912
+ nil
913
+ end
914
+
915
+ def steer_running_turn(rpc_session, input)
916
+ turn_id = rpc_session.running_turn_id
917
+ turn = turn_id && fetch_turn(turn_id)
918
+ raise ArgumentError, "Unsupported streamingBehavior: steer" unless turn&.status == "running" && turn.steering
919
+
920
+ turn.steering.submit(input)
921
+ turn_payload(turn)
922
+ rescue StandardError
923
+ nil
924
+ end
925
+
926
+ def run_plugin_turn(rpc_session, turn)
927
+ turn.cancellation&.raise_if_cancelled!
928
+ command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
929
+ output = []
930
+ context = PluginRegistry::Context.new(
931
+ conversation: rpc_session.conversation,
932
+ args: turn.plugin_arguments.to_s,
933
+ session: rpc_session.session,
934
+ workspace_root: rpc_session.workspace_root,
935
+ say_callback: lambda { |message| output << message.to_s }
936
+ )
937
+ result = command.handler.call(turn.plugin_arguments.to_s, context)
938
+ answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
939
+ unless answer.empty?
940
+ emit_turn_event(turn, "assistantDelta", { delta: answer })
941
+ emit_turn_event(turn, "answer", { content: answer })
942
+ end
943
+ finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
944
+ end
945
+
946
+ def notify_plugin_transcript_event(rpc_session, event)
947
+ return if plugin_registry.transcript_event_handlers.empty?
948
+
949
+ context = PluginRegistry::Context.new(
950
+ conversation: rpc_session.conversation,
951
+ session: rpc_session.session,
952
+ workspace_root: rpc_session.workspace_root,
953
+ say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
954
+ )
955
+ plugin_registry.notify_transcript_event(event, context)
956
+ end
957
+
958
+ def handle_agent_event(turn, event)
959
+ case event
960
+ when Events::ReasoningDelta
961
+ emit_turn_event(turn, "reasoningDelta", { delta: event.delta })
962
+ when Events::AssistantDelta
963
+ emit_turn_event(turn, "assistantDelta", { delta: event.delta })
964
+ when Events::AssistantMessage
965
+ emit_turn_event(turn, "assistantMessage", { message: event.message })
966
+ when Events::Retry
967
+ emit_turn_event(turn, "modelRetry", retry_event_payload(event))
968
+ when Events::Steering
969
+ emit_turn_event(turn, "turnSteered", { input: event.input, createdAt: event.created_at })
970
+ when Events::ToolCall
971
+ emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
972
+ when Events::ToolResult
973
+ emit_turn_event(turn, "toolResult", normalized_tool_result_event_payload(event.tool_call, event.content))
974
+ when Events::Answer
975
+ emit_turn_event(turn, "answer", { content: event.content })
976
+ end
977
+ end
978
+
979
+ def retry_event_payload(event)
980
+ {
981
+ provider: event.provider,
982
+ model: event.model,
983
+ attempt: event.attempt,
984
+ maxAttempts: event.max_attempts,
985
+ delaySeconds: event.delay_seconds,
986
+ error: event.error,
987
+ requestBytes: event.request_bytes
988
+ }.compact
989
+ end
990
+
991
+ def finish_turn(turn, status)
992
+ return if ["completed", "failed", "canceled"].include?(turn.status)
993
+
994
+ turn.status = status
995
+ turn.finished_at = now
996
+ emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
997
+ end
998
+
999
+ def emit_turn_event(turn, type, payload)
1000
+ event = {
1001
+ sequence: turn.next_sequence,
1002
+ timestamp: now,
1003
+ sessionId: turn.session_id,
1004
+ turnId: turn.id,
1005
+ type: type,
1006
+ payload: payload
1007
+ }
1008
+ turn.next_sequence += 1
1009
+ turn.events << event
1010
+ turn.events.shift while turn.events.length > RECENT_EVENT_LIMIT
1011
+ @server.notify("turn/event", event)
1012
+ event
1013
+ end
1014
+
1015
+ def emit_session_event(rpc_session, type, payload)
1016
+ @server.notify("session/event", {
1017
+ timestamp: now,
1018
+ sessionId: rpc_session.id,
1019
+ type: type,
1020
+ payload: payload
1021
+ })
1022
+ end
1023
+
1024
+ def turn_payload(turn)
1025
+ {
1026
+ id: turn.id,
1027
+ sessionId: turn.session_id,
1028
+ status: turn.status,
1029
+ cancelRequested: turn.cancel_requested,
1030
+ createdAt: turn.created_at,
1031
+ startedAt: turn.started_at,
1032
+ finishedAt: turn.finished_at,
1033
+ error: turn.error
1034
+ }.compact
1035
+ end
1036
+
1037
+ def session_info_payload(info, workspace_root:)
1038
+ cwd = info.cwd.to_s.empty? ? workspace_root : info.cwd
1039
+ {
1040
+ id: info.id,
1041
+ path: File.expand_path(info.path),
1042
+ cwd: cwd,
1043
+ workspaceRoot: workspace_root,
1044
+ createdAt: info.created_at&.utc&.iso8601(3),
1045
+ modifiedAt: info.modified_at&.utc&.iso8601(3),
1046
+ name: info.name,
1047
+ firstMessage: info.first_message.to_s,
1048
+ messageCount: info.message_count.to_i,
1049
+ parentId: info.parent_id,
1050
+ parentPath: info.parent_path,
1051
+ depth: info.depth.to_i,
1052
+ isLast: info.is_last,
1053
+ ancestorContinues: Array(info.ancestor_continues)
1054
+ }
1055
+ end
1056
+
1057
+ def export_path(rpc_session, path, format)
1058
+ extension = format == "html" ? ".html" : ".md"
1059
+ default_path = rpc_session.session.path.sub(/\.jsonl\z/, extension)
1060
+ ExportPath.resolve(path, workspace_root: rpc_session.workspace_root, default_path: default_path, session_dir: rpc_session.store.session_dir)
1061
+ end
1062
+
1063
+ def export_format(format)
1064
+ TranscriptExport.format(format)
1065
+ end
1066
+
1067
+ def export_content(conversation, format)
1068
+ TranscriptExport.content(conversation, format: format)
1069
+ end
1070
+
1071
+ def normalized_tool_event_payload(tool_call)
1072
+ ToolEventNormalizer.new(tool_call).call_payload(legacy_tool: tool_metadata(tool_call))
1073
+ end
1074
+
1075
+ def normalized_tool_result_event_payload(tool_call, content)
1076
+ ToolEventNormalizer.new(tool_call, content: content).result_payload(legacy_tool: tool_metadata(tool_call))
1077
+ end
1078
+
1079
+ def turn_error_payload(error)
1080
+ {
1081
+ message: error.message,
1082
+ code: error.class.name,
1083
+ fatal: false
1084
+ }
1085
+ end
1086
+
1087
+ def tool_metadata(tool_call)
1088
+ name = ToolCall.name(tool_call)
1089
+ args = ToolCall.arguments(tool_call)
1090
+
1091
+ case name
1092
+ when "edit_file"
1093
+ edits = Array(args["edits"] || args[:edits]).map do |edit|
1094
+ {
1095
+ oldText: edit["old_text"] || edit[:old_text],
1096
+ newText: edit["new_text"] || edit[:new_text]
1097
+ }.compact
1098
+ end
1099
+ first_edit = edits.first || {}
1100
+ {
1101
+ kind: "edit",
1102
+ path: args["path"] || args[:path],
1103
+ edits: edits,
1104
+ oldText: first_edit[:oldText],
1105
+ newText: first_edit[:newText]
1106
+ }.compact
1107
+ when "write_file"
1108
+ { kind: "write", path: args["path"] || args[:path] }.compact
1109
+ when "run_shell_command"
1110
+ { kind: "shell", command: args["command"] || args[:command] }.compact
1111
+ else
1112
+ nil
1113
+ end
1114
+ end
1115
+
1116
+
1117
+ def now
1118
+ Time.now.utc.iso8601(3)
1119
+ end
1120
+ end
1121
+ end
1122
+ end