kward 0.67.1 → 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 +20 -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 +36 -9
  93. data/lib/kward/rpc/session_manager.rb +121 -345
  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 +114 -24
  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
@@ -10,17 +10,51 @@ require_relative "rpc/tool_event_normalizer"
10
10
  require_relative "tools/tool_call"
11
11
  require_relative "workspace"
12
12
 
13
+ # Namespace for the Kward CLI agent runtime.
13
14
  module Kward
15
+ # JSONL-backed persistence for CLI and RPC conversations.
16
+ #
17
+ # A session file is an append-only event log: a header record, message/tree
18
+ # records, metadata changes, memory state, tool execution metadata, labels, and
19
+ # branch navigation. `SessionStore` owns disk layout and reconstruction of a
20
+ # `Conversation`; frontends own when to create, resume, clone, compact, or
21
+ # delete sessions.
22
+ #
23
+ # The tree fields (`id`, `parentId`, leaf records, labels) are part of the
24
+ # persisted user-data contract. Keep backward compatibility in mind before
25
+ # changing record shapes, and prefer adding records over rewriting existing
26
+ # files.
14
27
  class SessionStore
15
28
  VERSION = 2
16
29
  LAST_SESSION_FILENAME = "last_session.json"
17
30
 
18
31
  SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
19
32
 
33
+ # Live handle that attaches persistence callbacks to a conversation.
34
+ #
35
+ # Once attached, every append/compact/tool execution writes a JSONL record and
36
+ # advances `leaf_id` for session tree navigation. Avoid mutating the attached
37
+ # conversation directly without these callbacks unless deliberately importing
38
+ # or reconstructing history.
20
39
  class Session
21
- attr_reader :id, :path, :cwd, :created_at, :parent_id, :parent_path
22
- attr_accessor :name, :leaf_id
23
-
40
+ # @return [String] stable persisted session id from the JSONL header
41
+ attr_reader :id
42
+ # @return [String] absolute JSONL session file path
43
+ attr_reader :path
44
+ # @return [String] workspace directory recorded when the session was created
45
+ attr_reader :cwd
46
+ # @return [Time] creation timestamp used for sorting and filenames
47
+ attr_reader :created_at
48
+ # @return [String, nil] source session id when this session was cloned or forked
49
+ attr_reader :parent_id
50
+ # @return [String, nil] source session path when this session was cloned or forked
51
+ attr_reader :parent_path
52
+ # @return [String, nil] user-visible session name persisted as metadata records
53
+ attr_accessor :name
54
+ # @return [String, nil] active tree leaf id used to restore the selected branch
55
+ attr_accessor :leaf_id
56
+
57
+ # Creates an object for JSONL session persistence.
24
58
  def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil, leaf_id: nil)
25
59
  @store = store
26
60
  @id = id
@@ -33,29 +67,39 @@ module Kward
33
67
  @leaf_id = leaf_id
34
68
  end
35
69
 
70
+ # Installs persistence callbacks on `conversation`.
71
+ #
72
+ # The callbacks are intentionally simple lambdas so `Conversation` remains
73
+ # storage-agnostic while `SessionStore` remains the only owner of JSONL
74
+ # record shape.
36
75
  def attach(conversation)
37
76
  conversation.on_append = lambda { |message| append_message(message) }
38
77
  conversation.on_compact = lambda { |message| compact(message) }
39
78
  conversation.on_tool_execution = lambda { |tool_call, content| append_tool_execution(tool_call, content) }
79
+ conversation.on_runtime_update = lambda { |provider:, model:, reasoning_effort:| update_runtime(provider: provider, model: model, reasoning_effort: reasoning_effort) }
40
80
  self
41
81
  end
42
82
 
83
+ # Persists a message as a tree entry and advances the session leaf.
43
84
  def append_message(message)
44
85
  record = @store.build_tree_record(@path, "message", @leaf_id, message: message)
45
86
  @leaf_id = record[:id]
46
87
  @store.append_record(@path, record)
47
88
  end
48
89
 
90
+ # Persists a compaction summary entry and makes it the active leaf.
49
91
  def compact(message)
50
92
  record = @store.build_tree_record(@path, "compaction", @leaf_id, message: message)
