Skip to content
Kward Search API index

Module: Kward::ConfigFiles::Prompts

Extended by:
Prompts
Included in:
Prompts
Defined in:
lib/kward/config/prompts.rb

Instance Method Summary collapse

Instance Method Details

#active_persona_label(workspace_root:, model: nil, config: read_config) ⇒ Object

Returns the label of the persona selected by default/workspace/model rules.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/kward/config/prompts.rb', line 93

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



212
213
214
215
216
217
# File 'lib/kward/config/prompts.rb', line 212

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(config: read_config) ⇒ Object



7
8
9
10
11
12
13
14
# File 'lib/kward/config/prompts.rb', line 7

def agents_prompt(config: read_config)
  return nil unless include_config_principles?(config)

  path = config_principles_path
  return read_prompt_file(path, "Kward principles file") if File.exist?(path)

  read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
end

#canonical_workspace_root(path) ⇒ Object



207
208
209
210
# File 'lib/kward/config/prompts.rb', line 207

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



259
260
261
262
263
264
265
266
267
268
# File 'lib/kward/config/prompts.rb', line 259

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



270
271
272
273
274
275
276
277
278
279
# File 'lib/kward/config/prompts.rb', line 270

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

#config_agents_pathObject



66
67
68
# File 'lib/kward/config/prompts.rb', line 66

def config_agents_path
  File.join(config_dir, "AGENTS.md")
end

#config_principles_pathObject



62
63
64
# File 'lib/kward/config/prompts.rb', line 62

def config_principles_path
  File.join(config_dir, "PRINCIPLES.md")
end

#crew_character_labels(personas) ⇒ Object



225
226
227
228
229
# File 'lib/kward/config/prompts.rb', line 225

def crew_character_labels(personas)
  named_character_values(personas) do |_key, definition|
    extract_character_label(definition)
  end
end

#crew_characters(personas) ⇒ Object



219
220
221
222
223
# File 'lib/kward/config/prompts.rb', line 219

def crew_characters(personas)
  named_character_values(personas) do |_key, definition|
    extract_character_instruction(definition)
  end
end

#extract_character_instruction(definition) ⇒ Object



287
288
289
290
291
292
293
294
295
296
# File 'lib/kward/config/prompts.rb', line 287

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



281
282
283
284
285
# File 'lib/kward/config/prompts.rb', line 281

def extract_character_label(definition)
  return nil unless definition.is_a?(Hash)

  presence(definition["label"] || definition[:label])
end

#include_config_principles?(config = read_config) ⇒ Boolean

Returns whether config-directory principles are included in normal system prompt assembly. Replacement prompt files bypass all assembled sections.

Returns:

  • (Boolean)


18
19
20
21
22
23
# File 'lib/kward/config/prompts.rb', line 18

def include_config_principles?(config = read_config)
  settings = config["system_prompt"]
  return true unless settings.is_a?(Hash)

  settings["include_principles"] != false
end

#named_character_values(personas) ⇒ Object



250
251
252
253
254
255
256
257
# File 'lib/kward/config/prompts.rb', line 250

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

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



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/kward/config/prompts.rb', line 119

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 = []
  active_persona = { layer: "default", value: personas["default"], name: nil }

  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
        active_persona = { layer: "workspace", value: key, name: path }
        break
      end
    end
  end

  models = personas["models"]
  if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
    active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
  end

  add_persona_entry(
    entries,
    active_persona.fetch(:layer),
    resolved_persona_text(active_persona.fetch(:value), characters: characters),
    name: active_persona[:name]
  )

  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



243
244
245
246
247
248
# File 'lib/kward/config/prompts.rb', line 243

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.

Persona resolution is intentionally data-driven so users can edit config without plugin code. Keep new persona selectors additive and deterministic; prompt construction depends on stable ordering.

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



83
84
85
86
87
88
89
90
# File 'lib/kward/config/prompts.rb', line 83

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

#prompt_source_signature(path) ⇒ Object



183
184
185
186
187
188
189
190
# File 'lib/kward/config/prompts.rb', line 183

def prompt_source_signature(path)
  return "#{path}:missing" unless File.exist?(path)

  stat = File.stat(path)
  "#{path}:#{stat.size}:#{stat.mtime.to_f}"
rescue StandardError
  "#{path}:unavailable"
end

#read_prompt_file(path, label) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/kward/config/prompts.rb', line 192

def read_prompt_file(path, label)
  return nil unless File.exist?(path)

  size = File.size(path)
  if size > MAX_PROMPT_FILE_BYTES
    emit_warning "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
  emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
  nil
end

#replacement_system_prompt?(config = read_config) ⇒ Boolean

Returns:

  • (Boolean)


46
47
48
# File 'lib/kward/config/prompts.rb', line 46

def replacement_system_prompt?(config = read_config)
  !system_prompt_file_path(config).nil?
end

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



231
232
233
234
235
236
237
238
239
240
241
# File 'lib/kward/config/prompts.rb', line 231

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

#system_prompt_file(config = read_config) ⇒ Object

Returns the replacement system prompt text when configured. A configured but unavailable file intentionally yields nil: callers must not fall back to Kward's larger assembled prompt in replacement mode.



39
40
41
42
43
44
# File 'lib/kward/config/prompts.rb', line 39

def system_prompt_file(config = read_config)
  path = system_prompt_file_path(config)
  return nil unless path

  read_prompt_file(path, "custom system prompt file")
end

#system_prompt_file_path(config = read_config) ⇒ Object

Returns the configured replacement system prompt path, or nil when normal prompt assembly should be used. Relative paths are anchored to the config directory so configuration remains portable with KWARD_CONFIG_PATH.



28
29
30
31
32
33
34
# File 'lib/kward/config/prompts.rb', line 28

def system_prompt_file_path(config = read_config)
  settings = config["system_prompt"]
  value = settings.is_a?(Hash) ? settings["file"].to_s.strip : ""
  return nil if value.empty?

  File.expand_path(value, config_dir)
end

#system_prompt_sources_fingerprintObject

Returns a lightweight fingerprint for config-owned prompt sources. It is used by active conversations to pick up prompt edits without a restart.



52
53
54
55
56
57
58
59
60
# File 'lib/kward/config/prompts.rb', line 52

def system_prompt_sources_fingerprint
  config = read_config
  paths = [config_path, system_prompt_file_path(config)]
  if include_config_principles?(config)
    paths << (File.exist?(config_principles_path) ? config_principles_path : config_agents_path)
  end

  paths.compact.map { |path| prompt_source_signature(path) }.join("\0")
end

#time_of_day_bucket(now) ⇒ Object



298
299
300
301
302
303
304
305
# File 'lib/kward/config/prompts.rb', line 298

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

#weekday_name(now) ⇒ Object



307
308
309
# File 'lib/kward/config/prompts.rb', line 307

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

#workspace_agents_file?(workspace_root) ⇒ Boolean

Returns:

  • (Boolean)


175
176
177
# File 'lib/kward/config/prompts.rb', line 175

def workspace_agents_file?(workspace_root)
  File.exist?(workspace_agents_path(workspace_root))
end

#workspace_agents_path(workspace_root) ⇒ Object



171
172
173
# File 'lib/kward/config/prompts.rb', line 171

def workspace_agents_path(workspace_root)
  File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
end

#workspace_agents_prompt(workspace_root) ⇒ Object



179
180
181
# File 'lib/kward/config/prompts.rb', line 179

def workspace_agents_prompt(workspace_root)
  read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
end