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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. 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