kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -1,4 +1,5 @@
1
1
  require "fileutils"
2
+ require "digest"
2
3
  require "json"
3
4
  require "securerandom"
4
5
  require "time"
@@ -10,17 +11,51 @@ require_relative "rpc/tool_event_normalizer"
10
11
  require_relative "tools/tool_call"
11
12
  require_relative "workspace"
12
13
 
14
+ # Namespace for the Kward CLI agent runtime.
13
15
  module Kward
16
+ # JSONL-backed persistence for CLI and RPC conversations.
17
+ #
18
+ # A session file is an append-only event log: a header record, message/tree
19
+ # records, metadata changes, memory state, tool execution metadata, labels, and
20
+ # branch navigation. `SessionStore` owns disk layout and reconstruction of a
21
+ # `Conversation`; frontends own when to create, resume, clone, compact, or
22
+ # delete sessions.
23
+ #
24
+ # The tree fields (`id`, `parentId`, leaf records, labels) are part of the
25
+ # persisted user-data contract. Keep backward compatibility in mind before
26
+ # changing record shapes, and prefer adding records over rewriting existing
27
+ # files.
14
28
  class SessionStore
15
29
  VERSION = 2
16
30
  LAST_SESSION_FILENAME = "last_session.json"
17
31
 
18
- 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)
32
+ SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :provider, :model, :reasoning_effort, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
19
33
 
34
+ # Live handle that attaches persistence callbacks to a conversation.
35
+ #
36
+ # Once attached, every append/compact/tool execution writes a JSONL record and
37
+ # advances `leaf_id` for session tree navigation. Avoid mutating the attached
38
+ # conversation directly without these callbacks unless deliberately importing
39
+ # or reconstructing history.
20
40
  class Session
21
- attr_reader :id, :path, :cwd, :created_at, :parent_id, :parent_path
22
- attr_accessor :name, :leaf_id
23
-
41
+ # @return [String] stable persisted session id from the JSONL header
42
+ attr_reader :id
43
+ # @return [String] absolute JSONL session file path
44
+ attr_reader :path
45
+ # @return [String] workspace directory recorded when the session was created
46
+ attr_reader :cwd
47
+ # @return [Time] creation timestamp used for sorting and filenames
48
+ attr_reader :created_at
49
+ # @return [String, nil] source session id when this session was cloned or forked
50
+ attr_reader :parent_id
51
+ # @return [String, nil] source session path when this session was cloned or forked
52
+ attr_reader :parent_path
53
+ # @return [String, nil] user-visible session name persisted as metadata records
54
+ attr_accessor :name
55
+ # @return [String, nil] active tree leaf id used to restore the selected branch
56
+ attr_accessor :leaf_id
57
+
58
+ # Creates an object for JSONL session persistence.
24
59
  def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil, leaf_id: nil)
25
60
  @store = store
26
61
  @id = id
@@ -33,29 +68,46 @@ module Kward
33
68
  @leaf_id = leaf_id
34
69
  end
35
70
 
71
+ # Installs persistence callbacks on `conversation`.
72
+ #
73
+ # The callbacks are intentionally simple lambdas so `Conversation` remains
74
+ # storage-agnostic while `SessionStore` remains the only owner of JSONL
75
+ # record shape.
36
76
  def attach(conversation)
37
77
  conversation.on_append = lambda { |message| append_message(message) }
38
78
  conversation.on_compact = lambda { |message| compact(message) }
39
79
  conversation.on_tool_execution = lambda { |tool_call, content| append_tool_execution(tool_call, content) }
80
+ conversation.on_runtime_update = lambda { |provider:, model:, reasoning_effort:| update_runtime(provider: provider, model: model, reasoning_effort: reasoning_effort) }
81
+ conversation.on_system_message_change = lambda { |system_message| append_system_prompt_snapshot(system_message, reason: "changed") }
82
+ append_system_prompt_snapshot(conversation.system_message, reason: "attach")
40
83
  self
41
84
  end
42
85
 
86
+ # Persists a message as a tree entry and advances the session leaf.
43
87
  def append_message(message)
44
88
  record = @store.build_tree_record(@path, "message", @leaf_id, message: message)
45
89
  @leaf_id = record[:id]
