kward 0.72.0 → 0.73.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +53 -0
  4. data/Gemfile.lock +2 -2
  5. data/doc/configuration.md +1 -1
  6. data/doc/editor.md +23 -2
  7. data/doc/git.md +1 -0
  8. data/doc/rpc.md +2 -2
  9. data/doc/shell.md +56 -10
  10. data/doc/usage.md +27 -1
  11. data/lib/kward/ansi.rb +62 -23
  12. data/lib/kward/cli/plugins.rb +1 -1
  13. data/lib/kward/cli/rendering.rb +4 -1
  14. data/lib/kward/cli/runtime_helpers.rb +141 -7
  15. data/lib/kward/cli/settings.rb +0 -1
  16. data/lib/kward/cli/slash_commands.rb +213 -0
  17. data/lib/kward/cli/tabs.rb +34 -4
  18. data/lib/kward/cli/tool_summaries.rb +6 -0
  19. data/lib/kward/cli.rb +4 -12
  20. data/lib/kward/clipboard.rb +2 -3
  21. data/lib/kward/compactor.rb +7 -19
  22. data/lib/kward/config_files.rb +26 -4
  23. data/lib/kward/ekwsh.rb +239 -42
  24. data/lib/kward/image_attachments.rb +3 -1
  25. data/lib/kward/interactive_pty_runner.rb +151 -0
  26. data/lib/kward/local_command_runner.rb +155 -0
  27. data/lib/kward/local_pty_command_runner.rb +171 -0
  28. data/lib/kward/model/context_usage.rb +2 -2
  29. data/lib/kward/model/payloads.rb +2 -5
  30. data/lib/kward/prompt_history.rb +5 -3
  31. data/lib/kward/prompt_interface/editor/auto_indent.rb +5 -4
  32. data/lib/kward/prompt_interface/editor/controller.rb +262 -62
  33. data/lib/kward/prompt_interface/editor/modes/emacs.rb +21 -21
  34. data/lib/kward/prompt_interface/editor/modes/modern.rb +38 -37
  35. data/lib/kward/prompt_interface/editor/modes/vibe.rb +23 -173
  36. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  37. data/lib/kward/prompt_interface/editor/renderer.rb +6 -5
  38. data/lib/kward/prompt_interface/editor/state.rb +28 -6
  39. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +5 -3
  40. data/lib/kward/prompt_interface/git_prompt.rb +12 -23
  41. data/lib/kward/prompt_interface/interactive/controller.rb +1 -1
  42. data/lib/kward/prompt_interface/key_handler.rb +93 -51
  43. data/lib/kward/prompt_interface/question_prompt.rb +1 -6
  44. data/lib/kward/prompt_interface/screen.rb +3 -3
  45. data/lib/kward/prompt_interface/selection_prompt.rb +3 -6
  46. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  47. data/lib/kward/prompt_interface.rb +87 -221
  48. data/lib/kward/prompts/commands.rb +4 -0
  49. data/lib/kward/rpc/memory_methods.rb +83 -0
  50. data/lib/kward/rpc/server.rb +130 -83
  51. data/lib/kward/rpc/session_manager.rb +10 -74
  52. data/lib/kward/rpc/tool_metadata.rb +11 -0
  53. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  54. data/lib/kward/scratchpad_runner.rb +56 -0
  55. data/lib/kward/session_diff.rb +20 -3
  56. data/lib/kward/session_naming.rb +11 -0
  57. data/lib/kward/terminal_keys.rb +84 -0
  58. data/lib/kward/terminal_sequences.rb +42 -0
  59. data/lib/kward/tools/context_for_task.rb +2 -0
  60. data/lib/kward/version.rb +1 -1
  61. data/lib/kward/workers/git_guard.rb +25 -0
  62. data/lib/kward/workers/job.rb +99 -0
  63. data/lib/kward/workers/queue_runner.rb +166 -0
  64. data/lib/kward/workers/queue_store.rb +112 -0
  65. data/lib/kward/workers.rb +3 -0
  66. data/lib/kward/workspace.rb +15 -63
  67. data/templates/default/fulldoc/html/css/kward.css +33 -0
  68. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  69. data/templates/default/fulldoc/html/setup.rb +1 -0
  70. data/templates/default/layout/html/layout.erb +19 -32
  71. metadata +15 -1
@@ -44,13 +44,20 @@ module Kward
44
44
  invalid_params: -32_602,
45
45
  internal_error: -32_603
46
46
  }.freeze
