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



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/kward/config_files.rb', line 217

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



323
324
325
326
327
328
# File 'lib/kward/config_files.rb', line 323

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



194
195
196
197
# File 'lib/kward/config_files.rb', line 194

def agents_prompt
  path = File.join(config_dir, "AGENTS.md")
  read_prompt_file(path, "Kward prompt file")
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



318
319
320
321
# File 'lib/kward/config_files.rb', line 318

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

.code_search_cache_dirObject



78
79
80
# File 'lib/kward/config_files.rb', line 78

def code_search_cache_dir
  File.join(cache_dir, "code_search")
end

.composer_busy_help?(config = read_config) ⇒ Boolean

Returns:

  • (Boolean)


163
164
165
166
# File 'lib/kward/config_files.rb', line 163

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



140
141
142
143
144
145
146
# File 'lib/kward/config_files.rb', line 140

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



343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/kward/config_files.rb', line 343

def crew_character_labels(personas)
  raw = personas["characters"] || personas["crew"]
  return {} unless raw

  if raw.is_a?(Hash)
    parse_named_character_labels(raw)
  elsif raw.is_a?(Array)
    parse_named_character_labels_array(raw)
  else
    {}
  end
end

.crew_characters(personas) ⇒ Object



330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/kward/config_files.rb', line 330

def crew_characters(personas)
  raw = personas["characters"] || personas["crew"]
  return {} unless raw

  if raw.is_a?(Hash)
    parse_named_characters(raw)
  elsif raw.is_a?(Array)
    parse_named_characters_array(raw)
  else
    {}
  end
end

.default_configObject



57
58
59
60
61
62
63
64
65
66
67
68
# 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
    }
  }
end

.delete_config_key(key, path = config_path) ⇒ Object



132
133
134
135
136
137
138
# File 'lib/kward/config_files.rb', line 132

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



70
71
72
73
74
75
76
# File 'lib/kward/config_files.rb', line 70

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



443
444
445
446
447
448
449
450
451
452
# File 'lib/kward/config_files.rb', line 443

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



437
438
439
440
441
# File 'lib/kward/config_files.rb', line 437

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)


555
556
557
# File 'lib/kward/config_files.rb', line 555

def inside_directory?(path, base)
  path == base || path.start_with?(base + File::SEPARATOR)
end

.markdown_parts(path) ⇒ Object



542
543
544
545
546
547
548
549
550
551
552
553
# File 'lib/kward/config_files.rb', line 542

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



87
88
89
# File 'lib/kward/config_files.rb', line 87

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



83
84
85
# File 'lib/kward/config_files.rb', line 83

def memory_dir
  File.join(config_dir, "memory")
end

.memory_events_pathObject



95
96
97
# File 'lib/kward/config_files.rb', line 95

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

.memory_soft_pathObject



91
92
93
# File 'lib/kward/config_files.rb', line 91

def memory_soft_path
  File.join(memory_dir, "soft.jsonl")
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



153
154
155
156
157
158
159
160
161
# File 'lib/kward/config_files.rb', line 153

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

.parse_named_character_labels(raw) ⇒ Object



406
407
408
409
410
411
412
413
# File 'lib/kward/config_files.rb', line 406

def parse_named_character_labels(raw)
  raw.each_with_object({}) do |(key, definition), mapping|
    label = extract_character_label(definition)
    next if label.nil?

    mapping[key.to_s] = label
  end
end

.parse_named_character_labels_array(raw) ⇒ Object



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/kward/config_files.rb', line 415

def parse_named_character_labels_array(raw)
  raw.each_with_object({}) do |entry, mapping|
    char_key = nil
    definition = nil

    if entry.is_a?(Hash) && entry.length == 1 && entry.keys.first.is_a?(String)
      char_key = entry.keys.first
      definition = entry.values.first
    elsif entry.is_a?(Hash)
      char_key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
      definition = entry
    end

    next if char_key.to_s.empty?

    label = extract_character_label(definition)
    next if label.to_s.empty?

    mapping[char_key.to_s] = label
  end