46
90
  @store.append_record(@path, record)
47
91
  end
48
92
 
93
+ # Persists a compaction summary entry and makes it the active leaf.
49
94
  def compact(message)
50
95
  record = @store.build_tree_record(@path, "compaction", @leaf_id, message: message)
51
96
  @leaf_id = record[:id]
52
97
  @store.append_record(@path, record)
53
98
  end
54
99
 
100
+ # Persists normalized tool execution metadata alongside transcript messages.
55
101
  def append_tool_execution(tool_call, content)
56
102
  @store.append_record(@path, RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record)
57
103
  end
58
104
 
105
+ # Persists the current system prompt as audit metadata when it changes.
106
+ def append_system_prompt_snapshot(system_message, reason: "changed")
107
+ @store.append_system_prompt_snapshot(@path, system_message, reason: reason)
108
+ end
109
+
110
+ # Persists the session memory snapshot used when the session is restored.
59
111
  def update_memory_state(session_memories:, last_retrieval: nil)
60
112
  @store.append_record(@path, {
61
113
  type: "memory_state",
@@ -65,6 +117,7 @@ module Kward
65
117
  })
66
118
  end
67
119
 
120
+ # Persists a user-visible session name without rewriting earlier records.
68
121
  def rename(name)
69
122
  @name = name.to_s.strip.empty? ? nil : name.to_s.strip
70
123
  @store.append_record(@path, {
@@ -74,19 +127,23 @@ module Kward
74
127
  })
75
128
  end
76
129
 
130
+ # Moves the active leaf to an existing entry so future messages fork there.
77
131
  def branch(entry_id)
78
132
  @leaf_id = entry_id.to_s.empty? ? nil : entry_id.to_s
79
133
  @store.append_leaf_change(@path, @leaf_id)
80
134
  end
81
135
 
136
+ # Clears the active leaf so the next append starts a fresh root branch.
82
137
  def reset_leaf
83
138
  branch(nil)
84
139
  end
85
140
 
141
+ # Persists a display label override for one tree entry.
86
142
  def append_label_change(entry_id, label)
87
143
  @store.append_label_change(@path, entry_id, label)
88
144
  end
89
145
 
146
+ # Adds a branch-summary node under `parent_id` and selects it as the leaf.
90
147
  def append_branch_summary(parent_id, from_id:, summary:, details: {})
91
148
  record = @store.build_tree_record(@path, "branch_summary", parent_id, fromId: from_id, summary: summary, details: details || {})
92
149
  @leaf_id = record[:id]
@@ -94,29 +151,38 @@ module Kward
94
151
  record[:id]
95
152
  end
96
153
 
97
- def update_runtime(model:, reasoning_effort:)
154
+ # Persists model/runtime metadata so restored sessions keep their context.
155
+ def update_runtime(provider: nil, model:, reasoning_effort:)
98
156
  @store.append_record(@path, {
99
157
  type: "session_info",
100
158
  timestamp: Time.now.utc.iso8601(3),
101
159
  name: @name,
160
+ provider: provider.to_s,
102
161
  model: model.to_s,
103
162
  reasoningEffort: reasoning_effort.to_s
104
163
  }.delete_if { |_key, value| value.to_s.empty? })
105
164
  end
106
165
 
166
+ # Removes this session file when it is still empty and unnamed.
107
167
  def delete_if_unused
108
168
  @store.delete_unused_session(self)
109
169
  end
110
170
  end
111
171
 
172
+ # Creates an object for JSONL session persistence.
112
173
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
113
174
  @config_dir = config_dir
114
175
  @cwd = File.expand_path(cwd)
115
176
  end
116
177
 
178
+ # @return [String] workspace directory this store lists and creates sessions for
117
179
  attr_reader :cwd
118
180
 
119
- def create(model: nil, reasoning_effort: nil, parent_id: nil, parent_path: nil)
181
+ # Creates a new empty session file for the store's workspace directory.
182
+ #
183
+ # Parent fields record clone/fork ancestry; they do not imply live coupling
184
+ # between files after creation.
185
+ def create(provider: nil, model: nil, reasoning_effort: nil, parent_id: nil, parent_path: nil)
120
186
  dir = session_dir