47
+ PROTOCOL_METHODS = ["initialize", "shutdown"].freeze
48
+ WORKSPACE_METHODS = ["workspace/validate", "workspace/info"].freeze
49
+ TOOL_METHODS = ["tools/list"].freeze
50
+ PROMPT_METHODS = ["prompts/list", "prompts/expand"].freeze
47
51
  SESSION_METHODS = [
48
52
  "sessions/create", "sessions/resume", "sessions/list", "sessions/rename",
49
53
  "sessions/clone", "sessions/compact", "sessions/forkMessages", "sessions/fork",
50
54
  "sessions/tree", "sessions/tree/setLabel", "sessions/tree/navigate",
51
55
  "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"
52
56
  ].freeze
57
+ TURN_METHODS = ["turns/start", "turns/cancel", "turns/status", "turns/events"].freeze
53
58
  MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
59
+ RUNTIME_METHODS = ["runtime/state", "runtime/stats"].freeze
60
+ RUNTIME_SETTING_METHODS = ["runtime/updateSetting", "runtime/reload"].freeze
54
61
  AUTH_METHODS = [
55
62
  "auth/status", "auth/providers", "auth/loginWithApiKey", "auth/logoutProvider",
56
63
  "auth/loginWithOAuth", "auth/startOpenAILogin", "auth/submitOpenAICode", "auth/loginStatus"
@@ -62,6 +69,36 @@ module Kward
62
69
  "memory/why", "memory/summarize"
63
70
  ].freeze
64
71
  WORKER_METHODS = ["workers/list", "workers/show"].freeze
72
+ COMMAND_METHODS = ["commands/list", "commands/run"].freeze
73
+ STARTUP_RESOURCE_METHODS = ["resources/startup"].freeze
74
+ CONFIG_METHODS = ["config/read", "config/update"].freeze
75
+ LOGGING_METHODS = ["logging/stats", "logging/tokenCsv"].freeze
76
+ UI_METHODS = ["ui/answerQuestion"].freeze
77
+ SESSION_EVENT_NOTIFICATION = "session/event"
78
+ SESSION_UPDATED_NOTIFICATION = "session/updated"
79
+ TURN_EVENT_NOTIFICATION = "turn/event"
80
+ UI_QUESTION_NOTIFICATION = "ui/question"
81
+ UI_FOOTER_NOTIFICATION = "ui/footer"
82
+ METHOD_GROUPS = {
83
+ protocol: PROTOCOL_METHODS,
84
+ workspace: WORKSPACE_METHODS,
85
+ tools: TOOL_METHODS,
86
+ prompts: PROMPT_METHODS,
87
+ sessions: SESSION_METHODS,
88
+ turns: TURN_METHODS,
89
+ models: MODEL_METHODS,
90
+ runtime: RUNTIME_METHODS,
91
+ runtime_settings: RUNTIME_SETTING_METHODS,
92
+ auth: AUTH_METHODS,
93
+ memory: MEMORY_METHODS,
94
+ workers: WORKER_METHODS,
95
+ commands: COMMAND_METHODS,
96
+ startup_resources: STARTUP_RESOURCE_METHODS,
97
+ config: CONFIG_METHODS,
98
+ logging: LOGGING_METHODS,
99
+ ui: UI_METHODS
100
+ }.freeze
101
+ RPC_METHODS = METHOD_GROUPS.values.flatten.freeze
65
102
 
66
103
  # Creates the RPC server and its stateful managers.
67
104
  def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new, experimental_workers: false)
@@ -154,145 +191,145 @@ module Kward
154
191
  def dispatch(method, params)
155
192
  params = stringify_keys(params || {})
156
193
  case method
157
- when "initialize"
194
+ when PROTOCOL_METHODS[0]
158
195
  initialize_result
159
- when "shutdown"
196
+ when PROTOCOL_METHODS[1]
160
197
  @shutdown = true
161
198
  { ok: true }
162
- when "workspace/validate"
199
+ when WORKSPACE_METHODS[0]
163
200
  { root: @session_manager.validate_workspace_root(params["workspaceRoot"] || Dir.pwd) }
164
- when "workspace/info"
201
+ when WORKSPACE_METHODS[1]
165
202
  workspace_info(params["workspaceRoot"] || Dir.pwd)
166
- when "tools/list"
203
+ when TOOL_METHODS[0]
167
204
  { tools: ToolRegistry.new(workspace: configured_workspace).schemas }
168
- when "prompts/list"
205
+ when PROMPT_METHODS[0]
169
206
  prompts_list
170
- when "prompts/expand"
207
+ when PROMPT_METHODS[1]
171
208
  prompts_expand(params)
172
- when "models/list"
209
+ when MODEL_METHODS[0]
173
210
  models_list
174
- when "models/current"
211
+ when MODEL_METHODS[1]
175
212
  models_current