end

.parse_named_characters(raw) ⇒ Object



375
376
377
378
379
380
381
382
# File 'lib/kward/config_files.rb', line 375

def parse_named_characters(raw)
  raw.each_with_object({}) do |(key, definition), mapping|
    instruction = extract_character_instruction(definition)
    next if instruction.nil?

    mapping[key.to_s] = instruction
  end
end

.parse_named_characters_array(raw) ⇒ Object



384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/kward/config_files.rb', line 384

def parse_named_characters_array(raw)
  raw.each_with_object({}) do |entry, mapping|
    char_key = nil
    definition = nil

    if entry.is_a?(Hash) && entry.length == 1 && entry.keys.first.is_a?(String)
      char_key = entry.keys.first
      definition = entry.values.first
    elsif entry.is_a?(Hash)
      char_key = entry["key"] || entry[:key] || entry["id"] || entry[:id] || entry["name"] || entry[:name]
      definition = entry
    end

    next if char_key.to_s.empty?

    instruction = extract_character_instruction(definition)
    next if instruction.to_s.empty?

    mapping[char_key.to_s] = instruction
  end
end

.persona_entries(workspace_root:, model: nil, reasoning_effort: nil, now: Time.now, config: read_config, include_reasoning: true) ⇒ Object



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/kward/config_files.rb', line 243

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



368
369
370
371
372
373
# File 'lib/kward/config_files.rb', line 368

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



208
209
210
211
212
213
214
215
# File 'lib/kward/config_files.rb', line 208

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



475
476
477
# File 'lib/kward/config_files.rb', line 475

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



485
486
487
488
489
490
491
492
493
494
# File 'lib/kward/config_files.rb', line 485

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



559
560
561
562
# File 'lib/kward/config_files.rb', line 559

def presence(value)
  text = value.to_s
  text.empty? ? nil : text
end

.prompt_template_registryObject



534
535
536
537
538
539
540
# File 'lib/kward/config_files.rb', line 534

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:



511
512
513
# File 'lib/kward/config_files.rb', line 511

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



106
107
108
109
110
111
112
113
# File 'lib/kward/config_files.rb', line 106

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



292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'lib/kward/config_files.rb', line 292

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



520
521
522
# File 'lib/kward/config_files.rb', line 520

def read_skill_file(name, relative_path = nil)
  skills_registry.read_skill_file(name, relative_path)
end

.resolved_persona_text(value, characters: {}) ⇒ Object



356
357
358
359
360
361
362
363
364
365
366
# File 'lib/kward/config_files.rb', line 356

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

.skillsArray<Skill>

Lists configured skills discovered under the config directory.

Returns:

  • (Array<Skill>)

    skill metadata available to the model



470
471
472
# File 'lib/kward/config_files.rb', line 470

def skills
  skills_registry.skills
end

.skills_registryObject



524
525
526
527
528
529
530
531
532
# File 'lib/kward/config_files.rb', line 524

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



454
455
456
457
458
459
460
461
# File 'lib/kward/config_files.rb', line 454

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



123
124
125
126
127
128
129
130
# File 'lib/kward/config_files.rb', line 123

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



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/kward/config_files.rb', line 168

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



496
497
498
499
500
501
502
503
504
505
# File 'lib/kward/config_files.rb', line 496

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



463
464
465
# File 'lib/kward/config_files.rb', line 463

def weekday_name(now)
  %w[sunday monday tuesday wednesday thursday friday saturday][now.wday]
end

.workspace_agents_prompt(workspace_root) ⇒ Object



286
287
288
289
290
# File 'lib/kward/config_files.rb', line 286

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



307
308
309
310
311
312
313
314
315
316
# File 'lib/kward/config_files.rb', line 307

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

.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



119
120
121
# File 'lib/kward/config_files.rb', line 119

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