121
187
  FileUtils.mkdir_p(dir, mode: 0o700)
122
188
  created_at = Time.now.utc
@@ -128,6 +194,7 @@ module Kward
128
194
  id: id,
129
195
  timestamp: created_at.iso8601(3),
130
196
  cwd: @cwd,
197
+ provider: provider.to_s,
131
198
  model: model.to_s,
132
199
  reasoningEffort: reasoning_effort.to_s,
133
200
  parentId: parent_id.to_s,
@@ -144,7 +211,7 @@ module Kward
144
211
  end
145
212
 
146
213
  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)
214
+ session = create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
148
215
  session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
149
216
  persisted_messages(conversation).each { |message| session.append_message(message) }
150
217
  session.attach(conversation)
@@ -155,22 +222,35 @@ module Kward
155
222
  create_independent_from_messages(
156
223
  persisted_messages(conversation),
157
224
  read_paths: Array(conversation.read_paths),
225
+ provider: conversation.provider,
158
226
  model: conversation.model,
159
227
  reasoning_effort: conversation.reasoning_effort,
160
228
  parent_session: parent_session
161
229
  )
162
230
  end
163
231
 
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)
232
+ # Creates a new session containing an independent copy of selected messages.
233
+ #
234
+ # Used by clone/fork flows where the new conversation must preserve selected
235
+ # history but then diverge without mutating the source session file.
236
+ #
237
+ # @param messages [Array<Hash>] messages to persist into the new session
238
+ # @param read_paths [Array<String>] restored read-before-write paths
239
+ # @param parent_session [Session, nil] optional source session metadata
240
+ # @return [Array(Session, Conversation)] new session handle and attached conversation
241
+ def create_independent_from_messages(messages, read_paths: [], provider: nil, model: nil, reasoning_effort: nil, parent_session: nil)
242
+ session = create(provider: provider, model: model, reasoning_effort: reasoning_effort, parent_id: parent_session&.id, parent_path: parent_session&.path)
166
243
  session.rename(parent_session.name) unless parent_session&.name.to_s.strip.empty?
167
244
  persisted = deep_copy(messages)
168
245
  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)
246
+ conversation = Conversation.new(messages: deep_copy(persisted), read_paths: read_paths, workspace_root: @cwd, provider: provider, model: model, reasoning_effort: reasoning_effort)
170
247
  session.attach(conversation)
171
248
  [session, conversation]
172
249
  end
173
250
 
251
+ # Resolves a user-provided path and returns the stored workspace location.
252
+ #
253
+ # @return [Hash] `:path` and `:cwd` for loading the session safely
174
254
  def session_location(path)
175
255
  resolved_path = resolve_session_path(path)
176
256
  records = records_from_file(resolved_path)
@@ -178,7 +258,12 @@ module Kward
178
258
  { path: resolved_path, cwd: header["cwd"].to_s.empty? ? @cwd : header["cwd"].to_s }
179
259
  end
180
260
 
181
- def load(path, workspace: Workspace.new, model: nil, reasoning_effort: nil)
261
+ # Loads a session file and reconstructs its current conversation leaf.
262
+ #
263
+ # `workspace` is used both for the active root and to restore read-before-write
264
+ # paths from successful read tool results. If a session moved workspaces, load
265
+ # it through `session_location` first so the original cwd is respected.
266
+ def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil)
182
267
  resolved_path = resolve_session_path(path)
183
268
  records = records_from_file(resolved_path)
184
269
  header = session_header(records, resolved_path)
@@ -194,6 +279,7 @@ module Kward
194
279
  messages: messages,
195
280
  read_paths: read_paths,
196
281
  workspace_root: workspace.root,
282
+ provider: runtime["provider"] || provider,
197
283
  model: runtime["model"] || model,
198
284
  reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
199
285
  session_memories: memory_state["sessionMemories"],
@@ -215,11 +301,17 @@ module Kward
215
301
  [session, conversation]
216
302
  end
217
303
 
304
+ # Lists recent non-empty sessions for this workspace.
305
+ #
306
+ # @param limit [Integer, nil] maximum number of sessions, or nil for all
307
+ # @param keep_empty_path [String, nil] empty session path to keep visible
308
+ # @return [Array<SessionInfo>] newest sessions first
218
309
  def recent(limit: 20, keep_empty_path: nil)