176
- when "models/set"
213
+ when MODEL_METHODS[2]
177
214
  models_set(params)
178
- when "reasoning/set"
215
+ when MODEL_METHODS[3]
179
216
  reasoning_set(params)
180
- when "runtime/state"
217
+ when RUNTIME_METHODS[0]
181
218
  @session_manager.runtime_state(session_id: params.fetch("sessionId"))
182
- when "runtime/stats"
219
+ when RUNTIME_METHODS[1]
183
220
  @session_manager.runtime_stats(session_id: params.fetch("sessionId"))
184
- when "runtime/updateSetting"
221
+ when RUNTIME_SETTING_METHODS[0]
185
222
  runtime_update_setting(params)
186
- when "runtime/reload"
223
+ when RUNTIME_SETTING_METHODS[1]
187
224
  runtime_reload(params)
188
- when "commands/list"
225
+ when COMMAND_METHODS[0]
189
226
  commands_list(params)
190
- when "commands/run"
227
+ when COMMAND_METHODS[1]
191
228
  commands_run(params)
192
- when "resources/startup"
229
+ when STARTUP_RESOURCE_METHODS[0]
193
230
  startup_resources(params)
194
- when "config/read"
231
+ when CONFIG_METHODS[0]
195
232
  { path: @config_manager.config_path, config: @config_manager.read(redacted: params.fetch("redacted", true)) }
196
- when "config/update"
233
+ when CONFIG_METHODS[1]
197
234
  config_update(params)
198
- when "logging/stats"
235
+ when LOGGING_METHODS[0]
199
236
  logging_stats(params)
200
- when "logging/tokenCsv"
237
+ when LOGGING_METHODS[1]
201
238
  logging_token_csv(params)
202
- when "memory/status"
239
+ when MEMORY_METHODS[0]
203
240
  @session_manager.memory_status
204
- when "memory/enable"
241
+ when MEMORY_METHODS[1]
205
242
  @session_manager.memory_enable
206
- when "memory/disable"
243
+ when MEMORY_METHODS[2]
207
244
  @session_manager.memory_disable
208
- when "memory/autoSummary/enable"
245
+ when MEMORY_METHODS[3]
209
246
  @session_manager.memory_auto_summary_enable
210
- when "memory/autoSummary/disable"
247
+ when MEMORY_METHODS[4]
211
248
  @session_manager.memory_auto_summary_disable
212
- when "memory/list"
249
+ when MEMORY_METHODS[5]
213
250
  @session_manager.memory_list(include_inactive: params["includeInactive"] || false, workspace_root: params["workspaceRoot"] || Dir.pwd)
214
- when "memory/add"
251
+ when MEMORY_METHODS[6]
215
252
  @session_manager.memory_add(text: params.fetch("text"), scope: params["scope"], tags: params["tags"] || [])
216
- when "memory/addCore"
253
+ when MEMORY_METHODS[7]
217
254
  @session_manager.memory_add_core(text: params.fetch("text"), scope: params["scope"], tags: params["tags"] || [])
218
- when "memory/forget"
255
+ when MEMORY_METHODS[8]
219
256
  @session_manager.memory_forget(id: params.fetch("id"))
220
- when "memory/promote"
257
+ when MEMORY_METHODS[9]
221
258
  @session_manager.memory_promote(id: params.fetch("id"))
222
- when "memory/relax"
259
+ when MEMORY_METHODS[10]
223
260
  @session_manager.memory_relax(id: params.fetch("id"), workspace_root: params["workspaceRoot"] || Dir.pwd)
224
- when "memory/inspect"
261
+ when MEMORY_METHODS[11]
225
262
  @session_manager.memory_inspect
226
- when "memory/why"
263
+ when MEMORY_METHODS[12]
227
264
  @session_manager.memory_why(session_id: params["sessionId"])
228
- when "memory/summarize"
265
+ when MEMORY_METHODS[13]
229
266
  @session_manager.memory_summarize(session_id: params.fetch("sessionId"))
230
- when "workers/list"
267
+ when WORKER_METHODS[0]
231
268
  require_experimental_workers!
232
269
  workers_list(params)
233
- when "workers/show"
270
+ when WORKER_METHODS[1]
234
271
  require_experimental_workers!
235
272
  workers_show(params)
236
- when "auth/status"
273
+ when AUTH_METHODS[0]
237
274
  @auth_manager.status
238
- when "auth/providers"
275
+ when AUTH_METHODS[1]
239
276
  @auth_manager.providers
240
- when "auth/loginWithApiKey"
277
+ when AUTH_METHODS[2]
241
278
  auth_login_with_api_key(params)
