kward 0.67.0 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -0,0 +1,376 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # Interactive session list, resume, clone, tree, export, and copy helpers.
6
+ module Sessions
7
+ private
8
+
9
+ def interactive_session_store(agent)
10
+ return @session_store if @session_store
11
+ return nil if agent
12
+
13
+ SessionStore.new
14
+ end
15
+
16
+ def resume_last_session(session_store)
17
+ return nil unless session_auto_resume_enabled?
18
+
19
+ path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
20
+ return nil if path.to_s.empty?
21
+
22
+ @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
23
+ reset_session_diff(@active_session.path)
24
+ track_session(@active_session)
25
+ @resumed_last_session = true
26
+ build_interactive_agent(conversation)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def render_resumed_last_session_transcript(conversation)
32
+ restore_prompt_transcript do
33
+ @prompt.say("\nResumed session: #{@active_session.path}\n")
34
+ render_conversation_transcript(conversation)
35
+ end
36
+ end
37
+
38
+ def remember_active_session(session_store)
39
+ return unless session_store&.respond_to?(:remember_last_session)
40
+ return unless @active_session&.path && File.file?(@active_session.path)
41
+
42
+ session_store.remember_last_session(@active_session)
43
+ end
44
+
45
+ def build_new_session_agent(session_store)
46
+ @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
47
+ reset_session_diff
48
+ conversation = new_conversation(workspace_root: session_store.cwd)
49
+ @active_session.attach(conversation)
50
+ build_interactive_agent(conversation)
51
+ end
52
+
53
+ def track_session(session)
54
+ @cleanup_sessions << session if session
55
+ session
56
+ end
57
+
58
+ def reset_session_diff(path = nil)
59
+ @session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
60
+ end
61
+
62
+ def update_session_diff(content, tool_call: nil)
63
+ return unless mutation_tool_call?(tool_call)
64
+ return unless @session_diff&.add_tool_result(content)
65
+
66
+ @prompt.redraw if @prompt.respond_to?(:redraw)
67
+ end
68
+
69
+ def mutation_tool_call?(tool_call)
70
+ ["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
71
+ end
72
+
73
+ def cleanup_unused_sessions
74
+ @cleanup_sessions.reverse_each do |session|
75
+ session.delete_if_unused if session.respond_to?(:delete_if_unused)
76
+ end
77
+ @cleanup_sessions.clear
78
+ end
79
+
80
+ def cleanup_replaced_session(previous_session)
81
+ return unless previous_session
82
+ return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
83
+
84
+ previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
85
+ end
86
+
87
+ def start_new_session(session_store)
88
+ return say_sessions_unavailable unless session_store
89
+
90
+ previous_session = @active_session
91
+ @active_session = track_session(session_store.create)
92
+ reset_session_diff
93
+ cleanup_replaced_session(previous_session)
94
+ conversation = new_conversation(workspace_root: session_store.cwd)
95
+ @active_session.attach(conversation)
96
+ update_assistant_prompt(conversation)
97
+ clear_prompt_transcript
98
+ print_visual_banner
99
+ build_interactive_agent(conversation)
100
+ end
101
+
102
+ def resume_session(session_store, argument)
103
+ return say_sessions_unavailable unless session_store
104
+
105
+ path = argument.to_s.strip
106
+ path = select_session_path(session_store) if path.empty?
107
+ return nil if path.to_s.empty?
108
+
109
+ previous_session = @active_session
110
+ @active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
111
+ reset_session_diff(@active_session.path)
112
+ track_session(@active_session)
113
+ cleanup_replaced_session(previous_session)
114
+ update_assistant_prompt(conversation)
115
+ restore_prompt_transcript do
116
+ @prompt.say("\nResumed session: #{@active_session.path}\n")
117
+ render_conversation_transcript(conversation)
118
+ end
119
+ agent = build_interactive_agent(conversation)
120
+ @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
121
+ agent
122
+ rescue StandardError => e
123
+ @prompt.say("\nError: #{e.message}\n")
124
+ nil
125
+ end
126
+
127
+ def navigate_session_tree(session_store)
128
+ return say_sessions_unavailable unless session_store
129
+ unless @active_session
130
+ @prompt.say("\nNo active persisted session.\n")
131
+ return nil
132
+ end
133
+
134
+ tree_items = session_tree_items(session_store)
135
+ if tree_items.empty?
136
+ @prompt.say("\nNo session tree entries found.\n")
137
+ return nil
138
+ end
139
+
140
+ labels_by_entry_id = tree_items.to_h { |item| [item[:entry]["id"].to_s, item[:label]] }
141
+ current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
142
+ initial_index = tree_items.index { |item| item[:entry]["id"].to_s == current_leaf_id.to_s } || tree_items.length - 1
143
+ choice = select_session_tree_entry(labels_by_entry_id.values, initial_index: initial_index)
144
+ return nil unless choice
145
+
146
+ entry_id = labels_by_entry_id.key(choice)
147
+ entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
148
+ return nil unless entry
149
+
150
+ selected_text = apply_session_tree_entry(entry)
151
+ @prompt.say("\nMoved session tree position to #{entry["id"]}.\n")
152
+ if selected_text && !selected_text.empty?
153
+ if @prompt.respond_to?(:prefill_input)
154
+ @prompt.prefill_input(selected_text)
155
+ else
156
+ @prompt.say("\nSelected text for editing:\n#{selected_text}\n")
157
+ end
158
+ end
159
+ agent = reload_active_session(session_store)
160
+ @prompt.redraw if @prompt.respond_to?(:redraw)
161
+ agent
162
+ rescue StandardError => e
163
+ @prompt.say("\nSession tree error: #{e.message}\n")
164
+ nil
165
+ end
166
+
167
+ def select_session_tree_entry(labels, initial_index: 0)
168
+ if @prompt.respond_to?(:select)
169
+ return @prompt.select("Tree>", labels, title: "Session Tree", initial_index: initial_index)
170
+ end
171
+
172
+ numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
173
+ @prompt.say("\nSession tree:\n#{numbered_labels.join("\n")}\n")
174
+ answer = @prompt.ask("Tree entry number>").to_s.strip
175
+ answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
176
+ end
177
+
178
+ def apply_session_tree_entry(entry)
179
+ message = entry["message"]
180
+ if message.is_a?(Hash) && message_role(message) == "user"
181
+ target_leaf = entry["parentId"]
182
+ target_leaf.to_s.empty? ? @active_session.reset_leaf : @active_session.branch(target_leaf)
183
+ return full_message_text(message)
184
+ end
185
+
186
+ @active_session.branch(entry["id"])
187
+ nil
188
+ end
189
+
190
+ def reload_active_session(session_store)
191
+ @active_session, conversation = session_store.load(
192
+ @active_session.path,
193
+ workspace: configured_workspace(root: session_store.cwd),
194
+ provider: current_model_provider,
195
+ model: current_model_id,
196
+ reasoning_effort: current_reasoning_effort
197
+ )
198
+ reset_session_diff(@active_session.path)
199
+ track_session(@active_session)
200
+ update_assistant_prompt(conversation)
201
+ restore_prompt_transcript do
202
+ render_conversation_transcript(conversation)
203
+ end
204
+ build_interactive_agent(conversation)
205
+ end
206
+
207
+ def session_tree_items(session_store)
208
+ roots = session_store.session_tree(@active_session.path)
209
+ current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
210
+ SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
211
+ end
212
+
213
+ def rename_session(argument)
214
+ unless @active_session
215
+ @prompt.say("\nNo active persisted session.\n")
216
+ return
217
+ end
218
+
219
+ @active_session.rename(argument)
220
+ label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
221
+ @prompt.say("\n#{label}\n")
222
+ end
223
+
224
+ def clone_session(session_store, agent)
225
+ return say_sessions_unavailable unless session_store
226
+
227
+ previous_session = @active_session
228
+ @active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
229
+ reset_session_diff(@active_session.path)
230
+ cleanup_replaced_session(previous_session)
231
+ @prompt.say("\nCloned session: #{@active_session.path}\n")
232
+ render_conversation_transcript(agent.conversation)
233
+ agent
234
+ end
235
+
236
+ def copy_session_text(conversation, argument)
237
+ target = copy_target(argument)
238
+ unless target
239
+ @prompt.say("\nUsage: /copy [last|transcript]\n")
240
+ return
241
+ end
242
+
243
+ content = copy_target_content(conversation, target)
244
+ if content.to_s.empty?
245
+ @prompt.say("\nNothing to copy.\n")
246
+ return
247
+ end
248
+
249
+ result = Clipboard.new(output: $stdout).copy(content)
250
+ if result.success?
251
+ @prompt.say("\nCopied #{copy_target_label(target)}.\n")
252
+ else
253
+ @prompt.say("\nCopy failed: #{result.message}.\n")
254
+ end
255
+ end
256
+
257
+ def copy_target(argument)
258
+ target = argument.to_s.strip.downcase
259
+ target = "last" if target.empty?
260
+ return target if ["last", "transcript"].include?(target)
261
+
262
+ nil
263
+ end
264
+
265
+ def full_message_text(message)
266
+ CLITranscriptFormatter.full_text(message)
267
+ end
268
+
269
+ def copy_target_content(conversation, target)
270
+ case target
271
+ when "last"
272
+ last_assistant_copy_text(conversation)
273
+ when "transcript"
274
+ markdown_transcript(conversation)
275
+ else
276
+ ""
277
+ end
278
+ end
279
+
280
+ def last_assistant_copy_text(conversation)
281
+ message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
282
+ return "" unless message
283
+
284
+ CLITranscriptFormatter.content_text(message_content(message))
285
+ end
286
+
287
+ def copy_target_label(target)
288
+ target == "transcript" ? "transcript" : "last assistant response"
289
+ end
290
+
291
+ def export_session(conversation, argument)
292
+ path = export_path(argument)
293
+ File.write(path, markdown_transcript(conversation))
294
+ @prompt.say("\nExported session: #{path}\n")
295
+ rescue StandardError => e
296
+ @prompt.say("\nError: #{e.message}\n")
297
+ end
298
+
299
+ def say_sessions_unavailable
300
+ @prompt.say("\nSessions are unavailable for this interactive loop.\n")
301
+ nil
302
+ end
303
+
304
+ def clear_prompt_transcript
305
+ @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
306
+ end
307
+
308
+ def restore_prompt_transcript(&block)
309
+ if @prompt.respond_to?(:restore_transcript)
310
+ @prompt.restore_transcript(&block)
311
+ else
312
+ block.call
313
+ end
314
+ end
315
+
316
+ def select_session_path(session_store)
317
+ sessions = session_store.recent(limit: nil)
318
+ if sessions.empty?
319
+ @prompt.say("\nNo saved sessions found.\n")
320
+ return nil
321
+ end
322
+
323
+ labels = sessions.map { |session| session_label(session) }
324
+ if @prompt.respond_to?(:select)
325
+ choice = @prompt.select("Session>", labels)
326
+ return nil unless choice
327
+
328
+ selected = sessions[labels.index(choice)]
329
+ return selected&.path
330
+ end
331
+
332
+ numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
333
+ @prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
334
+ answer = @prompt.ask("Session number or path>").to_s.strip
335
+ if answer.match?(/\A\d+\z/)
336
+ sessions[answer.to_i - 1]&.path
337
+ else
338
+ answer
339
+ end
340
+ end
341
+
342
+ def session_label(session)
343
+ title = session.name.to_s.strip
344
+ title = session.first_message.to_s.strip if title.empty?
345
+ title = session.id if title.empty?
346
+ "#{session_tree_prefix(session)}#{title} — #{File.basename(session.path)}"
347
+ end
348
+
349
+ def session_tree_prefix(session)
350
+ depth = session.respond_to?(:depth) ? session.depth.to_i : 0
351
+ return "" if depth <= 0
352
+
353
+ ancestors = session.respond_to?(:ancestor_continues) ? Array(session.ancestor_continues) : []
354
+ prefix = ancestors.map { |continues| continues ? "│ " : " " }.join
355
+ branch = session.respond_to?(:is_last) && session.is_last ? "└─ " : "├─ "
356
+ prefix + branch
357
+ end
358
+
359
+ def export_path(argument)
360
+ default_path = if @active_session
361
+ @active_session.path.sub(/\.jsonl\z/, ".md")
362
+ else
363
+ File.expand_path("kward-session-#{Time.now.utc.iso8601(3).tr(':', '-')}.md", Dir.pwd)
364
+ end
365
+ session_dir = @session_store&.session_dir || (@active_session && File.dirname(@active_session.path))
366
+
367
+ ExportPath.resolve(argument, workspace_root: Dir.pwd, default_path: default_path, session_dir: session_dir)
368
+ end
369
+
370
+ def markdown_transcript(conversation)
371
+ TranscriptExport.content(conversation)
372
+ end
373
+
374
+ end
375
+ end
376
+ end