219
310
  sessions = recent_sessions(keep_empty_path: keep_empty_path)
220
311
  limit ? sessions.first(limit) : sessions
221
312
  end
222
313
 
314
+ # Persists the last active session pointer for workspace auto-resume.
223
315
  def remember_last_session(session)
224
316
  return unless session&.path
225
317
 
@@ -231,6 +323,7 @@ module Kward
231
323
  File.join(session_dir, LAST_SESSION_FILENAME)
232
324
  end
233
325
 
326
+ # @return [String, nil] remembered session path when the file still exists
234
327
  def remembered_last_session_path
235
328
  return nil unless File.file?(last_session_path)
236
329
 
@@ -242,12 +335,18 @@ module Kward
242
335
  nil
243
336
  end
244
337
 
338
+ # Lists recent sessions decorated with parent/branch display metadata.
339
+ #
340
+ # @return [Array<SessionInfo>] recent sessions with tree depth fields
245
341
  def recent_tree(limit: 20, keep_empty_path: nil)
246
342
  sessions = recent_sessions(keep_empty_path: keep_empty_path)
247
343
  sessions = sessions.first(limit) if limit
248
344
  decorate_tree(sessions)
249
345
  end
250
346
 
347
+ # Deletes an empty unnamed session file.
348
+ #
349
+ # @return [Boolean] true when a file was removed
251
350
  def delete_unused_session(session)
252
351
  path = session.path
253
352
  return false if session_named?(session)
@@ -264,6 +363,9 @@ module Kward
264
363
  end
265
364
 
266
365
 
366
+ # Builds a persisted tree record and assigns a stable entry id to messages.
367
+ #
368
+ # @return [Hash] JSONL-ready tree record
267
369
  def build_tree_record(path, type, parent_id, fields = {})
268
370
  message = fields[:message]
269
371
  id = message_entry_id(message) || next_entry_id(path)
@@ -294,11 +396,13 @@ module Kward
294
396
  })
295
397
  end
296
398
 
399
+ # @return [Array<Hash>] nested session tree roots for the given session file
297
400
  def session_tree(path)
298
401
  records = records_from_file(resolve_session_path(path))
299
402
  build_session_tree(records)
300
403
  end
301
404
 
405
+ # @return [Array<Hash>] flat tree records with resolved labels attached
302
406
  def session_entries(path)
303
407
  records = records_from_file(resolve_session_path(path))
304
408
  labels = labels_by_target(records)
@@ -312,10 +416,14 @@ module Kward
312
416
  end
313
417
  end
314
418
 
419
+ # Finds one persisted tree entry by id.
420
+ #
421
+ # @return [Hash, nil]
315
422
  def session_entry(path, entry_id)
316
423
  session_entries(path).find { |record| record["id"].to_s == entry_id.to_s }
317
424
  end
318
425
 
426
+ # @return [String, nil] current active tree leaf id
319
427
  def current_leaf(path)
320
428
  current_leaf_id(records_from_file(resolve_session_path(path)))
321
429
  end
@@ -327,12 +435,39 @@ module Kward
327
435
  end
328
436
  end
329
437
 
438
+ def append_system_prompt_snapshot(path, system_message, reason: "changed")
439
+ content = MessageAccess.content(system_message).to_s
440
+ return if content.empty?
441
+ return if latest_system_prompt_hash(records_from_file(path)) == system_prompt_hash(content)
442
+
443
+ append_record(path, {
444
+ type: "system_prompt",
445
+ timestamp: Time.now.utc.iso8601(3),
446
+ reason: reason.to_s,
447
+ hash: system_prompt_hash(content),
448
+ content: content
449
+ })
450
+ end
451
+
330
452
  def self.safe_cwd(cwd)
331
453
  "--#{File.expand_path(cwd).sub(%r{\A[/\\]}, "").gsub(%r{[/\\:]}, "-")}--"
332
454
  end
333
455
 
334
456
  private
335
457
 