242
- when "auth/logoutProvider"
279
+ when AUTH_METHODS[3]
243
280
  auth_logout_provider(params)
244
- when "auth/loginWithOAuth"
281
+ when AUTH_METHODS[4]
245
282
  @auth_manager.login_with_oauth(provider_id: params.fetch("providerId"), timeout_seconds: params["timeoutSeconds"] || 120)
246
- when "auth/startOpenAILogin"
283
+ when AUTH_METHODS[5]
247
284
  @auth_manager.start_openai_login(timeout_seconds: params["timeoutSeconds"] || 120)
248
- when "auth/submitOpenAICode"
285
+ when AUTH_METHODS[6]
249
286
  @auth_manager.submit_openai_code(login_id: params.fetch("loginId"), code: params.fetch("code"))
250
- when "auth/loginStatus"
287
+ when AUTH_METHODS[7]
251
288
  @auth_manager.login_status(login_id: params.fetch("loginId"))
252
- when "sessions/create"
289
+ when SESSION_METHODS[0]
253
290
  @session_manager.create_session(workspace_root: params["workspaceRoot"] || Dir.pwd, name: params["name"], resume_last: params["resumeLast"] != false)
254
- when "sessions/resume"
291
+ when SESSION_METHODS[1]
255
292
  @session_manager.resume_session(path: params.fetch("path"), workspace_root: params["workspaceRoot"])
256
- when "sessions/list"
293
+ when SESSION_METHODS[2]
257
294
  { sessions: @session_manager.list_sessions(workspace_root: params["workspaceRoot"] || Dir.pwd, limit: params["limit"], current_session_path: params["currentSessionPath"]) }
258
- when "sessions/rename"
295
+ when SESSION_METHODS[3]
259
296
  @session_manager.rename_session(session_id: params.fetch("sessionId"), name: params["name"])
260
- when "sessions/clone"
297
+ when SESSION_METHODS[4]
261
298
  @session_manager.clone_session(session_id: params.fetch("sessionId"))
262
- when "sessions/compact"
299
+ when SESSION_METHODS[5]
263
300
  @session_manager.compact_session(session_id: params.fetch("sessionId"), custom_instructions: params["customInstructions"] || "")
264
- when "sessions/forkMessages"
301
+ when SESSION_METHODS[6]
265
302
  @session_manager.fork_messages(session_id: params.fetch("sessionId"))
266
- when "sessions/fork"
303
+ when SESSION_METHODS[7]
267
304
  @session_manager.fork_session(session_id: params.fetch("sessionId"), entry_id: params.fetch("entryId"))
268
- when "sessions/tree"
305
+ when SESSION_METHODS[8]
269
306
  @session_manager.session_tree(session_id: params.fetch("sessionId"))
270
- when "sessions/tree/setLabel"
307
+ when SESSION_METHODS[9]
271
308
  @session_manager.set_tree_label(session_id: params.fetch("sessionId"), entry_id: params.fetch("entryId"), label: params["label"])
272
- when "sessions/tree/navigate"
309
+ when SESSION_METHODS[10]
273
310
  @session_manager.navigate_tree(session_id: params.fetch("sessionId"), entry_id: params.fetch("entryId"), summarize: params["summarize"], custom_instructions: params["customInstructions"])
274
- when "sessions/export"
311
+ when SESSION_METHODS[11]
275
312
  @session_manager.export_session(session_id: params.fetch("sessionId"), path: params["path"], format: params["format"])
276
- when "sessions/delete"
313
+ when SESSION_METHODS[12]
277
314
  @session_manager.delete_session(session_id: params.fetch("sessionId"))
278
- when "sessions/close"
315
+ when SESSION_METHODS[13]
279
316
  @session_manager.close_session(session_id: params.fetch("sessionId"))
280
- when "sessions/transcript"
317
+ when SESSION_METHODS[14]
281
318
  @session_manager.transcript(session_id: params.fetch("sessionId"))
282
- when "turns/start"
319
+ when TURN_METHODS[0]
283
320
  @session_manager.start_turn(
284
321
  session_id: params.fetch("sessionId"),
285
322
  input: params.fetch("input"),
286
323
  streaming_behavior: params["streamingBehavior"],
287
324
  attachments: params["attachments"] || []
288
325
  )
289
- when "turns/cancel"
326
+ when TURN_METHODS[1]
290
327
  @session_manager.cancel_turn(turn_id: params.fetch("turnId"))
291
- when "turns/status"
328
+ when TURN_METHODS[2]
292
329
  @session_manager.turn_status(turn_id: params.fetch("turnId"))
293
- when "turns/events"
330
+ when TURN_METHODS[3]
294
331
  @session_manager.turn_events(turn_id: params.fetch("turnId"), after_sequence: params["afterSequence"] || 0)