51
93
  @leaf_id = record[:id]
52
94
  @store.append_record(@path, record)
53
95
  end
54
96
 
97
+ # Persists normalized tool execution metadata alongside transcript messages.
55
98
  def append_tool_execution(tool_call, content)
56
99
  @store.append_record(@path, RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record)
57
100
  end
58
101
 
102
+ # Persists the session memory snapshot used when the session is restored.
59
103
  def update_memory_state(session_memories:, last_retrieval: nil)
60
104
  @store.append_record(@path, {
61
105
  type: "memory_state",
@@ -65,6 +109,7 @@ module Kward
65
109
  })
66
110
  end
67
111
 
112
+ # Persists a user-visible session name without rewriting earlier records.
68
113
  def rename(name)
69
114
  @name = name.to_s.strip.empty? ? nil : name.to_s.strip
70
115
  @store.append_record(@path, {
@@ -74,19 +119,23 @@ module Kward
74
119
  })
75
120
  end
76
121
 
122
+ # Moves the active leaf to an existing entry so future messages fork there.
77
123
  def branch(entry_id)
78
124
  @leaf_id = entry_id.to_s.empty? ? nil : entry_id.to_s
79
125
  @store.append_leaf_change(@path, @leaf_id)
80
126
  end
81
127
 
128
+ # Clears the active leaf so the next append starts a fresh root branch.
82
129
  def reset_leaf
83
130
  branch(nil)
84
131
  end
85
132
 
133
+ # Persists a display label override for one tree entry.
86
134
  def append_label_change(entry_id, label)
87
135
  @store.append_label_change(@path, entry_id, label)
88
136
  end
89
137
 
138
+ # Adds a branch-summary node under `parent_id` and selects it as the leaf.
90
139
  def append_branch_summary(parent_id, from_id:, summary:, details: {})
91
140
  record = @store.build_tree_record(@path, "branch_summary", parent_id, fromId: from_id, summary: summary, details: details || {})
92
141
  @leaf_id = record[:id]
@@ -94,29 +143,38 @@ module Kward
94
143
  record[:id]
95
144
  end
96
145
 
97
- def update_runtime(model:, reasoning_effort:)
146
+ # Persists model/runtime metadata so restored sessions keep their context.
147
+ def update_runtime(provider: nil, model:, reasoning_effort:)
98
148
  @store.append_record(@path, {
99
149
  type: "session_info",
100
150
  timestamp: Time.now.utc.iso8601(3),
101
151
  name: @name,
152
+ provider: provider.to_s,
102
153
  model: model.to_s,
103
154
  reasoningEffort: reasoning_effort.to_s
104
155
  }.delete_if { |_key, value| value.to_s.empty? })
105
156
  end
106
157
 
158
+ # Removes this session file when it is still empty and unnamed.
107
159
  def delete_if_unused
108
160
  @store.delete_unused_session(self)
109
161
  end
110
162
  end
111
163
 
164
+ # Creates an object for JSONL session persistence.
112
165
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
113
166
  @config_dir = config_dir
114
167
  @cwd = File.expand_path(cwd)
115
168
  end
116
169
 
170
+ # @return [String] workspace directory this store lists and creates sessions for
117
171
  attr_reader :cwd
118
172
 
119
- def create(model: nil, reasoning_effort: nil, parent_id: nil, parent_path: nil)
173
+ # Creates a new empty session file for the store's workspace directory.
174
+ #
175
+ # Parent fields record clone/fork ancestry; they do not imply live coupling
176
+ # between files after creation.
177
+ def create(provider: nil, model: nil, reasoning_effort: nil, parent_id: nil, parent_path: nil)
120
178
  dir = session_dir
121
179
  FileUtils.mkdir_p(dir, mode: 0o700)
122
180
  created_at = Time.now.utc
@@ -128,6 +186,7 @@ module Kward
128
186
  id: id,
129
187
  timestamp: created_at.iso8601(3),
130
188
  cwd: @cwd,
189
+ provider: provider.to_s,
131
190
  model: model.to_s,
132
191
  reasoningEffort: reasoning_effort.to_s,