458
+ def latest_system_prompt_hash(records)
459
+ records.reverse_each do |record|
460
+ next unless record["type"] == "system_prompt"
461
+
462
+ return record["hash"].to_s unless record["hash"].to_s.empty?
463
+ end
464
+ nil
465
+ end
466
+
467
+ def system_prompt_hash(content)
468
+ "sha256:#{Digest::SHA256.hexdigest(content.to_s)}"
469
+ end
470
+
336
471
  def resolve_session_path(path)
337
472
  expanded = path.to_s.start_with?("~/") ? File.join(Dir.home, path.to_s[2..]) : path.to_s
338
473
  resolved = File.expand_path(expanded, @cwd)
@@ -351,16 +486,10 @@ module Kward
351
486
  end
352
487
 
353
488
  def normalize_tree_records(records)
354
- parent_id = nil
355
- entry_index = 0
356
489
  records.each do |record|
357
- next unless tree_entry_record?(record)
490
+ next unless tree_entry_record?(record) && !record["id"].to_s.empty?
358
491
 
359
- record["id"] = "message:#{entry_index}" if record["id"].to_s.empty?
360
- record["parentId"] = parent_id unless record.key?("parentId")
361
492
  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
493
  end
365
494
  records
366
495
  end
@@ -414,12 +543,14 @@ module Kward
414
543
 
415
544
  def session_runtime(records, header)
416
545
  result = {
546
+ "provider" => header["provider"],
417
547
  "model" => header["model"],
418
548
  "reasoningEffort" => header["reasoningEffort"]
419
549
  }
420
550
  records.each do |record|
421
551
  next unless record["type"] == "session_info"
422
552
 
553
+ result["provider"] = record["provider"] if record.key?("provider")
423
554
  result["model"] = record["model"] if record.key?("model")
424
555
  result["reasoningEffort"] = record["reasoningEffort"] if record.key?("reasoningEffort")
425
556
  end
@@ -471,9 +602,7 @@ module Kward
471
602
  end
472
603
 
473
604
  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) }
605
+ entries = records.select { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
477
606
  by_id = entries.to_h { |record| [record["id"].to_s, record] }
478
607
  leaf_id = current_leaf_id(records)
479
608
  return [] if leaf_id.nil?
@@ -490,10 +619,6 @@ module Kward
490
619
  branch.reverse
491
620
  end
492
621
 
493
- def legacy_branch_records(records)
494
- records.select { |record| ["message", "compaction"].include?(record["type"]) }
495
- end
496
-
497
622
  def current_leaf_id(records)
498
623
  latest = records.reverse.find { |record| record["type"] == "leaf" || (tree_entry_record?(record) && !record["id"].to_s.empty?) }
499
624
  return nil unless latest
@@ -632,6 +757,7 @@ module Kward
632
757
 
633
758
  messages = restored_messages(records)
634
759
  name = session_name(records)
760
+ runtime = session_runtime(records, header)
635
761
  first_message = messages.find { |message| ["user", "compactionSummary"].include?(message_role(message)) }
636
762
  stats = File.stat(path)
637
763
 
@@ -644,6 +770,9 @@ module Kward
644
770
  name: name,
645
771
  first_message: first_message ? message_text(first_message) : "",
646
772
  message_count: messages.count { |message| ["user", "assistant", "tool", "toolResult", "compactionSummary"].include?(message_role(message)) },
773
+ provider: runtime["provider"],
774
+ model: runtime["model"],
775
+ reasoning_effort: runtime["reasoningEffort"],
647
776
  parent_id: header["parentId"],
648
777
  parent_path: header["parentPath"],
649
778
  depth: 0,
@@ -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,12 +7,13 @@ 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
13
- VERSION = "v1.0.0"
14
+ VERSION = "v1.0.1"
14
15
  ARCHIVE_URL = "https://codeload.github.com/kaiwood/kward-starter-pack/tar.gz/refs/tags/#{VERSION}".freeze
15
- ALLOWED_FILES = ["AGENTS.md"].freeze
16
+ ALLOWED_FILES = ["PRINCIPLES.md"].freeze
16
17
  ALLOWED_PREFIXES = ["prompts/", "skills/"].freeze
17
18
  Result = Struct.new(:installed, :skipped, keyword_init: true)
18
19
 
@@ -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