295
- when "ui/answerQuestion"
332
+ when UI_METHODS[0]
296
333
  @session_manager.answer_question(session_id: params.fetch("sessionId"), question_request_id: params.fetch("questionRequestId"), answers: params.fetch("answers"))
297
334
  else
298
335
  raise NoMethodError, method
@@ -324,13 +361,13 @@ module Kward
324
361
  mode: "explicit",
325
362
  persistence: "jsonl",
326
363
  methods: SESSION_METHODS,
327
- startupResume: { supported: true, method: "sessions/create", parameter: "resumeLast", default: session_auto_resume_enabled?, immediateTranscript: true, sessionActivePersonaLabel: true },
364
+ startupResume: { supported: true, method: SESSION_METHODS[0], parameter: "resumeLast", default: session_auto_resume_enabled?, immediateTranscript: true, sessionActivePersonaLabel: true },
328
365
  list: { supported: true, source: "rpc", ancestry: true, treeFields: true },
329
- fork: { supported: true, methods: ["sessions/forkMessages", "sessions/fork"], entryIdFormat: "entry-id", selectedMessage: "excludedFromForkComposerTextReturned" },
330
- compact: { supported: true, method: "sessions/compact", notification: "session/event", events: ["compactionStart", "compactionEnd"] },
366
+ fork: { supported: true, methods: SESSION_METHODS.values_at(6, 7), entryIdFormat: "entry-id", selectedMessage: "excludedFromForkComposerTextReturned" },
367
+ compact: { supported: true, method: SESSION_METHODS[5], notification: SESSION_EVENT_NOTIFICATION, events: ["compactionStart", "compactionEnd"] },
331
368
  import: { supported: false },
332
- tree: { supported: true, method: "sessions/tree", labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "tauren-tree-items-v1" },
333
- updates: { supported: false, notification: "session/updated" }
369
+ tree: { supported: true, method: SESSION_METHODS[8], labels: true, labelTimestamps: true, navigate: true, summarize: true, shape: "tauren-tree-items-v1" },
370
+ updates: { supported: false, notification: SESSION_UPDATED_NOTIFICATION }
334
371
  },
335
372
  turns: {
336
373
  mode: "async",
@@ -349,19 +386,19 @@ module Kward
349
386
  eventReplay: { behavior: "recent-in-memory", persisted: false, limit: SessionManager::RECENT_EVENT_LIMIT }
350
387
  },
351
388
  events: {
352
- notification: "turn/event",
389
+ notification: TURN_EVENT_NOTIFICATION,
353
390
  assistantText: "assistantDelta",
354
391
  reasoning: { start: false, delta: true, end: false },
355
392
  modelRetry: { supported: true, event: "modelRetry" },
356
393
  steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
357
- tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: false, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
394
+ tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
358
395
  errors: true,
359
396
  sessionUpdates: false
360
397
  },