133
192
  parentId: parent_id.to_s,
@@ -144,7 +203,7 @@ module Kward
144
203
  end
145
204
 
146
205
  def create_from_conversation(conversation, parent_session: nil)
147
- session = create(model: conversation.model, reasoning_effort: conversation.reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
206
+ session = create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
148
207
  session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
149
208
  persisted_messages(conversation).each { |message| session.append_message(message) }
150
209
  session.attach(conversation)
@@ -155,22 +214,35 @@ module Kward
155
214
  create_independent_from_messages(
156
215
  persisted_messages(conversation),
157
216
  read_paths: Array(conversation.read_paths),
217
+ provider: conversation.provider,
158
218
  model: conversation.model,
159
219
  reasoning_effort: conversation.reasoning_effort,
160
220
  parent_session: parent_session
161
221
  )
162
222
  end
163
223
 
164
- def create_independent_from_messages(messages, read_paths: [], model: nil, reasoning_effort: nil, parent_session: nil)
165
- session = create(model: model, reasoning_effort: reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
224
+ # Creates a new session containing an independent copy of selected messages.
225
+ #
226
+ # Used by clone/fork flows where the new conversation must preserve selected
227
+ # history but then diverge without mutating the source session file.
228
+ #
229
+ # @param messages [Array<Hash>] messages to persist into the new session
230
+ # @param read_paths [Array<String>] restored read-before-write paths
231
+ # @param parent_session [Session, nil] optional source session metadata
232
+ # @return [Array(Session, Conversation)] new session handle and attached conversation
233
+ def create_independent_from_messages(messages, read_paths: [], provider: nil, model: nil, reasoning_effort: nil, parent_session: nil)
234
+ session = create(provider: provider, model: model, reasoning_effort: reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
166
235
  session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
167
236
  persisted = deep_copy(messages)
168
237
  persisted.each { |message| session.append_message(message) }
169
- conversation = Conversation.new(messages: deep_copy(persisted), read_paths: read_paths, workspace_root: @cwd, model: model, reasoning_effort: reasoning_effort)
238
+ conversation = Conversation.new(messages: deep_copy(persisted), read_paths: read_paths, workspace_root: @cwd, provider: provider, model: model, reasoning_effort: reasoning_effort)
170
239
  session.attach(conversation)
171
240
  [session, conversation]
172
241
  end
173
242
 
243
+ # Resolves a user-provided path and returns the stored workspace location.
244
+ #
245
+ # @return [Hash] `:path` and `:cwd` for loading the session safely
174
246
  def session_location(path)
175
247
  resolved_path = resolve_session_path(path)
176
248
  records = records_from_file(resolved_path)
@@ -178,7 +250,12 @@ module Kward
178
250
  { path: resolved_path, cwd: header["cwd"].to_s.empty? ? @cwd : header["cwd"].to_s }
179
251
  end
180
252
 
181
- def load(path, workspace: Workspace.new, model: nil, reasoning_effort: nil)
253
+ # Loads a session file and reconstructs its current conversation leaf.
254
+ #
255
+ # `workspace` is used both for the active root and to restore read-before-write
256
+ # paths from successful read tool results. If a session moved workspaces, load
257
+ # it through `session_location` first so the original cwd is respected.
258
+ def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil)
182
259
  resolved_path = resolve_session_path(path)
183
260
  records = records_from_file(resolved_path)
184
261
  header = session_header(records, resolved_path)
@@ -194,6 +271,7 @@ module Kward
194
271
  messages: messages,
195
272
  read_paths: read_paths,
196
273
  workspace_root: workspace.root,
274
+ provider: runtime["provider"] || provider,
197
275
  model: runtime["model"] || model,
198
276
  reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
199
277
  session_memories: memory_state["sessionMemories"],
@@ -215,11 +293,17 @@ module Kward
215
293
  [session, conversation]
216
294
  end
217
295
 
296
+ # Lists recent non-empty sessions for this workspace.
297
+ #
298
+ # @param limit [Integer, nil] maximum number of sessions, or nil for all
299
+ # @param keep_empty_path [String, nil] empty session path to keep visible
300
+ # @return [Array<SessionInfo>] newest sessions first
218
301
  def recent(limit: 20, keep_empty_path: nil)
219
302
  sessions = recent_sessions(keep_empty_path: keep_empty_path)
220
303
  limit ? sessions.first(limit) : sessions
221
304
  end
222
305
 
306
+ # Persists the last active session pointer for workspace auto-resume.
223
307
  def remember_last_session(session)
224
308
  return unless session&.path
225
309
 
@@ -231,6 +315,7 @@ module Kward
231
315
  File.join(session_dir, LAST_SESSION_FILENAME)
232
316
  end
233
317
 
318
+ # @return [String, nil] remembered session path when the file still exists
234
319
  def remembered_last_session_path
235
320
  return nil unless File.file?(last_session_path)
236
321
 
@@ -242,12 +327,18 @@ module Kward
242
327
  nil
243
328
  end
244
329
 
330
+ # Lists recent sessions decorated with parent/branch display metadata.
331
+ #
332
+ # @return [Array<SessionInfo>] recent sessions with tree depth fields
245
333
  def recent_tree(limit: 20, keep_empty_path: nil)
246
334
  sessions = recent_sessions(keep_empty_path: keep_empty_path)
247
335
  sessions = sessions.first(limit) if limit
248
336
  decorate_tree(sessions)
249
337
  end
250
338
 
339
+ # Deletes an empty unnamed session file.
340
+ #
341
+ # @return [Boolean] true when a file was removed
251
342
  def delete_unused_session(session)
252
343
  path = session.path
253
344
  return false if session_named?(session)
@@ -264,6 +355,9 @@ module Kward
264
355
  end
265
356
 
266
357
 
358
+ # Builds a persisted tree record and assigns a stable entry id to messages.
359
+ #
360
+ # @return [Hash] JSONL-ready tree record
267
361
  def build_tree_record(path, type, parent_id, fields = {})
268
362
  message = fields[:message]
269
363
  id = message_entry_id(message) || next_entry_id(path)
@@ -294,11 +388,13 @@ module Kward
294
388
  })
