kward 0.67.1 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -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 +36 -9
- data/lib/kward/rpc/session_manager.rb +121 -345
- 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 +114 -24
- 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,11 +293,17 @@ module Kward
|
|
|
215
293
|
[session, conversation]
|
|
216
294
|
end
|
|
217
295
|
|
|
296
|
+
# Lists recent non-empty sessions for this workspace.
|
|
297
|
+
#
|
|
298
|
+
# @param limit [Integer, nil] maximum number of sessions, or nil for all
|
|
299
|
+
# @param keep_empty_path [String, nil] empty session path to keep visible
|
|
300
|
+
# @return [Array<SessionInfo>] newest sessions first
|
|
218
301
|
def recent(limit: 20, keep_empty_path: nil)
|
|
219
302
|
sessions = recent_sessions(keep_empty_path: keep_empty_path)
|
|
220
303
|
limit ? sessions.first(limit) : sessions
|
|
221
304
|
end
|
|
222
305
|
|
|
306
|
+
# Persists the last active session pointer for workspace auto-resume.
|
|
223
307
|
def remember_last_session(session)
|
|
224
308
|
return unless session&.path
|
|
225
309
|
|
|
@@ -231,6 +315,7 @@ module Kward
|
|
|
231
315
|
File.join(session_dir, LAST_SESSION_FILENAME)
|
|
232
316
|
end
|
|
233
317
|
|
|
318
|
+
# @return [String, nil] remembered session path when the file still exists
|
|
234
319
|
def remembered_last_session_path
|
|
235
320
|
return nil unless File.file?(last_session_path)
|
|
236
321
|
|
|
@@ -242,12 +327,18 @@ module Kward
|
|
|
242
327
|
nil
|
|
243
328
|
end
|
|
244
329
|
|
|
330
|
+
# Lists recent sessions decorated with parent/branch display metadata.
|
|
331
|
+
#
|
|
332
|
+
# @return [Array<SessionInfo>] recent sessions with tree depth fields
|
|
245
333
|
def recent_tree(limit: 20, keep_empty_path: nil)
|
|
246
334
|
sessions = recent_sessions(keep_empty_path: keep_empty_path)
|
|
247
335
|
sessions = sessions.first(limit) if limit
|
|
248
336
|
decorate_tree(sessions)
|
|
249
337
|
end
|
|
250
338
|
|
|
339
|
+
# Deletes an empty unnamed session file.
|
|
340
|
+
#
|
|
341
|
+
# @return [Boolean] true when a file was removed
|
|
251
342
|
def delete_unused_session(session)
|
|
252
343
|
path = session.path
|
|
253
344
|
return false if session_named?(session)
|
|
@@ -264,6 +355,9 @@ module Kward
|
|
|
264
355
|
end
|
|
265
356
|
|
|
266
357
|
|
|
358
|
+
# Builds a persisted tree record and assigns a stable entry id to messages.
|
|
359
|
+
#
|
|
360
|
+
# @return [Hash] JSONL-ready tree record
|
|
267
361
|
def build_tree_record(path, type, parent_id, fields = {})
|
|
268
362
|
message = fields[:message]
|
|
269
363
|
id = message_entry_id(message) || next_entry_id(path)
|
|
@@ -294,11 +388,13 @@ module Kward
|
|
|
294
388
|
})
|
|
295
389
|
end
|
|
296
390
|
|
|
391
|
+
# @return [Array<Hash>] nested session tree roots for the given session file
|
|
297
392
|
def session_tree(path)
|
|
298
393
|
records = records_from_file(resolve_session_path(path))
|
|
299
394
|
build_session_tree(records)
|
|
300
395
|
end
|
|
301
396
|
|
|
397
|
+
# @return [Array<Hash>] flat tree records with resolved labels attached
|
|
302
398
|
def session_entries(path)
|
|
303
399
|
records = records_from_file(resolve_session_path(path))
|
|
304
400
|
labels = labels_by_target(records)
|
|
@@ -312,10 +408,14 @@ module Kward
|
|
|
312
408
|
end
|
|
313
409
|
end
|
|
314
410
|
|
|
411
|
+
# Finds one persisted tree entry by id.
|
|
412
|
+
#
|
|
413
|
+
# @return [Hash, nil]
|
|
315
414
|
def session_entry(path, entry_id)
|
|
316
415
|
session_entries(path).find { |record| record["id"].to_s == entry_id.to_s }
|
|
317
416
|
end
|
|
318
417
|
|
|
418
|
+
# @return [String, nil] current active tree leaf id
|
|
319
419
|
def current_leaf(path)
|
|
320
420
|
current_leaf_id(records_from_file(resolve_session_path(path)))
|
|
321
421
|
end
|
|
@@ -351,16 +451,10 @@ module Kward
|
|
|
351
451
|
end
|
|
352
452
|
|
|
353
453
|
def normalize_tree_records(records)
|
|
354
|
-
parent_id = nil
|
|
355
|
-
entry_index = 0
|
|
356
454
|
records.each do |record|
|
|
357
|
-
next unless tree_entry_record?(record)
|
|
455
|
+
next unless tree_entry_record?(record) && !record["id"].to_s.empty?
|
|
358
456
|
|
|
359
|
-
record["id"] = "message:#{entry_index}" if record["id"].to_s.empty?
|
|
360
|
-
record["parentId"] = parent_id unless record.key?("parentId")
|
|
361
457
|
assign_message_entry_id(record["message"], record["id"]) if record["message"].is_a?(Hash) && message_entry_id(record["message"]).to_s.empty?
|
|
362
|
-
parent_id = record["id"]
|
|
363
|
-
entry_index += 1
|
|
364
458
|
end
|
|
365
459
|
records
|
|
366
460
|
end
|
|
@@ -414,12 +508,14 @@ module Kward
|
|
|
414
508
|
|
|
415
509
|
def session_runtime(records, header)
|
|
416
510
|
result = {
|
|
511
|
+
"provider" => header["provider"],
|
|
417
512
|
"model" => header["model"],
|
|
418
513
|
"reasoningEffort" => header["reasoningEffort"]
|
|
419
514
|
}
|
|
420
515
|
records.each do |record|
|
|
421
516
|
next unless record["type"] == "session_info"
|
|
422
517
|
|
|
518
|
+
result["provider"] = record["provider"] if record.key?("provider")
|
|
423
519
|
result["model"] = record["model"] if record.key?("model")
|
|
424
520
|
result["reasoningEffort"] = record["reasoningEffort"] if record.key?("reasoningEffort")
|
|
425
521
|
end
|
|
@@ -471,9 +567,7 @@ module Kward
|
|
|
471
567
|
end
|
|
472
568
|
|
|
473
569
|
def branch_records(records)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
entries = records.select { |record| tree_entry_record?(record) }
|
|
570
|
+
entries = records.select { |record| tree_entry_record?(record) && !record["id"].to_s.empty? }
|
|
477
571
|
by_id = entries.to_h { |record| [record["id"].to_s, record] }
|
|
478
572
|
leaf_id = current_leaf_id(records)
|
|
479
573
|
return [] if leaf_id.nil?
|
|
@@ -490,10 +584,6 @@ module Kward
|
|
|
490
584
|
branch.reverse
|
|
491
585
|
end
|
|
492
586
|
|
|
493
|
-
def legacy_branch_records(records)
|
|
494
|
-
records.select { |record| ["message", "compaction"].include?(record["type"]) }
|
|
495
|
-
end
|
|
496
|
-
|
|
497
587
|
def current_leaf_id(records)
|
|
498
588
|
latest = records.reverse.find { |record| record["type"] == "leaf" || (tree_entry_record?(record) && !record["id"].to_s.empty?) }
|
|
499
589
|
return nil unless latest
|
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
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
require_relative "../question_contract"
|
|
1
2
|
require_relative "base"
|
|
2
3
|
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
5
|
module Kward
|
|
6
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
4
7
|
module Tools
|
|
8
|
+
# Tool wrapper for structured clarification questions to the user.
|
|
5
9
|
class AskUserQuestion < Base
|
|
10
|
+
# Builds the tool schema and stores the execution dependency.
|
|
6
11
|
def initialize(prompt:)
|
|
7
12
|
@prompt = prompt
|
|
8
13
|
super(
|
|
@@ -42,6 +47,7 @@ module Kward
|
|
|
42
47
|
)
|
|
43
48
|
end
|
|
44
49
|
|
|
50
|
+
# Executes the tool and returns model-facing output text.
|
|
45
51
|
def call(args, _conversation, cancellation: nil)
|
|
46
52
|
return "Error: ask_user_question requires interactive prompt support." unless @prompt.respond_to?(:ask_user_question)
|
|
47
53
|
|
|
@@ -67,40 +73,22 @@ module Kward
|
|
|
67
73
|
end
|
|
68
74
|
|
|
69
75
|
def validated_questions(args)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
questions.map.with_index(1) do |question, index|
|
|
75
|
-
return "Error: question #{index} must be an object." unless question.respond_to?(:key?)
|
|
76
|
-
return "Error: question #{index} uses unsupported multiSelect." if question.key?("multiSelect") || question.key?(:multiSelect)
|
|
77
|
-
|
|
78
|
-
text = question_value(question, :question).to_s.strip
|
|
79
|
-
header = question_value(question, :header).to_s.strip
|
|
80
|
-
options = question_value(question, :options)
|
|
81
|
-
return "Error: question #{index} requires question and header." if text.empty? || header.empty?
|
|
82
|
-
return "Error: question #{index} requires 2 to 4 options." unless options.is_a?(Array) && options.length.between?(2, 4)
|
|
83
|
-
|
|
84
|
-
normalized_options = options.map.with_index(1) do |option, option_index|
|
|
85
|
-
return "Error: question #{index} option #{option_index} must be an object." unless option.respond_to?(:key?)
|
|
86
|
-
return "Error: question #{index} option #{option_index} uses unsupported preview." if option.key?("preview") || option.key?(:preview)
|
|
87
|
-
|
|
88
|
-
label = question_value(option, :label).to_s.strip
|
|
89
|
-
description = question_value(option, :description).to_s.strip
|
|
90
|
-
return "Error: question #{index} option #{option_index} requires label and description." if label.empty? || description.empty?
|
|
91
|
-
|
|
92
|
-
{ label: label, description: description }
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
{ question: text, header: header, options: normalized_options }
|
|
96
|
-
end
|
|
76
|
+
QuestionContract.normalize_questions(argument(args, :questions))
|
|
77
|
+
rescue ArgumentError => e
|
|
78
|
+
"Error: #{tool_error_message(e.message)}."
|
|
97
79
|
end
|
|
98
80
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
81
|
+
def tool_error_message(message)
|
|
82
|
+
case message
|
|
83
|
+
when "questions must be an array"
|
|
84
|
+
"ask_user_question requires questions"
|
|
85
|
+
when "ui/question requires 1-4 questions"
|
|
86
|
+
"ask_user_question requires 1 to 4 questions"
|
|
87
|
+
else
|
|
88
|
+
message.gsub("2-4", "2 to 4")
|
|
89
|
+
.gsub("multiSelect is unsupported", "uses unsupported multiSelect")
|
|
90
|
+
.gsub("preview is unsupported", "uses unsupported preview")
|
|
91
|
+
end
|
|
104
92
|
end
|
|
105
93
|
end
|
|
106
94
|
end
|