361
398
  attachments: {
362
399
  input: {
363
400
  supported: true,
364
- method: "turns/start",
401
+ method: TURN_METHODS[0],
365
402
  encoding: "base64",
366
403
  mimeTypes: SessionManager::RPC_IMAGE_MIME_TYPES,
367
404
  maxBytes: SessionManager::RPC_ATTACHMENT_MAX_BYTES
@@ -376,13 +413,13 @@ module Kward
376
413
  },
377
414
  runtime: {
378
415
  supported: true,
379
- methods: ["runtime/state", "runtime/stats"],
416
+ methods: RUNTIME_METHODS,
380
417
  state: { supported: true },
381
418
  stats: { messageCounts: true, tokens: false, cost: false, contextUsage: true, contextUsageEstimated: true }
382
419
  },
383
420
  runtimeSettings: {
384
421
  supported: true,
385
- methods: ["runtime/updateSetting", "runtime/reload"],
422
+ methods: RUNTIME_SETTING_METHODS,
386
423
  settings: ["defaultModel", "defaultThinkingLevel"]
387
424
  },
388
425
  auth: {
@@ -396,18 +433,19 @@ module Kward
396
433
  },
397
434
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
398
435
  workers: workers_capability,
399
- commands: { supported: true, methods: ["commands/list", "commands/run"], method: "commands/list", runMethod: "commands/run", sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
400
- startupResources: { supported: true, method: "resources/startup" },
436
+ commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
437
+ startupResources: { supported: true, method: STARTUP_RESOURCE_METHODS.first },
401
438
  starterPack: { supported: false, reason: "cliOnlyInstallCommand" },
402
439
  shell: { supported: false, reason: "interactiveTuiOnly" },
440
+ scratchpad: { supported: false, reason: "interactiveTuiOnly" },
403
441
  extensionUi: {
404
- question: { supported: true, notification: "ui/question", method: "ui/answerQuestion", maxQuestions: 4, multiSelect: false, preview: false },
442
+ question: { supported: true, notification: UI_QUESTION_NOTIFICATION, method: UI_METHODS.first, maxQuestions: 4, multiSelect: false, preview: false },
405
443
  select: false,
406
444
  confirm: false,
407
445
  input: false,
408
446
  editor: false,
409
447
  widgets: false,
410
- footer: { supported: true, notification: "ui/footer" },
448
+ footer: { supported: true, notification: UI_FOOTER_NOTIFICATION },
411
449
  custom: false,
412
450
  terminalInput: false
413
451
  },
@@ -425,9 +463,9 @@ module Kward
425
463
  logging: {
426
464
  supported: true,
427
465
  defaultEnabled: false,
428
- methods: ["logging/stats", "logging/tokenCsv"],
429
- stats: { supported: true, method: "logging/stats", defaultRange: TelemetryStats::DEFAULT_RANGE, units: %w[minutes hours days weeks months years] },
430
- usageCsv: { supported: true, method: "logging/tokenCsv", defaultRange: TelemetryStats::DEFAULT_RANGE, buckets: %w[second minute hour day week month year] },
466
+ methods: LOGGING_METHODS,
467
+ stats: { supported: true, method: LOGGING_METHODS[0], defaultRange: TelemetryStats::DEFAULT_RANGE, units: %w[minutes hours days weeks months years] },
468
+ usageCsv: { supported: true, method: LOGGING_METHODS[1], defaultRange: TelemetryStats::DEFAULT_RANGE, buckets: %w[second minute hour day week month year] },
431
469
  config: "logging",
432
470
  envPrefix: "KWARD_LOGGING",
433
471
  directory: File.join(ConfigFiles.config_dir, "logs"),
@@ -641,6 +679,15 @@ module Kward
641
679
  end
642
680
 
643
681
  def provider_model_from(value)
682
+ if value.is_a?(Hash)
683
+ provider = value["provider"] || value[:provider] || @session_manager.current_model[:provider]
684
+ model = value["model"] || value[:model] || value["id"] || value[:id]
685
+ model = model.to_s.strip
686
+ raise ArgumentError, "Model must be a non-empty string" if model.empty?
687
+
688
+ return [provider, model]
689
+ end
690
+
644
691
  text = value.to_s.strip
645
692
  raise ArgumentError, "Model must be a non-empty string" if text.empty?
646
693
 
@@ -18,6 +18,7 @@ require_relative "../model/model_info"
18
18
  require_relative "../plugin_registry"
19
19
  require_relative "../prompts/commands"
20
20
  require_relative "../session_store"
21
+ require_relative "../session_naming"
21
22
  require_relative "../session_trash"
22
23
  require_relative "../steering"
23
24
  require_relative "../tools/tool_call"
@@ -26,6 +27,7 @@ require_relative "../transcript_export"
26
27
  require_relative "../workspace"
27
28
  require_relative "attachment_normalizer"
28
29
  require_relative "config_manager"
30
+ require_relative "memory_methods"
29
31
  require_relative "prompt_bridge"
30
32
  require_relative "runtime_payloads"
31
33
  require_relative "session_metrics"
@@ -51,6 +53,8 @@ module Kward
51
53
  # `ToolRegistry`. This class should coordinate those pieces rather than own
52
54
  # their low-level mechanics.
53
55
  class SessionManager
56
+ include MemoryMethods
57
+
54
58
  RECENT_EVENT_LIMIT = 1_000
55
59
  RPC_ATTACHMENT_MAX_BYTES = AttachmentNormalizer::MAX_BYTES
56
60
  RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
@@ -391,78 +395,6 @@ module Kward
391
395
  run_plugin_command(session_id: session_id, command: name, arguments: arguments)
392
396
  end
393
397
 
394
- def memory_manager
395
- Memory::Manager.for_config_dir(@config_dir)
396
- end
397
-
398
- def memory_status
399
- manager = memory_manager
400
- { enabled: manager.enabled?, autoSummary: manager.auto_summary_enabled?, paths: manager.paths }
401
- end
402
-
403
- def memory_enable
404
- memory_manager.enable
405
- { enabled: true }
406
- end
407
-
408
- def memory_disable
409
- memory_manager.disable
410
- { enabled: false }
411
- end
412
-
413
- def memory_auto_summary_enable
414
- memory_manager.auto_summary_enable
415
- { autoSummary: true }
416
- end
417
-
418
- def memory_auto_summary_disable
419
- memory_manager.auto_summary_disable
420
- { autoSummary: false }
421
- end
422
-
423
- def memory_list(include_inactive: false, workspace_root: Dir.pwd)
424
- memory_manager.hierarchy(include_inactive: include_inactive, workspace_root: workspace_root)
425
- end
426
-
427
- def memory_add(text:, scope: nil, tags: [])
428
- { memory: memory_manager.add_soft(text, scope: scope || "global", tags: tags) }
429
- end
430
-
431
- def memory_add_core(text:, scope: nil, tags: [])
432
- { memory: memory_manager.add_core(text, scope: scope || "global", tags: tags) }
433
- end
434
-
435
- def memory_forget(id:)
436
- { forgotten: memory_manager.forget_memory(id) }
437
- end
438
-
439
- def memory_promote(id:)
440
- { memory: memory_manager.promote_memory(id) }
441
- end
442
-
443
- def memory_relax(id:, workspace_root: Dir.pwd)
444
- { memory: memory_manager.relax_core(id, workspace_root: workspace_root) }
445
- end
446
-
447
- def memory_inspect
448
- memory_manager.inspect_memory
449
- end
450
-
451
- def memory_why(session_id: nil)
452
- if session_id
453
- rpc_session = fetch_session(session_id)
454
- return rpc_session.conversation.last_memory_retrieval || memory_manager.explain_retrieval
455
- end
456
- memory_manager.explain_retrieval
457
- end
458
-
459
- def memory_summarize(session_id:)
460
- rpc_session = fetch_session(session_id)
461
- records = memory_manager.summarize_conversation(rpc_session.conversation, client: @client)
462
- persist_memory_state(rpc_session)
463
- { memories: records }
464
- end
465
-
466
398
  def run_plugin_command(session_id:, command:, arguments: "")
467
399
  rpc_session = fetch_session(session_id)
468
400
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
@@ -613,11 +545,15 @@ module Kward
613
545
  end
614
546
 
615
547
  def refresh_session_runtime_contexts
548
+ provider = current_model[:provider]
616
549
  model = current_model_id
617
550
  reasoning_effort = current_reasoning_effort
618
551
  sessions = @mutex.synchronize { @sessions.values }
619
552
  sessions.each do |rpc_session|
620
- rpc_session.conversation.update_runtime_context!(provider: current_model[:provider], model: model, reasoning_effort: reasoning_effort)
553
+ conversation = rpc_session.conversation
554
+ runtime_changed = [conversation.provider, conversation.model, conversation.reasoning_effort] != [provider, model, reasoning_effort]
555
+ conversation.update_runtime_context!(provider: provider, model: model, reasoning_effort: reasoning_effort)
556
+ conversation.persist_runtime_context! if runtime_changed
621
557
  end
622
558
  end
623
559
 
@@ -1036,7 +972,7 @@ module Kward
1036
972
  end
1037
973
 
1038
974
  def default_session_name(input)
1039
- input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
975
+ SessionNaming.default_name(input)
1040
976
  end
1041
977
 
1042
978
  def turn_display_input(turn)
@@ -75,6 +75,17 @@ module Kward
75
75
  index ? text.to_s[index..] : nil
76
76
  end
77
77
 
78
+ def changed_files_from_result(text, matching_call = nil)
79
+ path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
80
+ return [path] if path
81
+
82
+ if (match = text.to_s.match(/\A(?:Wrote \d+ bytes to|Edited)\s+([^:\n]+)/))
83
+ [match[1].strip]
84
+ else
85
+ []
86
+ end
87
+ end
88
+
78
89
  def error_result?(text)
79
90
  text.to_s.start_with?("Error:", "Declined:", "Cancelled.")
80
91
  end
@@ -106,7 +106,7 @@ module Kward
106
106
  type: "toolCall",
107
107
  id: ToolCall.id(tool_call),
108
108
  name: normalize_tool_name(raw_name) || raw_name,
109
- arguments: normalize_tool_arguments(raw_name, ToolCall.raw_arguments(tool_call))
109
+ arguments: ToolMetadata.normalize_tool_args(raw_name, ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call)))
110
110
  }.compact
