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

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_promptString?

Reads global agent instructions from the config directory.

Returns:

  • (String, nil)

    prompt text, or nil when absent/too large



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

Returns:

  • (Boolean)


174
175
176
177
# File 'lib/kward/config_files.rb', line 174

def banner_enabled?(config = read_config)
  banner = config["banner"].is_a?(Hash) ? config["banner"] : {}
  banner["enabled"] != false
end

.cache_dirObject



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)
  expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
  File.directory?(expanded) ? File.realpath(expanded) : expanded
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_dirObject



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

Returns:

  • (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_dirString

Directory that contains Kward's user config and adjacent prompt/skill data. Defaults to ~/.kward, or the directory of KWARD_CONFIG_PATH.

Returns:

  • (String)

    expanded config directory 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.expand_path(File.dirname(config_path)) if config_path && !config_path.empty?

  File.expand_path("~/.kward")
end

.config_pathString

Returns expanded JSON config file path.

Returns:

  • (String)

    expanded JSON config file path



49
50
51
# File 'lib/kward/config_files.rb', line 49

def config_path
  File.expand_path(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_configObject



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.expand_path(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

Returns:

  • (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_pathObject



93
94
95
# File 'lib/kward/config_files.rb', line 93

def memory_core_path
  File.join(memory_dir, "core.json")
end

.memory_dirString

Returns directory containing structured memory files.

Returns:

  • (String)

    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_pathObject



101
102
103
# File 'lib/kward/config_files.rb', line 101

def memory_events_path
  File.join(memory_dir, "events.jsonl")
end

.memory_soft_pathObject



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.

Parameters:

  • config (Hash) (defaults to: read_config)

    parsed config object

Returns:

  • (Hash)

    overlay settings with alignment and width



159
160
161
162
163
164
165
166
167
# File 'lib/kward/config_files.rb', line 159

def overlay_settings(config = read_config)
  overlay = config["overlay"].is_a?(Hash) ? config["overlay"] : {}
  settings = DEFAULT_OVERLAY_SETTINGS.dup
  alignment = overlay["alignment"].to_s
  width = overlay["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.

Parameters:

  • workspace_root (String)

    active workspace root

  • model (String, nil) (defaults to: nil)

    active model name

  • reasoning_effort (String, nil) (defaults to: nil)

    active reasoning effort

  • now (Time) (defaults to: Time.now)

    local time used for time-based modifiers

  • config (Hash) (defaults to: read_config)

    parsed config object

Returns:

  • (String, nil)

    persona prompt text when entries match



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_dirString

Returns trusted user plugin directory.

Returns:

  • (String)

    trusted user plugin directory



451
452
453
# File 'lib/kward/config_files.rb', line 451

def plugin_dir
  File.expand_path("~/.kward/plugins")
end

.plugin_pathsArray<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.

Returns:

  • (Array<String>)

    sorted plugin file paths



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.message}"
  []
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_registryObject



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.

Parameters:

  • reserved_commands (Array<String>) (defaults to: [])

    command names unavailable to templates

Returns:



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.

Parameters:

  • path (String) (defaults to: config_path)

    config file path

Returns:

  • (Hash)

    parsed config object



112
113
114
115
116
117
118
119
# File 'lib/kward/config_files.rb', line 112

def read_config(path = config_path)
  path = File.expand_path(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.message}"
  nil
end

.read_skill_file(name, relative_path = nil) ⇒ String

Reads a skill file by skill name and optional relative path.

Parameters:

  • name (String)

    configured skill name

  • relative_path (String, nil) (defaults to: nil)

    path inside the skill directory

Returns:

  • (String)

    file contents or an error string



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

Returns:

  • (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

.skillsArray<Skill>

Lists configured skills discovered under the config directory.

Returns:

  • (Array<Skill>)

    skill metadata available to the model



446
447
448
# File 'lib/kward/config_files.rb', line 446

def skills
  skills_registry.skills
end

.skills_registryObject



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 update_overlay_settings(values)
  raise "Overlay settings must be an object" unless values.is_a?(Hash)

  config = read_config
  overlay = 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
    overlay[key] = value
  end
  config["overlay"] = overlay
  write_config(config)
  overlay_settings(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.expand_path(File.join(File.dirname(config_path), "plugins"))
  return if legacy_root == File.expand_path(plugins_root)
  return unless Dir.exist?(legacy_root)

  warn "Warning: ignoring Kward plugins in #{legacy_root}; plugins are only loaded from #{File.expand_path(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

Returns:

  • (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.

Parameters:

  • config (Hash)

    config object to persist

  • path (String) (defaults to: config_path)

    config file path



125
126
127
# File 'lib/kward/config_files.rb', line 125

def write_config(config, path = config_path)
  PrivateFile.write_json(path, config)
end