295
389
  end
296
390
 
391
+ # @return [Array<Hash>] nested session tree roots for the given session file
297
392
  def session_tree(path)
298
393
  records = records_from_file(resolve_session_path(path))
299
394
  build_session_tree(records)
300
395
  end
301
396
 
397
+ # @return [Array<Hash>] flat tree records with resolved labels attached
302
398
  def session_entries(path)
303
399
  records = records_from_file(resolve_session_path(path))
304
400
  labels = labels_by_target(records)
@@ -312,10 +408,14 @@ module Kward
312
408
  end
313
409
  end
314
410
 
411
+ # Finds one persisted tree entry by id.
412
+ #
413
+ # @return [Hash, nil]
315
414
  def session_entry(path, entry_id)
316
415
  session_entries(path).find { |record| record["id"].to_s == entry_id.to_s }
317
416
  end
318
417
 
418
+ # @return [String, nil] current active tree leaf id
319
419
  def current_leaf(path)
320
420
  current_leaf_id(records_from_file(resolve_session_path(path)))
321
421
  end
@@ -351,16 +451,10 @@ module Kward
351
451
  end
352
452
 
353
453
  def normalize_tree_records(records)
354
- parent_id = nil
355
- entry_index = 0
356
454
  records.each do |record|
357
- next unless tree_entry_record?(record)
455
+ next unless tree_entry_record?(record) && !record["id"].to_s.empty?
358
456
 
359
- record["id"] = "message:#{entry_index}" if record["id"].to_s.empty?
360
- record["parentId"] = parent_id unless record.key?("parentId")
361
457
  assign_message_entry_id(record["message"], record["id"]) if record["message"].is_a?(Hash) && message_entry_id(record["message"]).to_s.empty?
362
- parent_id = record["id"]
363
- entry_index += 1
364
458
  end
365
459
  records
366
460
  end
@@ -414,12 +508,14 @@ module Kward
414
508
 
415
509
  def session_runtime(records, header)
416
510
  result = {
511
+ "provider" => header["provider"],
417
512
  "model" => header["model"],
418
513
  "reasoningEffort" => header["reasoningEffort"]
419
514
  }