111
111
  end
112
112
 
@@ -235,7 +235,7 @@ module Kward
235
235
  type: "toolCall",
236
236
  id: ToolCall.value(part, :id),
237
237
  name: normalize_tool_name(raw_name) || raw_name,
238
- arguments: normalize_tool_arguments(raw_name, arguments)
238
+ arguments: ToolMetadata.normalize_tool_args(raw_name, ToolCall.parse_arguments(arguments))
239
239
  }.compact
240
240
  end
241
241
 
@@ -244,10 +244,10 @@ module Kward
244
244
  details = explicit_details.is_a?(Hash) ? safe_details(explicit_details) : {}
245
245
  text = content_text(content)
246
246
 
247
- diff = details[:diff] || details["diff"] || extract_unified_diff(text)
247
+ diff = details[:diff] || details["diff"] || ToolMetadata.extract_unified_diff(text)
248
248
  details[:diff] = diff if diff
249
249
 
250
- changed_files = details[:changedFiles] || details["changedFiles"] || changed_files_from_result(text, matching_call)
250
+ changed_files = details[:changedFiles] || details["changedFiles"] || ToolMetadata.changed_files_from_result(text, matching_call)
251
251
  details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
252
252
 
253
253
  details
@@ -262,21 +262,6 @@ module Kward
262
262
  allowed
