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
@@ -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,10 +293,17 @@ module Kward
215
293
  [session, conversation]
216
294
  end
217
295
 
218
- def recent(limit: 20)
219
- limit ? recent_sessions.first(limit) : recent_sessions
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
301
+ def recent(limit: 20, keep_empty_path: nil)
302
+ sessions = recent_sessions(keep_empty_path: keep_empty_path)
303
+ limit ? sessions.first(limit) : sessions
220
304
  end
221
305
 
306
+ # Persists the last active session pointer for workspace auto-resume.
222
307
  def remember_last_session(session)
223
308
  return unless session&.path
224
309
 
@@ -230,6 +315,7 @@ module Kward
230
315
  File.join(session_dir, LAST_SESSION_FILENAME)
231
316
  end
232
317
 
318
+ # @return [String, nil] remembered session path when the file still exists
233
319
  def remembered_last_session_path
234
320
  return nil unless File.file?(last_session_path)
235
321
 
@@ -241,11 +327,18 @@ module Kward
241
327
  nil
242
328
  end
243
329
 
244
- def recent_tree(limit: 20)
245
- sessions = limit ? recent_sessions.first(limit) : recent_sessions
330
+ # Lists recent sessions decorated with parent/branch display metadata.
331
+ #
332
+ # @return [Array<SessionInfo>] recent sessions with tree depth fields
333
+ def recent_tree(limit: 20, keep_empty_path: nil)
334
+ sessions = recent_sessions(keep_empty_path: keep_empty_path)
335
+ sessions = sessions.first(limit) if limit
246
336
  decorate_tree(sessions)
247
337
  end
248
338
 
339
+ # Deletes an empty unnamed session file.
340
+ #
341
+ # @return [Boolean] true when a file was removed
249
342
  def delete_unused_session(session)
250
343
  path = session.path
251
344
  return false if session_named?(session)
@@ -262,6 +355,9 @@ module Kward
262
355
  end
263
356
 
264
357
 
358
+ # Builds a persisted tree record and assigns a stable entry id to messages.
359
+ #
360
+ # @return [Hash] JSONL-ready tree record
265
361
  def build_tree_record(path, type, parent_id, fields = {})
266
362
  message = fields[:message]
267
363
  id = message_entry_id(message) || next_entry_id(path)
@@ -292,11 +388,13 @@ module Kward
292
388
  })
293
389
  end
294
390
 
391
+ # @return [Array<Hash>] nested session tree roots for the given session file
295
392
  def session_tree(path)
296
393
  records = records_from_file(resolve_session_path(path))
297
394
  build_session_tree(records)
298
395
  end
299
396
 
397
+ # @return [Array<Hash>] flat tree records with resolved labels attached
300
398
  def session_entries(path)
301
399
  records = records_from_file(resolve_session_path(path))
302
400
  labels = labels_by_target(records)
@@ -310,10 +408,14 @@ module Kward
310
408
  end
311
409
  end
312
410
 
411
+ # Finds one persisted tree entry by id.
412
+ #
413
+ # @return [Hash, nil]
313
414
  def session_entry(path, entry_id)
314
415
  session_entries(path).find { |record| record["id"].to_s == entry_id.to_s }
315
416
  end
316
417
 
418
+ # @return [String, nil] current active tree leaf id
317
419
  def current_leaf(path)
318
420
  current_leaf_id(records_from_file(resolve_session_path(path)))
319
421
  end
@@ -349,16 +451,10 @@ module Kward
349
451
  end
350
452
 
351
453
  def normalize_tree_records(records)
352
- parent_id = nil
353
- entry_index = 0
354
454
  records.each do |record|
355
- next unless tree_entry_record?(record)
455
+ next unless tree_entry_record?(record) && !record["id"].to_s.empty?
356
456
 
357
- record["id"] = "message:#{entry_index}" if record["id"].to_s.empty?
358
- record["parentId"] = parent_id unless record.key?("parentId")
359
457
  assign_message_entry_id(record["message"], record["id"]) if record["message"].is_a?(Hash) && message_entry_id(record["message"]).to_s.empty?
360
- parent_id = record["id"]
361
- entry_index += 1
362
458
  end
363
459
  records
364
460
  end
@@ -412,12 +508,14 @@ module Kward
412
508
 
413
509
  def session_runtime(records, header)
414
510
  result = {
511
+ "provider" => header["provider"],
415
512
  "model" => header["model"],
416
513
  "reasoningEffort" => header["reasoningEffort"]
417
514
  }
418
515
  records.each do |record|
419
516
  next unless record["type"] == "session_info"
420
517
 
518
+ result["provider"] = record["provider"] if record.key?("provider")
421
519
  result["model"] = record["model"] if record.key?("model")
422
520
  result["reasoningEffort"] = record["reasoningEffort"] if record.key?("reasoningEffort")
423
521
  end
@@ -469,9 +567,7 @@ module Kward
469
567
  end
470
568
 
471
569
  def branch_records(records)
472
- return legacy_branch_records(records) unless records.any? { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
473
-
474
- entries = records.select { |record| tree_entry_record?(record) }
570
+ entries = records.select { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
475
571
  by_id = entries.to_h { |record| [record["id"].to_s, record] }
476
572
  leaf_id = current_leaf_id(records)
477
573
  return [] if leaf_id.nil?
@@ -488,10 +584,6 @@ module Kward
488
584
  branch.reverse
489
585
  end
490
586
 
491
- def legacy_branch_records(records)
492
- records.select { |record| ["message", "compaction"].include?(record["type"]) }
493
- end
494
-
495
587
  def current_leaf_id(records)
496
588
  latest = records.reverse.find { |record| record["type"] == "leaf" || (tree_entry_record?(record) && !record["id"].to_s.empty?) }
497
589
  return nil unless latest
@@ -574,18 +666,20 @@ module Kward
574
666
  nil
575
667
  end
576
668
 
577
- def recent_sessions
669
+ def recent_sessions(keep_empty_path: nil)
670
+ keep_empty_path = File.expand_path(keep_empty_path) unless keep_empty_path.to_s.empty?
578
671
  Dir.glob(File.join(session_dir, "*.jsonl")).filter_map do |path|
579
672
  info = session_info(path)
580
673
  next unless info
581
- next if delete_empty_unnamed_session_info(info)
674
+ next if delete_empty_unnamed_session_info(info, keep_empty_path: keep_empty_path)
582
675
 
583
676
  info
584
677
  end.sort_by { |info| info.modified_at || Time.at(0) }.reverse
585
678
  end
586
679
 
587
- def delete_empty_unnamed_session_info(info)
680
+ def delete_empty_unnamed_session_info(info, keep_empty_path: nil)
588
681
  return false unless info.name.to_s.strip.empty? && info.message_count.to_i.zero?
682
+ return true if keep_empty_path && File.expand_path(info.path) == keep_empty_path
589
683
 
590
684
  File.delete(info.path)
591
685
  true
@@ -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