Module: Kward::ConfigFiles
- Defined in:
- lib/kward/config_files.rb
Overview
Resolves Kward configuration, cache, memory, prompt, skill, and plugin paths, and reads/writes the JSON config file used by the CLI and RPC server.
Defined Under Namespace
Classes: PromptTemplate, Skill
Constant Summary collapse
- MAX_SKILL_FILE_BYTES =
100_000- MAX_PROMPT_FILE_BYTES =
32 * 1024
- DEFAULT_OVERLAY_SETTINGS =
{ "alignment" => "center", "width" => "maximum" }.freeze
- DEFAULT_PERSONAS =
{ "characters" => [ { "key" => "kward", "label" => "Kward", "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." } ], "default" => "kward" }.freeze
- OVERLAY_ALIGNMENTS =
%w[left center right].freeze
- OVERLAY_WIDTHS =
%w[capped maximum].freeze
Class Method Summary collapse
- .active_persona_label(workspace_root:, model: nil, config: read_config) ⇒ Object
- .add_persona_entry(entries, layer, value, name: nil) ⇒ Object
-
.agents_prompt ⇒ String?
Reads global agent instructions from the config directory.
- .banner_enabled?(config = read_config) ⇒ Boolean
- .cache_dir ⇒ Object
- .canonical_workspace_root(path) ⇒ Object
- .character_entries(raw) ⇒ Object
- .character_entry(entry) ⇒ Object
- .code_search_cache_dir ⇒ Object
- .composer_busy_help?(config = read_config) ⇒ Boolean
-
.config_dir ⇒ String
Directory that contains Kward's user config and adjacent prompt/skill data.
-
.config_path ⇒ String
Expanded JSON config file path.
- .config_value(config, *keys) ⇒ Object
- .crew_character_labels(personas) ⇒ Object
- .crew_characters(personas) ⇒ Object
- .default_config ⇒ Object
- .delete_config_key(key, path = config_path) ⇒ Object
- .ensure_default_config!(path = config_path) ⇒ Object
- .extract_character_instruction(definition) ⇒ Object
- .extract_character_label(definition) ⇒ Object
- .inside_directory?(path, base) ⇒ Boolean
- .markdown_parts(path) ⇒ Object
- .memory_core_path ⇒ Object
-
.memory_dir ⇒ String
Directory containing structured memory files.
- .memory_events_path ⇒ Object
- .memory_soft_path ⇒ Object
- .named_character_values(personas) ⇒ Object
-
.overlay_settings(config = read_config) ⇒ Hash
Returns validated overlay settings with defaults for missing or invalid values.
- .persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true) ⇒ Object
- .persona_label_for_key(value, labels) ⇒ Object
-
.persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config) ⇒ String?
Builds persona prompt text from default, workspace, model, reasoning, time-of-day, weekday, and suffix config entries.
-
.plugin_dir ⇒ String
Trusted user plugin directory.
-
.plugin_paths ⇒ Array<String>
Finds trusted top-level plugin files.
- .presence(value) ⇒ Object
- .prompt_template_registry ⇒ Object
-
.prompt_templates(reserved_commands: []) ⇒ Array<PromptTemplate>
Lists prompt templates exposed as slash commands.
-
.read_config(path = config_path) ⇒ Hash
Reads the JSON config file.
- .read_prompt_file(path, label) ⇒ Object
-
.read_skill_file(name, relative_path = nil) ⇒ String
Reads a skill file by skill name and optional relative path.
- .resolved_persona_text(value, characters: {}) ⇒ Object
- .session_auto_resume_enabled?(config = read_config) ⇒ Boolean
-
.skills ⇒ Array<Skill>
Lists configured skills discovered under the config directory.
- .skills_registry ⇒ Object
- .time_of_day_bucket(now) ⇒ Object
- .update_config(values, path = config_path) ⇒ Object
- .update_overlay_settings(values) ⇒ Object
- .warn_legacy_plugin_dir(plugins_root) ⇒ Object
- .weekday_name(now) ⇒ Object
- .workspace_agents_prompt(workspace_root) ⇒ Object
- .workspace_config(workspace_root, config = read_config) ⇒ Object
- .workspace_guardrails_enabled?(config = read_config) ⇒ Boolean
-
.write_config(config, path = config_path) ⇒ Object
Writes config JSON using private file permissions.
Class Method Details
.active_persona_label(workspace_root:, model: nil, config: read_config) ⇒ Object
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/kward/config_files.rb', line 238 def active_persona_label(workspace_root:, model: nil, config: read_config) personas = config["personas"] return nil unless personas.is_a?(Hash) labels = crew_character_labels(personas) active_label = persona_label_for_key(personas["default"], labels) unless personas["default"].nil? workspaces = personas["workspaces"] if workspaces.is_a?(Hash) root = canonical_workspace_root(workspace_root) workspaces.each do |path, key| next unless canonical_workspace_root(path) == root active_label = persona_label_for_key(key, labels) break end end models = personas["models"] if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s) active_label = persona_label_for_key(models[model.to_s], labels) end active_label end |
.add_persona_entry(entries, layer, value, name: nil) ⇒ Object
344 345 346 347 348 349 |
# File 'lib/kward/config_files.rb', line 344 def add_persona_entry(entries, layer, value, name: nil) text = presence(value) return unless text entries << { layer: layer.to_s, name: name.to_s, prompt: text } end |
.agents_prompt ⇒ String?
Reads global agent instructions from the config directory.
215 216 217 218 |
# File 'lib/kward/config_files.rb', line 215 def agents_prompt path = File.join(config_dir, "AGENTS.md") read_prompt_file(path, "Kward prompt file") end |
.banner_enabled?(config = read_config) ⇒ Boolean
174 175 176 177 |
# File 'lib/kward/config_files.rb', line 174 def (config = read_config) = config["banner"].is_a?(Hash) ? config["banner"] : {} ["enabled"] != false end |
.cache_dir ⇒ Object
53 54 55 |
# File 'lib/kward/config_files.rb', line 53 def cache_dir File.join(config_dir, "cache") end |
.canonical_workspace_root(path) ⇒ Object
339 340 341 342 |
# File 'lib/kward/config_files.rb', line 339 def canonical_workspace_root(path) = File.(path.to_s.empty? ? Dir.pwd : path.to_s) File.directory?() ? File.realpath() : end |
.character_entries(raw) ⇒ Object
391 392 393 394 395 396 397 398 399 400 |
# File 'lib/kward/config_files.rb', line 391 def character_entries(raw) case raw when Hash raw.map { |key, definition| [key, definition] } when Array raw.filter_map { |entry| character_entry(entry) } else [] end end |
.character_entry(entry) ⇒ Object
402 403 404 405 406 407 408 409 410 411 |
# File 'lib/kward/config_files.rb', line 402 def character_entry(entry) return nil unless entry.is_a?(Hash) if entry.length == 1 && entry.keys.first.is_a?(String) [entry.keys.first, entry.values.first] else key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name] key.to_s.empty? ? nil : [key, entry] end end |
.code_search_cache_dir ⇒ Object
84 85 86 |
# File 'lib/kward/config_files.rb', line 84 def code_search_cache_dir File.join(cache_dir, "code_search") end |
.composer_busy_help?(config = read_config) ⇒ Boolean
169 170 171 172 |
# File 'lib/kward/config_files.rb', line 169 def composer_busy_help?(config = read_config) composer = config["composer"].is_a?(Hash) ? config["composer"] : {} composer["busy_help"] != false end |
.config_dir ⇒ String
Directory that contains Kward's user config and adjacent prompt/skill
data. Defaults to ~/.kward, or the directory of KWARD_CONFIG_PATH.
41 42 43 44 45 46 |
# File 'lib/kward/config_files.rb', line 41 def config_dir config_path = ENV["KWARD_CONFIG_PATH"] return File.(File.dirname(config_path)) if config_path && !config_path.empty? File.("~/.kward") end |
.config_path ⇒ String
Returns expanded JSON config file path.
49 50 51 |
# File 'lib/kward/config_files.rb', line 49 def config_path File.(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json")) end |
.config_value(config, *keys) ⇒ Object
146 147 148 149 150 151 152 |
# File 'lib/kward/config_files.rb', line 146 def config_value(config, *keys) keys.each do |key| text = presence(config[key]) return text if text end nil end |
.crew_character_labels(personas) ⇒ Object
357 358 359 360 361 |
# File 'lib/kward/config_files.rb', line 357 def crew_character_labels(personas) named_character_values(personas) do |_key, definition| extract_character_label(definition) end end |
.crew_characters(personas) ⇒ Object
351 352 353 354 355 |
# File 'lib/kward/config_files.rb', line 351 def crew_characters(personas) named_character_values(personas) do |_key, definition| extract_character_instruction(definition) end end |
.default_config ⇒ Object
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/kward/config_files.rb', line 57 def default_config { "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)), "memory" => { "enabled" => false, "auto_summary" => false }, "composer" => { "busy_help" => true }, "sessions" => { "auto_resume" => false }, "tools" => { "workspace_guardrails" => true } } end |
.delete_config_key(key, path = config_path) ⇒ Object
138 139 140 141 142 143 144 |
# File 'lib/kward/config_files.rb', line 138 def delete_config_key(key, path = config_path) config = read_config(path) existed = config.key?(key.to_s) config.delete(key.to_s) write_config(config, path) if existed existed end |
.ensure_default_config!(path = config_path) ⇒ Object
76 77 78 79 80 81 82 |
# File 'lib/kward/config_files.rb', line 76 def ensure_default_config!(path = config_path) path = File.(path) return false if File.exist?(path) write_config(default_config, path) true end |
.extract_character_instruction(definition) ⇒ Object
419 420 421 422 423 424 425 426 427 428 |
# File 'lib/kward/config_files.rb', line 419 def extract_character_instruction(definition) return nil if definition.nil? if definition.is_a?(Hash) value = definition["instruction"] || definition[:instruction] return presence(value) end presence(definition) end |
.extract_character_label(definition) ⇒ Object
413 414 415 416 417 |
# File 'lib/kward/config_files.rb', line 413 def extract_character_label(definition) return nil unless definition.is_a?(Hash) presence(definition["label"] || definition[:label]) end |
.inside_directory?(path, base) ⇒ Boolean
531 532 533 |
# File 'lib/kward/config_files.rb', line 531 def inside_directory?(path, base) path == base || path.start_with?(base + File::SEPARATOR) end |
.markdown_parts(path) ⇒ Object
518 519 520 521 522 523 524 525 526 527 528 529 |
# File 'lib/kward/config_files.rb', line 518 def markdown_parts(path) content = File.read(path) return [{}, content] unless content.start_with?("---\n", "---\r\n") _opening, rest = content.split(/\A---\r?\n/, 2) yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2) raise "missing frontmatter closing delimiter" if body.nil? data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false) frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {} [frontmatter, body] end |
.memory_core_path ⇒ Object
93 94 95 |
# File 'lib/kward/config_files.rb', line 93 def memory_core_path File.join(memory_dir, "core.json") end |
.memory_dir ⇒ String
Returns directory containing structured memory files.
89 90 91 |
# File 'lib/kward/config_files.rb', line 89 def memory_dir File.join(config_dir, "memory") end |
.memory_events_path ⇒ Object
101 102 103 |
# File 'lib/kward/config_files.rb', line 101 def memory_events_path File.join(memory_dir, "events.jsonl") end |
.memory_soft_path ⇒ Object
97 98 99 |
# File 'lib/kward/config_files.rb', line 97 def memory_soft_path File.join(memory_dir, "soft.jsonl") end |
.named_character_values(personas) ⇒ Object
382 383 384 385 386 387 388 389 |
# File 'lib/kward/config_files.rb', line 382 def named_character_values(personas) character_entries(personas["characters"] || personas["crew"]).each_with_object({}) do |(key, definition), mapping| value = yield(key, definition) next if value.to_s.empty? mapping[key.to_s] = value end end |
.overlay_settings(config = read_config) ⇒ Hash
Returns validated overlay settings with defaults for missing or invalid values.
159 160 161 162 163 164 165 166 167 |
# File 'lib/kward/config_files.rb', line 159 def (config = read_config) = config["overlay"].is_a?(Hash) ? config["overlay"] : {} settings = DEFAULT_OVERLAY_SETTINGS.dup alignment = ["alignment"].to_s width = ["width"].to_s settings["alignment"] = alignment if OVERLAY_ALIGNMENTS.include?(alignment) settings["width"] = width if OVERLAY_WIDTHS.include?(width) settings end |
.persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true) ⇒ Object
264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'lib/kward/config_files.rb', line 264 def persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true) personas = config["personas"] return [] unless personas.is_a?(Hash) characters = crew_characters(personas) entries = [] add_persona_entry(entries, "default", resolved_persona_text(personas["default"], characters: characters)) workspaces = personas["workspaces"] if workspaces.is_a?(Hash) root = canonical_workspace_root(workspace_root) workspaces.each do |path, key| if canonical_workspace_root(path) == root add_persona_entry(entries, "workspace", resolved_persona_text(key, characters: characters), name: path) break end end end models = personas["models"] 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? modifiers = personas["persona_modifiers"] if modifiers.is_a?(Hash) if include_reasoning reasoning = modifiers["reasoning"] add_persona_entry(entries, "reasoning", reasoning[reasoning_effort.to_s]) if reasoning.is_a?(Hash) && !reasoning_effort.to_s.empty? end time_of_day = modifiers["time_of_day"] bucket = time_of_day_bucket(now) add_persona_entry(entries, "time_of_day", time_of_day[bucket], name: bucket) if time_of_day.is_a?(Hash) weekday = modifiers["weekday"] day = weekday_name(now) add_persona_entry(entries, "weekday", weekday[day], name: day) if weekday.is_a?(Hash) add_persona_entry(entries, "suffix", modifiers["suffix"]) end entries end |
.persona_label_for_key(value, labels) ⇒ Object
375 376 377 378 379 380 |
# File 'lib/kward/config_files.rb', line 375 def persona_label_for_key(value, labels) key = value.to_s.strip return nil if key.empty? presence(labels[key]) end |
.persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config) ⇒ String?
Builds persona prompt text from default, workspace, model, reasoning, time-of-day, weekday, and suffix config entries.
229 230 231 232 233 234 235 236 |
# File 'lib/kward/config_files.rb', line 229 def persona_prompt(workspace_root, model: nil, reasoning_effort: nil, now: Time.now, config: read_config) text = persona_entries(workspace_root: workspace_root, model: model, reasoning_effort: reasoning_effort, now: now, config: config).map do |entry| entry[:prompt] end.join("\n\n") return nil if text.empty? text end |
.plugin_dir ⇒ String
Returns trusted user plugin directory.
451 452 453 |
# File 'lib/kward/config_files.rb', line 451 def plugin_dir File.("~/.kward/plugins") end |
.plugin_paths ⇒ Array<String>
Finds trusted top-level plugin files.
Plugins are intentionally loaded only from ~/.kward/plugins, not from a
workspace or custom KWARD_CONFIG_PATH directory.
461 462 463 464 465 466 467 468 469 470 |
# File 'lib/kward/config_files.rb', line 461 def plugin_paths plugins_root = plugin_dir warn_legacy_plugin_dir(plugins_root) return [] unless Dir.exist?(plugins_root) Dir.glob(File.join(plugins_root, "*.rb")).sort rescue StandardError => e warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.}" [] end |
.presence(value) ⇒ Object
535 536 537 538 |
# File 'lib/kward/config_files.rb', line 535 def presence(value) text = value.to_s text.empty? ? nil : text end |
.prompt_template_registry ⇒ Object
510 511 512 513 514 515 516 |
# File 'lib/kward/config_files.rb', line 510 def prompt_template_registry Prompts::Templates.new( config_dir: config_dir, template_class: PromptTemplate, markdown_parser: method(:markdown_parts) ) end |
.prompt_templates(reserved_commands: []) ⇒ Array<PromptTemplate>
Lists prompt templates exposed as slash commands.
487 488 489 |
# File 'lib/kward/config_files.rb', line 487 def prompt_templates(reserved_commands: []) prompt_template_registry.prompt_templates(reserved_commands: reserved_commands) end |
.read_config(path = config_path) ⇒ Hash
Reads the JSON config file.
Missing files are treated as an empty config. Invalid JSON raises a user-facing error that includes the file path.
112 113 114 115 116 117 118 119 |
# File 'lib/kward/config_files.rb', line 112 def read_config(path = config_path) path = File.(path) return {} unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError raise "Invalid Kward config JSON: #{path}" end |
.read_prompt_file(path, label) ⇒ Object
313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/kward/config_files.rb', line 313 def read_prompt_file(path, label) return nil unless File.exist?(path) size = File.size(path) if size > MAX_PROMPT_FILE_BYTES warn "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)" return nil end File.read(path) rescue StandardError => e warn "Warning: skipping #{label} #{path}: #{e.}" nil end |
.read_skill_file(name, relative_path = nil) ⇒ String
Reads a skill file by skill name and optional relative path.
496 497 498 |
# File 'lib/kward/config_files.rb', line 496 def read_skill_file(name, relative_path = nil) skills_registry.read_skill_file(name, relative_path) end |
.resolved_persona_text(value, characters: {}) ⇒ Object
363 364 365 366 367 368 369 370 371 372 373 |
# File 'lib/kward/config_files.rb', line 363 def resolved_persona_text(value, characters: {}) return nil if value.nil? key = value.to_s.strip return nil if key.empty? text = characters[key.to_s] return text unless text.to_s.empty? value end |
.session_auto_resume_enabled?(config = read_config) ⇒ Boolean
184 185 186 187 |
# File 'lib/kward/config_files.rb', line 184 def session_auto_resume_enabled?(config = read_config) sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {} sessions["auto_resume"] == true end |
.skills ⇒ Array<Skill>
Lists configured skills discovered under the config directory.
446 447 448 |
# File 'lib/kward/config_files.rb', line 446 def skills skills_registry.skills end |
.skills_registry ⇒ Object
500 501 502 503 504 505 506 507 508 |
# File 'lib/kward/config_files.rb', line 500 def skills_registry Skills::Registry.new( config_dir: config_dir, skill_class: Skill, max_file_bytes: MAX_SKILL_FILE_BYTES, markdown_parser: method(:markdown_parts), inside_directory: method(:inside_directory?) ) end |
.time_of_day_bucket(now) ⇒ Object
430 431 432 433 434 435 436 437 |
# File 'lib/kward/config_files.rb', line 430 def time_of_day_bucket(now) hour = now.hour return "morning" if hour >= 5 && hour < 11 return "before_lunch" if hour == 11 return "late_evening" if hour >= 21 || hour < 5 nil end |
.update_config(values, path = config_path) ⇒ Object
129 130 131 132 133 134 135 136 |
# File 'lib/kward/config_files.rb', line 129 def update_config(values, path = config_path) raise "Config values must be an object" unless values.is_a?(Hash) config = read_config(path) values.each { |key, value| config[key.to_s] = value } write_config(config, path) config end |
.update_overlay_settings(values) ⇒ Object
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 |
# File 'lib/kward/config_files.rb', line 189 def (values) raise "Overlay settings must be an object" unless values.is_a?(Hash) config = read_config = config["overlay"].is_a?(Hash) ? config["overlay"].dup : {} values.each do |key, value| key = key.to_s value = value.to_s case key when "alignment" raise "Overlay alignment must be left, center, or right" unless OVERLAY_ALIGNMENTS.include?(value) when "width" raise "Overlay width must be capped or maximum" unless OVERLAY_WIDTHS.include?(value) else raise "Unknown overlay setting: #{key}" end [key] = value end config["overlay"] = write_config(config) (config) end |
.warn_legacy_plugin_dir(plugins_root) ⇒ Object
472 473 474 475 476 477 478 479 480 481 |
# File 'lib/kward/config_files.rb', line 472 def warn_legacy_plugin_dir(plugins_root) config_path = ENV["KWARD_CONFIG_PATH"] return if config_path.to_s.empty? legacy_root = File.(File.join(File.dirname(config_path), "plugins")) return if legacy_root == File.(plugins_root) return unless Dir.exist?(legacy_root) warn "Warning: ignoring Kward plugins in #{legacy_root}; plugins are only loaded from #{File.(plugins_root)}" end |
.weekday_name(now) ⇒ Object
439 440 441 |
# File 'lib/kward/config_files.rb', line 439 def weekday_name(now) %w[sunday monday tuesday wednesday thursday friday saturday][now.wday] end |
.workspace_agents_prompt(workspace_root) ⇒ Object
307 308 309 310 311 |
# File 'lib/kward/config_files.rb', line 307 def workspace_agents_prompt(workspace_root) root = canonical_workspace_root(workspace_root) path = File.join(root, "AGENTS.md") read_prompt_file(path, "workspace AGENTS.md") end |
.workspace_config(workspace_root, config = read_config) ⇒ Object
328 329 330 331 332 333 334 335 336 337 |
# File 'lib/kward/config_files.rb', line 328 def workspace_config(workspace_root, config = read_config) workspaces = config["workspaces"] return nil unless workspaces.is_a?(Hash) root = canonical_workspace_root(workspace_root) workspaces.each do |path, entry| return entry if canonical_workspace_root(path) == root end nil end |
.workspace_guardrails_enabled?(config = read_config) ⇒ Boolean
179 180 181 182 |
# File 'lib/kward/config_files.rb', line 179 def workspace_guardrails_enabled?(config = read_config) tools = config["tools"].is_a?(Hash) ? config["tools"] : {} tools["workspace_guardrails"] != false end |
.write_config(config, path = config_path) ⇒ Object
Writes config JSON using private file permissions.
125 126 127 |
# File 'lib/kward/config_files.rb', line 125 def write_config(config, path = config_path) PrivateFile.write_json(path, config) end |