263
263
  end
264
264
 
265
- def extract_unified_diff(text)
266
- ToolMetadata.extract_unified_diff(text)
267
- end
268
-
269
- def changed_files_from_result(text, matching_call)
270
- path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
271
- return [path] if path
272
-
273
- if (match = text.match(/\A(?:Wrote \d+ bytes to|Edited)\s+([^:\n]+)/))
274
- [match[1].strip]
275
- else
276
- []
277
- end
278
- end
279
-
280
265
  def error_tool_result?(message, content)
281
266
  return ToolCall.value(message, :isError) if has_key?(message, :isError)
282
267
  return ToolCall.value(message, :is_error) if has_key?(message, :is_error)
@@ -299,26 +284,6 @@ module Kward
299
284
  ToolMetadata.normalize_tool_name(name)
300
285
  end
301
286
 
302
- def normalize_tool_arguments(name, arguments)
303
- args = ToolCall.parse_arguments(arguments)
304
- case name.to_s
305
- when "edit_file", "edit"
306
- ToolMetadata.normalize_tool_args(name, args)
307
- when "run_shell_command", "bash"
308
- normalize_bash_args(args)
309
- else
310
- ToolCall.camelize_args(args)
311
- end
312
- end
313
-
314
- def normalize_bash_args(args)
315
- normalized = ToolCall.camelize_args(args)
316
- timeout = ToolCall.value(args, :timeoutSeconds) || ToolCall.value(args, :timeout_seconds)
317
- normalized[:timeoutSeconds] = timeout if timeout
318
- normalized.delete(:timeout_seconds)
319
- normalized
320
- end
321
-
322
287
  def normalize_mime_type(mime_type)
323
288
  mime_type.to_s.downcase.sub("image/jpg", "image/jpeg")
324
289
  end
@@ -0,0 +1,56 @@
1
+ require "open3"
2
+ require "rbconfig"
3
+ require "tempfile"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Executes scratchpad buffers and returns transformed buffer content.
8
+ module ScratchpadRunner
9
+ RUBY_END_MARKER_PATTERN = /^__END__\n?/.freeze
10
+
11
+ Result = Struct.new(:buffer, :exit_status, keyword_init: true)
12
+
13
+ module_function
14
+
15
+ def run(language, content)
16
+ case language&.to_sym
17
+ when :ruby
18
+ run_ruby(content)
19
+ else
20
+ raise ArgumentError, "Scratchpad language #{language.inspect} is not runnable"
21
+ end
22
+ end
23
+
24
+ def run_ruby(content)
25
+ content = content.to_s
26
+ output, status = capture_ruby_output(content)
27
+ Result.new(buffer: ruby_buffer_with_output(content, output, status.exitstatus), exit_status: status.exitstatus)
28
+ end
29
+
30
+ def capture_ruby_output(content)
31
+ Tempfile.create(["kward-scratchpad", ".rb"]) do |file|
32
+ file.write(content)
33
+ file.flush
34
+ Open3.capture2e(RbConfig.ruby, file.path)
35
+ end
36
+ end
37
+
38
+ def ruby_buffer_with_output(content, output, exit_status)
39
+ output = output.to_s
40
+ output += "\n" unless output.empty? || output.end_with?("\n")
41
+ output += "[exit status: #{exit_status}]\n" unless exit_status.to_i.zero?
42
+
43
+ if (match = content.match(RUBY_END_MARKER_PATTERN))
44
+ "#{content[0...match.begin(0)]}__END__\n#{output}"
45
+ else
46
+ "#{content}#{ruby_end_separator(content)}__END__\n#{output}"
47
+ end
48
+ end
49
+
50
+ def ruby_end_separator(content)
51
+ return "" if content.empty?
52
+
53
+ content.end_with?("\n") ? "\n" : "\n\n"
54
+ end
55
+ end
56
+ end