kward 0.66.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "private_file"
|
|
5
|
+
require_relative "prompts/templates"
|
|
6
|
+
require_relative "skills/registry"
|
|
7
|
+
|
|
8
|
+
module Kward
|
|
9
|
+
# Resolves Kward configuration, cache, memory, prompt, skill, and plugin
|
|
10
|
+
# paths, and reads/writes the JSON config file used by the CLI and RPC server.
|
|
11
|
+
module ConfigFiles
|
|
12
|
+
MAX_SKILL_FILE_BYTES = 100_000
|
|
13
|
+
MAX_PROMPT_FILE_BYTES = 32 * 1024
|
|
14
|
+
DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
|
|
15
|
+
DEFAULT_PERSONAS = {
|
|
16
|
+
"characters" => [
|
|
17
|
+
{
|
|
18
|
+
"key" => "kward",
|
|
19
|
+
"label" => "Kward",
|
|
20
|
+
"instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forrest of Code, protecting the nature of good engineering priciples. Speak like an old druid, be suspicous of everyone, but with a good intend."
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"default" => "kward"
|
|
24
|
+
}.freeze
|
|
25
|
+
OVERLAY_ALIGNMENTS = %w[left center right].freeze
|
|
26
|
+
OVERLAY_WIDTHS = %w[capped maximum].freeze
|
|
27
|
+
|
|
28
|
+
Skill = Struct.new(:name, :description, :folder, :path, keyword_init: true)
|
|
29
|
+
PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
|
|
30
|
+
def expand(arguments)
|
|
31
|
+
body.gsub("$ARGUMENTS", arguments.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Directory that contains Kward's user config and adjacent prompt/skill
|
|
38
|
+
# data. Defaults to `~/.kward`, or the directory of `KWARD_CONFIG_PATH`.
|
|
39
|
+
#
|
|
40
|
+
# @return [String] expanded config directory path
|
|
41
|
+
def config_dir
|
|
42
|
+
config_path = ENV["KWARD_CONFIG_PATH"]
|
|
43
|
+
return File.expand_path(File.dirname(config_path)) if config_path && !config_path.empty?
|
|
44
|
+
|
|
45
|
+
File.expand_path("~/.kward")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] expanded JSON config file path
|
|
49
|
+
def config_path
|
|
50
|
+
File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cache_dir
|
|
54
|
+
File.join(config_dir, "cache")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def default_config
|
|
58
|
+
{
|
|
59
|
+
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
60
|
+
"memory" => {
|
|
61
|
+
"enabled" => false,
|
|
62
|
+
"auto_summary" => false
|
|
63
|
+
},
|
|
64
|
+
"composer" => {
|
|
65
|
+
"busy_help" => true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ensure_default_config!(path = config_path)
|
|
71
|
+
path = File.expand_path(path)
|
|
72
|
+
return false if File.exist?(path)
|
|
73
|
+
|
|
74
|
+
write_config(default_config, path)
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def code_search_cache_dir
|
|
79
|
+
File.join(cache_dir, "code_search")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] directory containing structured memory files
|
|
83
|
+
def memory_dir
|
|
84
|
+
File.join(config_dir, "memory")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def memory_core_path
|
|
88
|
+
File.join(memory_dir, "core.json")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def memory_soft_path
|
|
92
|
+
File.join(memory_dir, "soft.jsonl")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def memory_events_path
|
|
96
|
+
File.join(memory_dir, "events.jsonl")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Reads the JSON config file.
|
|
100
|
+
#
|
|
101
|
+
# Missing files are treated as an empty config. Invalid JSON raises a
|
|
102
|
+
# user-facing error that includes the file path.
|
|
103
|
+
#
|
|
104
|
+
# @param path [String] config file path
|
|
105
|
+
# @return [Hash] parsed config object
|
|
106
|
+
def read_config(path = config_path)
|
|
107
|
+
path = File.expand_path(path)
|
|
108
|
+
return {} unless File.exist?(path)
|
|
109
|
+
|
|
110
|
+
JSON.parse(File.read(path))
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
raise "Invalid Kward config JSON: #{path}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Writes config JSON using private file permissions.
|
|
116
|
+
#
|
|
117
|
+
# @param config [Hash] config object to persist
|
|
118
|
+
# @param path [String] config file path
|
|
119
|
+
def write_config(config, path = config_path)
|
|
120
|
+
PrivateFile.write_json(path, config)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def update_config(values, path = config_path)
|
|
124
|
+
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
config = read_config(path)
|
|
127
|
+
values.each { |key, value| config[key.to_s] = value }
|
|
128
|
+
write_config(config, path)
|
|
129
|
+
config
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def delete_config_key(key, path = config_path)
|
|
133
|
+
config = read_config(path)
|
|
134
|
+
existed = config.key?(key.to_s)
|
|
135
|
+
config.delete(key.to_s)
|
|
136
|
+
write_config(config, path) if existed
|
|
137
|
+
existed
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def config_value(config, *keys)
|
|
141
|
+
keys.each do |key|
|
|
142
|
+
text = presence(config[key])
|
|
143
|
+
return text if text
|
|
144
|
+
end
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns validated overlay settings with defaults for missing or invalid
|
|
149
|
+
# values.
|
|
150
|
+
#
|
|
151
|
+
# @param config [Hash] parsed config object
|
|
152
|
+
# @return [Hash] overlay settings with `alignment` and `width`
|
|
153
|
+
def overlay_settings(config = read_config)
|
|
154
|
+
overlay = config["overlay"].is_a?(Hash) ? config["overlay"] : {}
|
|
155
|
+
settings = DEFAULT_OVERLAY_SETTINGS.dup
|
|
156
|
+
alignment = overlay["alignment"].to_s
|
|
157
|
+
width = overlay["width"].to_s
|
|
158
|
+
settings["alignment"] = alignment if OVERLAY_ALIGNMENTS.include?(alignment)
|
|
159
|
+
settings["width"] = width if OVERLAY_WIDTHS.include?(width)
|
|
160
|
+
settings
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def composer_busy_help?(config = read_config)
|
|
164
|
+
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
165
|
+
composer["busy_help"] != false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def update_overlay_settings(values)
|
|
169
|
+
raise "Overlay settings must be an object" unless values.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
config = read_config
|
|
172
|
+
overlay = config["overlay"].is_a?(Hash) ? config["overlay"].dup : {}
|
|
173
|
+
values.each do |key, value|
|
|
174
|
+
key = key.to_s
|
|
175
|
+
value = value.to_s
|
|
176
|
+
case key
|
|
177
|
+
when "alignment"
|
|
178
|
+
raise "Overlay alignment must be left, center, or right" unless OVERLAY_ALIGNMENTS.include?(value)
|
|
179
|
+
when "width"
|
|
180
|
+
raise "Overlay width must be capped or maximum" unless OVERLAY_WIDTHS.include?(value)
|
|
181
|
+
else
|
|
182
|
+
raise "Unknown overlay setting: #{key}"
|
|
183
|
+
end
|
|
184
|
+
overlay[key] = value
|
|
185
|
+
end
|
|
186
|
+
config["overlay"] = overlay
|
|
187
|
+
write_config(config)
|
|
188
|
+
overlay_settings(config)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Reads global agent instructions from the config directory.
|
|
192
|
+
#
|
|
193
|
+
# @return [String, nil] prompt text, or nil when absent/too large
|
|
194
|
+
def agents_prompt
|
|
195
|
+
path = File.join(config_dir, "AGENTS.md")
|
|
196
|
+
read_prompt_file(path, "Kward prompt file")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Builds persona prompt text from default, workspace, model, reasoning,
|
|
200
|
+
# time-of-day, weekday, and suffix config entries.
|
|
201
|
+
#
|
|
202
|
+
# @param workspace_root [String] active workspace root
|
|
203
|
+
# @param model [String, nil] active model name
|
|
204
|
+
# @param reasoning_effort [String, nil] active reasoning effort
|
|
205
|
+
# @param now [Time] local time used for time-based modifiers
|
|
206
|
+
# @param config [Hash] parsed config object
|
|
207
|
+
# @return [String, nil] persona prompt text when entries match
|
|
208
|
+
def persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config)
|
|
209
|
+
text = persona_entries(workspace_root: workspace_root, model: model, reasoning_effort: reasoning_effort, now: now, config: config).map do |entry|
|
|
210
|
+
entry[:prompt]
|
|
211
|
+
end.join("\n\n")
|
|
212
|
+
return nil if text.empty?
|
|
213
|
+
|
|
214
|
+
text
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def active_persona_label(workspace_root:, model: nil, config: read_config)
|
|
218
|
+
personas = config["personas"]
|
|
219
|
+
return nil unless personas.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
labels = crew_character_labels(personas)
|
|
222
|
+
active_label = persona_label_for_key(personas["default"], labels) unless personas["default"].nil?
|
|
223
|
+
|
|
224
|
+
workspaces = personas["workspaces"]
|
|
225
|
+
if workspaces.is_a?(Hash)
|
|
226
|
+
root = canonical_workspace_root(workspace_root)
|
|
227
|
+
workspaces.each do |path, key|
|
|
228
|
+
next unless canonical_workspace_root(path) == root
|
|
229
|
+
|
|
230
|
+
active_label = persona_label_for_key(key, labels)
|
|
231
|
+
break
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
models = personas["models"]
|
|
236
|
+
if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
|
|
237
|
+
active_label = persona_label_for_key(models[model.to_s], labels)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
active_label
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true)
|
|
244
|
+
personas = config["personas"]
|
|
245
|
+
return [] unless personas.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
characters = crew_characters(personas)
|
|
248
|
+
entries = []
|
|
249
|
+
|
|
250
|
+
add_persona_entry(entries, "default", resolved_persona_text(personas["default"], characters: characters))
|
|
251
|
+
|
|
252
|
+
workspaces = personas["workspaces"]
|
|
253
|
+
if workspaces.is_a?(Hash)
|
|
254
|
+
root = canonical_workspace_root(workspace_root)
|
|
255
|
+
workspaces.each do |path, key|
|
|
256
|
+
if canonical_workspace_root(path) == root
|
|
257
|
+
add_persona_entry(entries, "workspace", resolved_persona_text(key, characters: characters), name: path)
|
|
258
|
+
break
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
models = personas["models"]
|
|
264
|
+
add_persona_entry(entries, "model", resolved_persona_text(models[model.to_s], characters: characters), name: model.to_s) if models.is_a?(Hash) && !model.to_s.empty?
|
|
265
|
+
|
|
266
|
+
modifiers = personas["persona_modifiers"]
|
|
267
|
+
if modifiers.is_a?(Hash)
|
|
268
|
+
if include_reasoning
|
|
269
|
+
reasoning = modifiers["reasoning"]
|
|
270
|
+
add_persona_entry(entries, "reasoning", reasoning[reasoning_effort.to_s]) if reasoning.is_a?(Hash) && !reasoning_effort.to_s.empty?
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
time_of_day = modifiers["time_of_day"]
|
|
274
|
+
bucket = time_of_day_bucket(now)
|
|
275
|
+
add_persona_entry(entries, "time_of_day", time_of_day[bucket], name: bucket) if time_of_day.is_a?(Hash)
|
|
276
|
+
|
|
277
|
+
weekday = modifiers["weekday"]
|
|
278
|
+
day = weekday_name(now)
|
|
279
|
+
add_persona_entry(entries, "weekday", weekday[day], name: day) if weekday.is_a?(Hash)
|
|
280
|
+
|
|
281
|
+
add_persona_entry(entries, "suffix", modifiers["suffix"])
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
entries
|
|
285
|
+
end
|
|
286
|
+
def workspace_agents_prompt(workspace_root)
|
|
287
|
+
root = canonical_workspace_root(workspace_root)
|
|
288
|
+
path = File.join(root, "AGENTS.md")
|
|
289
|
+
read_prompt_file(path, "workspace AGENTS.md")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def read_prompt_file(path, label)
|
|
293
|
+
return nil unless File.exist?(path)
|
|
294
|
+
|
|
295
|
+
size = File.size(path)
|
|
296
|
+
if size > MAX_PROMPT_FILE_BYTES
|
|
297
|
+
warn "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
|
|
298
|
+
return nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
File.read(path)
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
warn "Warning: skipping #{label} #{path}: #{e.message}"
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def workspace_config(workspace_root, config = read_config)
|
|
308
|
+
workspaces = config["workspaces"]
|
|
309
|
+
return nil unless workspaces.is_a?(Hash)
|
|
310
|
+
|
|
311
|
+
root = canonical_workspace_root(workspace_root)
|
|
312
|
+
workspaces.each do |path, entry|
|
|
313
|
+
return entry if canonical_workspace_root(path) == root
|
|
314
|
+
end
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def canonical_workspace_root(path)
|
|
319
|
+
expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
|
|
320
|
+
File.directory?(expanded) ? File.realpath(expanded) : expanded
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def add_persona_entry(entries, layer, value, name: nil)
|
|
324
|
+
text = presence(value)
|
|
325
|
+
return unless text
|
|
326
|
+
|
|
327
|
+
entries << { layer: layer.to_s, name: name.to_s, prompt: text }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def crew_characters(personas)
|
|
331
|
+
raw = personas["characters"] || personas["crew"]
|
|
332
|
+
return {} unless raw
|
|
333
|
+
|
|
334
|
+
if raw.is_a?(Hash)
|
|
335
|
+
parse_named_characters(raw)
|
|
336
|
+
elsif raw.is_a?(Array)
|
|
337
|
+
parse_named_characters_array(raw)
|
|
338
|
+
else
|
|
339
|
+
{}
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def crew_character_labels(personas)
|
|
344
|
+
raw = personas["characters"] || personas["crew"]
|
|
345
|
+
return {} unless raw
|
|
346
|
+
|
|
347
|
+
if raw.is_a?(Hash)
|
|
348
|
+
parse_named_character_labels(raw)
|
|
349
|
+
elsif raw.is_a?(Array)
|
|
350
|
+
parse_named_character_labels_array(raw)
|
|
351
|
+
else
|
|
352
|
+
{}
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def resolved_persona_text(value, characters: {})
|
|
357
|
+
return nil if value.nil?
|
|
358
|
+
|
|
359
|
+
key = value.to_s.strip
|
|
360
|
+
return nil if key.empty?
|
|
361
|
+
|
|
362
|
+
text = characters[key.to_s]
|
|
363
|
+
return text unless text.to_s.empty?
|
|
364
|
+
|
|
365
|
+
value
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def persona_label_for_key(value, labels)
|
|
369
|
+
key = value.to_s.strip
|
|
370
|
+
return nil if key.empty?
|
|
371
|
+
|
|
372
|
+
presence(labels[key])
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def parse_named_characters(raw)
|
|
376
|
+
raw.each_with_object({}) do |(key, definition), mapping|
|
|
377
|
+
instruction = extract_character_instruction(definition)
|
|
378
|
+
next if instruction.nil?
|
|
379
|
+
|
|
380
|
+
mapping[key.to_s] = instruction
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def parse_named_characters_array(raw)
|
|
385
|
+
raw.each_with_object({}) do |entry, mapping|
|
|
386
|
+
char_key = nil
|
|
387
|
+
definition = nil
|
|
388
|
+
|
|
389
|
+
if entry.is_a?(Hash) && entry.length == 1 && entry.keys.first.is_a?(String)
|
|
390
|
+
char_key = entry.keys.first
|
|
391
|
+
definition = entry.values.first
|
|
392
|
+
elsif entry.is_a?(Hash)
|
|
393
|
+
char_key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
|
|
394
|
+
definition = entry
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
next if char_key.to_s.empty?
|
|
398
|
+
|
|
399
|
+
instruction = extract_character_instruction(definition)
|
|
400
|
+
next if instruction.to_s.empty?
|
|
401
|
+
|
|
402
|
+
mapping[char_key.to_s] = instruction
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def parse_named_character_labels(raw)
|
|
407
|
+
raw.each_with_object({}) do |(key, definition), mapping|
|
|
408
|
+
label = extract_character_label(definition)
|
|
409
|
+
next if label.nil?
|
|
410
|
+
|
|
411
|
+
mapping[key.to_s] = label
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def parse_named_character_labels_array(raw)
|
|
416
|
+
raw.each_with_object({}) do |entry, mapping|
|
|
417
|
+
char_key = nil
|
|
418
|
+
definition = nil
|
|
419
|
+
|
|
420
|
+
if entry.is_a?(Hash) && entry.length == 1 && entry.keys.first.is_a?(String)
|
|
421
|
+
char_key = entry.keys.first
|
|
422
|
+
definition = entry.values.first
|
|
423
|
+
elsif entry.is_a?(Hash)
|
|
424
|
+
char_key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
|
|
425
|
+
definition = entry
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
next if char_key.to_s.empty?
|
|
429
|
+
|
|
430
|
+
label = extract_character_label(definition)
|
|
431
|
+
next if label.to_s.empty?
|
|
432
|
+
|
|
433
|
+
mapping[char_key.to_s] = label
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def extract_character_label(definition)
|
|
438
|
+
return nil unless definition.is_a?(Hash)
|
|
439
|
+
|
|
440
|
+
presence(definition["label"] || definition[:label])
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def extract_character_instruction(definition)
|
|
444
|
+
return nil if definition.nil?
|
|
445
|
+
|
|
446
|
+
if definition.is_a?(Hash)
|
|
447
|
+
value = definition["instruction"] || definition[:instruction]
|
|
448
|
+
return presence(value)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
presence(definition)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def time_of_day_bucket(now)
|
|
455
|
+
hour = now.hour
|
|
456
|
+
return "morning" if hour >= 5 && hour < 11
|
|
457
|
+
return "before_lunch" if hour == 11
|
|
458
|
+
return "late_evening" if hour >= 21 || hour < 5
|
|
459
|
+
|
|
460
|
+
nil
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def weekday_name(now)
|
|
464
|
+
%w[sunday monday tuesday wednesday thursday friday saturday][now.wday]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Lists configured skills discovered under the config directory.
|
|
468
|
+
#
|
|
469
|
+
# @return [Array<Skill>] skill metadata available to the model
|
|
470
|
+
def skills
|
|
471
|
+
skills_registry.skills
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# @return [String] trusted user plugin directory
|
|
475
|
+
def plugin_dir
|
|
476
|
+
File.expand_path("~/.kward/plugins")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Finds trusted top-level plugin files.
|
|
480
|
+
#
|
|
481
|
+
# Plugins are intentionally loaded only from `~/.kward/plugins`, not from a
|
|
482
|
+
# workspace or custom `KWARD_CONFIG_PATH` directory.
|
|
483
|
+
#
|
|
484
|
+
# @return [Array<String>] sorted plugin file paths
|
|
485
|
+
def plugin_paths
|
|
486
|
+
plugins_root = plugin_dir
|
|
487
|
+
warn_legacy_plugin_dir(plugins_root)
|
|
488
|
+
return [] unless Dir.exist?(plugins_root)
|
|
489
|
+
|
|
490
|
+
Dir.glob(File.join(plugins_root, "*.rb")).sort
|
|
491
|
+
rescue StandardError => e
|
|
492
|
+
warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
|
|
493
|
+
[]
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def warn_legacy_plugin_dir(plugins_root)
|
|
497
|
+
config_path = ENV["KWARD_CONFIG_PATH"]
|
|
498
|
+
return if config_path.to_s.empty?
|
|
499
|
+
|
|
500
|
+
legacy_root = File.expand_path(File.join(File.dirname(config_path), "plugins"))
|
|
501
|
+
return if legacy_root == File.expand_path(plugins_root)
|
|
502
|
+
return unless Dir.exist?(legacy_root)
|
|
503
|
+
|
|
504
|
+
warn "Warning: ignoring Kward plugins in #{legacy_root}; plugins are only loaded from #{File.expand_path(plugins_root)}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Lists prompt templates exposed as slash commands.
|
|
508
|
+
#
|
|
509
|
+
# @param reserved_commands [Array<String>] command names unavailable to templates
|
|
510
|
+
# @return [Array<PromptTemplate>] prompt template metadata and bodies
|
|
511
|
+
def prompt_templates(reserved_commands: [])
|
|
512
|
+
prompt_template_registry.prompt_templates(reserved_commands: reserved_commands)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Reads a skill file by skill name and optional relative path.
|
|
516
|
+
#
|
|
517
|
+
# @param name [String] configured skill name
|
|
518
|
+
# @param relative_path [String, nil] path inside the skill directory
|
|
519
|
+
# @return [String] file contents or an error string
|
|
520
|
+
def read_skill_file(name, relative_path = nil)
|
|
521
|
+
skills_registry.read_skill_file(name, relative_path)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def skills_registry
|
|
525
|
+
Skills::Registry.new(
|
|
526
|
+
config_dir: config_dir,
|
|
527
|
+
skill_class: Skill,
|
|
528
|
+
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
529
|
+
markdown_parser: method(:markdown_parts),
|
|
530
|
+
inside_directory: method(:inside_directory?)
|
|
531
|
+
)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def prompt_template_registry
|
|
535
|
+
Prompts::Templates.new(
|
|
536
|
+
config_dir: config_dir,
|
|
537
|
+
template_class: PromptTemplate,
|
|
538
|
+
markdown_parser: method(:markdown_parts)
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def markdown_parts(path)
|
|
543
|
+
content = File.read(path)
|
|
544
|
+
return [{}, content] unless content.start_with?("---\n", "---\r\n")
|
|
545
|
+
|
|
546
|
+
_opening, rest = content.split(/\A---\r?\n/, 2)
|
|
547
|
+
yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
|
|
548
|
+
raise "missing frontmatter closing delimiter" if body.nil?
|
|
549
|
+
|
|
550
|
+
data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
|
|
551
|
+
frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
|
|
552
|
+
[frontmatter, body]
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def inside_directory?(path, base)
|
|
556
|
+
path == base || path.start_with?(base + File::SEPARATOR)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def presence(value)
|
|
560
|
+
text = value.to_s
|
|
561
|
+
text.empty? ? nil : text
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
require_relative "image_attachments"
|
|
3
|
+
require_relative "message_access"
|
|
4
|
+
require_relative "plugin_registry"
|
|
5
|
+
require_relative "prompts"
|
|
6
|
+
|
|
7
|
+
module Kward
|
|
8
|
+
class Conversation
|
|
9
|
+
DEFAULT_SYSTEM_MESSAGE = Object.new.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :messages, :read_paths, :workspace_root, :compaction_system_message, :model, :reasoning_effort, :session_memories
|
|
12
|
+
attr_accessor :on_append, :on_compact, :on_tool_execution, :memory_context, :last_memory_retrieval, :plugin_registry
|
|
13
|
+
|
|
14
|
+
def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
|
|
15
|
+
@workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
|
|
16
|
+
@model = model
|
|
17
|
+
@reasoning_effort = reasoning_effort
|
|
18
|
+
@plugin_registry = plugin_registry
|
|
19
|
+
@messages = []
|
|
20
|
+
if system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
21
|
+
system_message = messages.any? { |message| MessageAccess.role(message) == "system" } ? nil : Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: memory_context, plugin_context: plugin_prompt_context)
|
|
22
|
+
end
|
|
23
|
+
@system_message_enabled = !!(system_message || messages.find { |message| MessageAccess.role(message) == "system" })
|
|
24
|
+
if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
|
|
25
|
+
compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort) : nil
|
|
26
|
+
end
|
|
27
|
+
@compaction_system_message = compaction_system_message
|
|
28
|
+
@workspace_agents_mtime = workspace_agents_mtime
|
|
29
|
+
@last_entry_compaction = false
|
|
30
|
+
@memory_context = memory_context
|
|
31
|
+
@session_memories = Array(session_memories)
|
|
32
|
+
@last_memory_retrieval = last_memory_retrieval
|
|
33
|
+
@messages << system_message unless system_message.nil?
|
|
34
|
+
@messages.concat(messages)
|
|
35
|
+
@read_paths = Set.new(read_paths)
|
|
36
|
+
@on_append = on_append
|
|
37
|
+
@on_compact = on_compact
|
|
38
|
+
@on_tool_execution = on_tool_execution
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def append_user(content, display_content: nil)
|
|
42
|
+
content = ImageAttachments.content_from_text(content) unless content.is_a?(Array)
|
|
43
|
+
message = { role: "user", content: content }
|
|
44
|
+
message[:display_content] = display_content.to_s unless display_content.nil?
|
|
45
|
+
append_message(message)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def append_assistant(message)
|
|
49
|
+
message = { role: "assistant", content: message } if message.is_a?(String)
|
|
50
|
+
append_message(message)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def append_tool(tool_call_id:, name:, content:)
|
|
54
|
+
append_message({
|
|
55
|
+
role: "tool",
|
|
56
|
+
tool_call_id: tool_call_id,
|
|
57
|
+
name: name,
|
|
58
|
+
content: content
|
|
59
|
+
})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def append_tool_execution(tool_call:, content:)
|
|
63
|
+
@on_tool_execution&.call(tool_call, content)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def refresh_system_message!
|
|
67
|
+
return nil unless @system_message_enabled
|
|
68
|
+
|
|
69
|
+
replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: plugin_prompt_context)
|
|
70
|
+
index = @messages.index { |message| MessageAccess.role(message) == "system" }
|
|
71
|
+
index ? @messages[index] = replacement : @messages.unshift(replacement)
|
|
72
|
+
@compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort)
|
|
73
|
+
@workspace_agents_mtime = workspace_agents_mtime
|
|
74
|
+
replacement
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def update_runtime_context!(model:, reasoning_effort:)
|
|
78
|
+
@model = model
|
|
79
|
+
@reasoning_effort = reasoning_effort
|
|
80
|
+
refresh_system_message!
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def refresh_system_message_if_workspace_agents_changed!
|
|
84
|
+
refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def mark_read(path)
|
|
88
|
+
@read_paths << path
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def plugin_prompt_context
|
|
92
|
+
return nil unless plugin_registry
|
|
93
|
+
|
|
94
|
+
context = PluginRegistry::Context.new(conversation: self, workspace_root: @workspace_root)
|
|
95
|
+
plugin_registry.prompt_context(context)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: [])
|
|
99
|
+
message = if compaction_summary
|
|
100
|
+
{ role: "compactionSummary", summary: summary.to_s }
|
|
101
|
+
else
|
|
102
|
+
{ role: "assistant", content: summary.to_s }
|
|
103
|
+
end
|
|
104
|
+
if compaction_summary
|
|
105
|
+
message[:first_kept_entry_id] = first_kept_entry_id if first_kept_entry_id
|
|
106
|
+
message[:tokens_before] = tokens_before if tokens_before
|
|
107
|
+
message[:from_hook] = from_hook
|
|
108
|
+
message[:details] = details || {}
|
|
109
|
+
end
|
|
110
|
+
@messages = @messages.select { |item| MessageAccess.role(item) == "system" }
|
|
111
|
+
@messages << message
|
|
112
|
+
@messages.concat(Array(keep_messages))
|
|
113
|
+
@read_paths.clear
|
|
114
|
+
@last_entry_compaction = true
|
|
115
|
+
@on_compact&.call(message)
|
|
116
|
+
message
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def last_entry_compaction?
|
|
120
|
+
@last_entry_compaction
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def mark_last_entry_compaction!
|
|
124
|
+
@last_entry_compaction = true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def last_file_change_result
|
|
128
|
+
@messages.select do |message|
|
|
129
|
+
MessageAccess.role(message) == "tool" && ["write_file", "edit_file"].include?(MessageAccess.name(message))
|
|
130
|
+
end.last
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def workspace_agents_mtime
|
|
136
|
+
path = File.join(@workspace_root, "AGENTS.md")
|
|
137
|
+
File.exist?(path) ? File.mtime(path) : nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def append_message(message)
|
|
141
|
+
@messages << message
|
|
142
|
+
@last_entry_compaction = false
|
|
143
|
+
@on_append&.call(message)
|
|
144
|
+
message
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
end
|
|
148
|
+
end
|