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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +54 -0
- data/Gemfile.lock +8 -2
- data/README.md +37 -30
- data/Rakefile +14 -1
- data/doc/authentication.md +84 -43
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +27 -2
- data/doc/extensibility.md +90 -129
- data/doc/getting-started.md +53 -57
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -99
- data/doc/releasing.md +10 -9
- data/doc/rpc.md +7 -7
- data/doc/usage.md +125 -141
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +30 -3
- 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 +229 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +227 -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 +134 -0
- data/lib/kward/cli/rendering.rb +378 -0
- data/lib/kward/cli/runtime_helpers.rb +170 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +669 -0
- data/lib/kward/cli/slash_commands.rb +114 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +157 -0
- data/lib/kward/cli.rb +52 -2792
- data/lib/kward/cli_transcript_formatter.rb +40 -12
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +31 -9
- data/lib/kward/config_files.rb +78 -34
- data/lib/kward/conversation.rb +110 -13
- 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 +144 -14
- data/lib/kward/message_access.rb +29 -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 +14 -10
- data/lib/kward/model/model_info.rb +160 -4
- data/lib/kward/model/payloads.rb +254 -22
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +387 -25
- 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 +151 -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 +63 -7
- 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 +43 -11
- data/lib/kward/rpc/session_manager.rb +139 -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 +50 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +154 -25
- 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 +3 -2
- 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/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -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 +42 -4
- 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 +20 -17
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +27 -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
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +56 -1
data/lib/kward/session_store.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
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
|
|
@@ -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.
|
|
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 = ["
|
|
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
|
|
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
|