420
515
  records.each do |record|
421
516
  next unless record["type"] == "session_info"
422
517
 
518
+ result["provider"] = record["provider"] if record.key?("provider")
423
519
  result["model"] = record["model"] if record.key?("model")
424
520
  result["reasoningEffort"] = record["reasoningEffort"] if record.key?("reasoningEffort")
425
521
  end
@@ -471,9 +567,7 @@ module Kward
471
567
  end
472
568
 
473
569
  def branch_records(records)
474
- return legacy_branch_records(records) unless records.any? { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
475
-
476
- entries = records.select { |record| tree_entry_record?(record) }
570
+ entries = records.select { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
477
571
  by_id = entries.to_h { |record| [record["id"].to_s, record] }
478
572
  leaf_id = current_leaf_id(records)
479
573
  return [] if leaf_id.nil?
@@ -490,10 +584,6 @@ module Kward
490
584
  branch.reverse
491
585
  end
492
586
 
493
- def legacy_branch_records(records)
494
- records.select { |record| ["message", "compaction"].include?(record["type"]) }
495
- end
496
-
497
587
  def current_leaf_id(records)
498
588
  latest = records.reverse.find { |record| record["type"] == "leaf" || (tree_entry_record?(record) && !record["id"].to_s.empty?) }
499
589
  return nil unless latest
@@ -1,6 +1,7 @@
1
1
  require "open3"
2
2
  require "rbconfig"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
5
6
  # Best-effort session file remover. Uses the OS trash/recycle bin when a
6
7
  # supported platform tool is available, and falls back to permanent deletion.
@@ -1,8 +1,11 @@
1
- require "json"
2
1
  require_relative "message_access"
2
+ require_relative "message_text"
3
+ require_relative "session_tree_tool_display"
3
4
  require_relative "tools/tool_call"
4
5
 
6
+ # Namespace for the Kward CLI agent runtime.
5
7
  module Kward
8
+ # Terminal renderer for persisted session tree choices.
6
9
  class SessionTreeRenderer
7
10
  def initialize(roots:, current_leaf_id:)
8
11
  @roots = roots
@@ -173,45 +176,18 @@ module Kward
173
176
 
174
177
  def session_tree_tool_display(message, tool_calls_by_id)
175
178
  tool_call = tool_calls_by_id[session_tree_message_tool_call_id(message).to_s]
176
- return session_tree_format_tool_call(tool_call) if tool_call
179
+ return SessionTreeToolDisplay.label(tool_call) if tool_call
177
180
 
178
181
  name = session_tree_message_tool_name(message).to_s
179
182
  "[#{name.empty? ? 'tool' : name}]"
180
183
  end
181
184
 
182
185
  def session_tree_message_tool_call_id(message)
183
- message_tool_call_id(message) || MessageAccess.value(message, :toolCallId)
186
+ message_tool_call_id(message)
184
187
  end
185
188
 
186
189
  def session_tree_message_tool_name(message)
187
- message_name(message) || MessageAccess.value(message, :toolName)
188
- end
189
-
190
- def session_tree_format_tool_call(tool_call)
191
- name = ToolCall.display_name(tool_call)
192
- args = tool_call_args(tool_call)
193
- case name
194
- when "read"
195
- path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
196
- offset = args["offset"] || args[:offset]
197
- limit = args["limit"] || args[:limit]
198
- display = path.to_s
199
- if offset || limit
200
- start_line = offset || 1
201
- end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
202
- display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
203
- end
204
- "[read: #{display}]"
205
- when "write", "edit"
206
- path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
207
- "[#{name}: #{path}]"
208
- when "bash"
209
- command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
210
- "[bash: #{command.length > 50 ? "#{command.slice(0, 50)}..." : command}]"
211
- else
212
- serialized = JSON.dump(args)
213
- "[#{name}: #{serialized.length > 40 ? "#{serialized.slice(0, 40)}..." : serialized}]"
214
- end
190
+ message_name(message)
215
191
  end
216
192
 
217
193
  def display_message_text(message)
@@ -224,13 +200,7 @@ module Kward
224
200
  end
225
201
 
226
202
  def full_message_text(message)
227
- content = message_content(message)
228
- text = if content.is_a?(Array)
229
- content.filter_map { |part| MessageAccess.value(part, :text) }.join("\n")
230
- else
231
- content.to_s
232
- end
233
- text.strip
203
+ MessageText.full_text(message)
234
204
  end
235
205
 
236
206
  def message_role(message)
@@ -257,8 +227,5 @@ module Kward
257
227
  ToolCall.id(tool_call)
258
228
  end
259
229
 
260
- def tool_call_args(tool_call)
261
- ToolCall.arguments(tool_call)
262
- end
263
230
  end
264
231
  end
@@ -0,0 +1,56 @@
1
+ require "json"
2
+ require_relative "tools/tool_call"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Formats persisted tool calls for compact session tree rows.
7
+ #
8
+ # Both the terminal session tree and the RPC session tree need the same short
9
+ # labels for tool result nodes. Keeping that display rule here prevents the two
10
+ # frontends from drifting while leaving each frontend free to render its own
11
+ # tree prefixes and wire payloads.
12
+ module SessionTreeToolDisplay
13
+ module_function
14
+
15
+ # Returns a concise bracketed label for a tool call.
16
+ #
17
+ # @param tool_call [Hash] OpenAI/Codex-style tool call hash
18
+ # @return [String] label such as `[read: README.md:2-4]`
19
+ def label(tool_call)
20
+ name = ToolCall.display_name(tool_call)
21
+ args = ToolCall.arguments(tool_call)
22
+ case name
23
+ when "read"
24
+ read_label(args)
25
+ when "write", "edit"
26
+ path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
27
+ "[#{name}: #{path}]"
28
+ when "bash"
29
+ command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
30
+ "[bash: #{truncate(command, 50)}]"
31
+ else
32
+ serialized = JSON.dump(args)
33
+ "[#{name}: #{truncate(serialized, 40)}]"
34
+ end
35
+ end
36
+
37
+ def read_label(args)
38
+ path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
39
+ offset = args["offset"] || args[:offset]
40
+ limit = args["limit"] || args[:limit]
41
+ display = path.to_s
42
+ if offset || limit
43
+ start_line = offset || 1
44
+ end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
45
+ display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
46
+ end
47
+ "[read: #{display}]"
48
+ end
49
+ private_class_method :read_label
50
+
51
+ def truncate(text, length)
52
+ text.length > length ? "#{text.slice(0, length)}..." : text
53
+ end
54
+ private_class_method :truncate
55
+ end
56
+ end
@@ -1,7 +1,10 @@
1
1
  require "pathname"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Skill discovery and metadata parsing from configured skill folders.
4
6
  module Skills
7
+ # Parsed skill metadata and instruction path.
5
8
  class Registry
6
9
  def initialize(config_dir:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
7
10
  @config_dir = config_dir
@@ -7,6 +7,7 @@ require "uri"
7
7
  require "zlib"
8
8
  require_relative "config_files"
9
9
 
10
+ # Namespace for the Kward CLI agent runtime.
10
11
  module Kward
11
12
  # Installs Kward's starter prompt/instruction files into the user config dir.
12
13
  class StarterPackInstaller
@@ -1,7 +1,9 @@
1
1
  require "thread"
2
2
  require "time"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Thread-safe queue for in-flight user steering events.
5
7
  class Steering
6
8
  Event = Struct.new(:input, :created_at, keyword_init: true)
7
9
 
@@ -4,7 +4,9 @@ require "time"
4
4
  require_relative "../config_files"
5
5
  require_relative "../rpc/redactor"
6
6
 
7
+ # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
9
+ # Append-only JSONL telemetry logger with secret-conscious error payloads.
8
10
  class TelemetryLogger
9
11
  CATEGORIES = %w[tokens performance tools errors].freeze
10
12
  ENV_KEYS = {
@@ -16,6 +18,7 @@ module Kward
16
18
  }.freeze
17
19
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
18
20
 
21
+ # Creates an object for telemetry event logging.
19
22
  def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
20
23
  @config_path = config_path
21
24
  @log_dir = log_dir
@@ -3,7 +3,9 @@ require "time"
3
3
  require_relative "../config_files"
4
4
  require_relative "logger"
5
5
 
6
+ # Namespace for the Kward CLI agent runtime.
6
7
  module Kward
8
+ # Aggregates local telemetry logs into token and usage statistics.
7
9
  class TelemetryStats
8
10
  DEFAULT_RANGE = "1 week"
9
11
  UNITS = %w[minute hour day week month year].freeze
@@ -34,6 +36,7 @@ module Kward
34
36
  end
35
37
  end
36
38
 
39
+ # Creates an object for telemetry statistics aggregation.
37
40
  def initialize(telemetry_logger: TelemetryLogger.new, clock: Time)
38
41
  @telemetry_logger = telemetry_logger
39
42
  @clock = clock
@@ -1,8 +1,13 @@
1
+ require_relative "../question_contract"
1
2
  require_relative "base"
2
3
 
4
+ # Namespace for the Kward CLI agent runtime.
3
5
  module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
4
7
  module Tools
8
+ # Tool wrapper for structured clarification questions to the user.
5
9
  class AskUserQuestion < Base
10
+ # Builds the tool schema and stores the execution dependency.
6
11
  def initialize(prompt:)
7
12
  @prompt = prompt
8
13
  super(
@@ -42,6 +47,7 @@ module Kward
42
47
  )
43
48
  end
44
49
 
50
+ # Executes the tool and returns model-facing output text.
45
51
  def call(args, _conversation, cancellation: nil)
46
52
  return "Error: ask_user_question requires interactive prompt support." unless @prompt.respond_to?(:ask_user_question)
47
53
 
@@ -67,40 +73,22 @@ module Kward
67
73
  end
68
74
 
69
75
  def validated_questions(args)
70
- questions = argument(args, :questions)
71
- return "Error: ask_user_question requires questions." unless questions.is_a?(Array)
72
- return "Error: ask_user_question requires 1 to 4 questions." unless questions.length.between?(1, 4)
73
-
74
- questions.map.with_index(1) do |question, index|
75
- return "Error: question #{index} must be an object." unless question.respond_to?(:key?)
76
- return "Error: question #{index} uses unsupported multiSelect." if question.key?("multiSelect") || question.key?(:multiSelect)
77
-
78
- text = question_value(question, :question).to_s.strip
79
- header = question_value(question, :header).to_s.strip
80
- options = question_value(question, :options)
81
- return "Error: question #{index} requires question and header." if text.empty? || header.empty?
82
- return "Error: question #{index} requires 2 to 4 options." unless options.is_a?(Array) && options.length.between?(2, 4)
83
-
84
- normalized_options = options.map.with_index(1) do |option, option_index|
85
- return "Error: question #{index} option #{option_index} must be an object." unless option.respond_to?(:key?)
86
- return "Error: question #{index} option #{option_index} uses unsupported preview." if option.key?("preview") || option.key?(:preview)
87
-
88
- label = question_value(option, :label).to_s.strip
89
- description = question_value(option, :description).to_s.strip
90
- return "Error: question #{index} option #{option_index} requires label and description." if label.empty? || description.empty?
91
-
92
- { label: label, description: description }
93
- end
94
-
95
- { question: text, header: header, options: normalized_options }
96
- end
76
+ QuestionContract.normalize_questions(argument(args, :questions))
77
+ rescue ArgumentError => e
78
+ "Error: #{tool_error_message(e.message)}."
97
79
  end
98
80
 
99
- def question_value(object, key)
100
- return object[key] if object.key?(key)
101
- return object[key.to_s] if object.key?(key.to_s)
102
-
103
- nil
81
+ def tool_error_message(message)
82
+ case message
83
+ when "questions must be an array"
84
+ "ask_user_question requires questions"
85
+ when "ui/question requires 1-4 questions"
86
+ "ask_user_question requires 1 to 4 questions"
87
+ else
88
+ message.gsub("2-4", "2 to 4")
89
+ .gsub("multiSelect is unsupported", "uses unsupported multiSelect")
90
+ .gsub("preview is unsupported", "uses unsupported preview")
91
+ end
104
92
  end
105
93
  end
106
94
  end