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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
data/lib/kward/session_store.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
data/lib/kward/session_trash.rb
CHANGED
|
@@ -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
|
|
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)
|
|
186
|
+
message_tool_call_id(message)
|
|
184
187
|
end
|
|
185
188
|
|
|
186
189
|
def session_tree_message_tool_name(message)
|
|
187
|
-
message_name(message)
|
|
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
|
-
|
|
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
|
data/lib/kward/steering.rb
CHANGED
|
@@ -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
|