kward 0.74.0 → 0.76.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +75 -10
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +2 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +111 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +77 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/interactive_turn.rb +3 -0
  27. data/lib/kward/cli/memory_commands.rb +1 -5
  28. data/lib/kward/cli/plugins.rb +41 -1
  29. data/lib/kward/cli/prompt_interface.rb +28 -7
  30. data/lib/kward/cli/rendering.rb +8 -4
  31. data/lib/kward/cli/runtime_helpers.rb +44 -5
  32. data/lib/kward/cli/sessions.rb +13 -0
  33. data/lib/kward/cli/settings.rb +226 -152
  34. data/lib/kward/cli/slash_commands.rb +81 -42
  35. data/lib/kward/cli/tabs.rb +26 -6
  36. data/lib/kward/cli.rb +26 -3
  37. data/lib/kward/cli_transcript_formatter.rb +21 -18
  38. data/lib/kward/compaction/token_estimator.rb +112 -0
  39. data/lib/kward/compactor.rb +42 -116
  40. data/lib/kward/config_files.rb +120 -25
  41. data/lib/kward/deep_copy.rb +42 -0
  42. data/lib/kward/diff_view_mode.rb +36 -0
  43. data/lib/kward/events.rb +2 -0
  44. data/lib/kward/export_path.rb +2 -6
  45. data/lib/kward/frontmatter.rb +43 -0
  46. data/lib/kward/hooks/audit_log.rb +121 -0
  47. data/lib/kward/hooks/catalog.rb +110 -0
  48. data/lib/kward/hooks/command_handler.rb +66 -0
  49. data/lib/kward/hooks/config_loader.rb +104 -0
  50. data/lib/kward/hooks/decision.rb +102 -0
  51. data/lib/kward/hooks/event.rb +60 -0
  52. data/lib/kward/hooks/http_handler.rb +72 -0
  53. data/lib/kward/hooks/manager.rb +158 -0
  54. data/lib/kward/hooks/matcher.rb +75 -0
  55. data/lib/kward/hooks.rb +16 -0
  56. data/lib/kward/markdown_transcript.rb +4 -3
  57. data/lib/kward/mcp/client.rb +1 -1
  58. data/lib/kward/mcp/server_config.rb +7 -3
  59. data/lib/kward/mcp/stdio_transport.rb +7 -1
  60. data/lib/kward/memory/turn_context.rb +19 -0
  61. data/lib/kward/message_access.rb +2 -2
  62. data/lib/kward/message_text.rb +19 -0
  63. data/lib/kward/model/client.rb +23 -52
  64. data/lib/kward/model/context_usage.rb +10 -4
  65. data/lib/kward/model/copilot_models.rb +44 -0
  66. data/lib/kward/model/model_info.rb +38 -5
  67. data/lib/kward/model/payloads.rb +15 -17
  68. data/lib/kward/model/stream_parser.rb +183 -18
  69. data/lib/kward/pan/index.html.erb +1 -0
  70. data/lib/kward/pan/server.rb +53 -2
  71. data/lib/kward/path_guard.rb +13 -0
  72. data/lib/kward/plugin_registry.rb +88 -25
  73. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  74. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  75. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  76. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  77. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
  78. data/lib/kward/prompt_interface.rb +4 -1
  79. data/lib/kward/prompts/commands.rb +3 -3
  80. data/lib/kward/prompts.rb +7 -7
  81. data/lib/kward/rpc/mcp_status.rb +50 -0
  82. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  83. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  84. data/lib/kward/rpc/server.rb +95 -15
  85. data/lib/kward/rpc/session_manager.rb +260 -33
  86. data/lib/kward/rpc/session_metrics.rb +1 -1
  87. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  88. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  89. data/lib/kward/rpc/transcript_normalizer.rb +19 -20
  90. data/lib/kward/rpc/turn_context.rb +109 -0
  91. data/lib/kward/session_store.rb +6 -20
  92. data/lib/kward/session_tree_nodes.rb +98 -18
  93. data/lib/kward/session_tree_renderer.rb +6 -61
  94. data/lib/kward/skills/registry.rb +109 -17
  95. data/lib/kward/tools/mcp_tool.rb +4 -0
  96. data/lib/kward/tools/read_skill.rb +9 -4
  97. data/lib/kward/tools/registry.rb +266 -8
  98. data/lib/kward/tools/search/code.rb +4 -22
  99. data/lib/kward/tools/search/web.rb +28 -2
  100. data/lib/kward/update_check.rb +122 -0
  101. data/lib/kward/version.rb +1 -1
  102. data/lib/kward/workers/manager.rb +43 -3
  103. data/lib/kward/workspace.rb +3 -1
  104. data/templates/default/fulldoc/html/css/kward.css +22 -0
  105. data/templates/default/fulldoc/html/js/kward.js +2 -0
  106. data/templates/default/kward_navigation.rb +4 -2
  107. data/templates/default/layout/html/setup.rb +2 -0
  108. metadata +25 -1
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "model/chat_invocation"
3
3
  require_relative "compaction/file_operation_tracker"
4
+ require_relative "compaction/token_estimator"
4
5
  require_relative "config_files"
5
6
  require_relative "message_access"
6
7
  require_relative "prompts"
@@ -74,111 +75,6 @@ module Kward
74
75
  end
75
76
  end
76
77
 
77
- # Compaction support object used by conversation summarization.
78
- class TokenEstimator
79
- def estimate_tokens(text)
80
- (text.to_s.length / 4.0).ceil
81
- end
82
-
83
- def messages_tokens(messages)
84
- Array(messages).sum { |message| message_tokens(message) }
85
- end
86
-
87
- def context_tokens(messages)
88
- messages = Array(messages)
89
- usage_info = last_assistant_usage_info(messages)
90
- return messages_tokens(messages) unless usage_info
91
-
92
- usage_tokens = usage_tokens(usage_info[:usage])
93
- trailing_tokens = messages[(usage_info[:index] + 1)..].to_a.sum { |message| message_tokens(message) }
94
- usage_tokens + trailing_tokens
95
- end
96
-
97
- def message_tokens(message)
98
- role = value(message, :role)
99
- parts = [role]
100
- if role.to_s == "compactionSummary"
101
- parts << value(message, :summary)
102
- else
103
- parts << content_text(value(message, :content))
104
- end
105
- parts << value(message, :reasoning_summary)
106
- tool_calls(message).each do |tool_call|
107
- parts << tool_call_name(tool_call)
108
- parts << tool_call_arguments(tool_call)
109
- end
110
- parts << value(message, :tool_call_id)
111
- parts << value(message, :name)
112
- estimate_tokens(parts.compact.join("\n"))
113
- end
114
-
115
- private
116
-
117
- def content_text(content)
118
- return content.to_s unless content.is_a?(Array)
119
-
120
- content.filter_map do |part|
121
- type = value(part, :type)
122
- if type == "text"
123
- value(part, :text)
124
- elsif type == "image"
125
- path = value(part, :path)
126
- media_type = value(part, :media_type) || value(part, :mimeType) || "image"
127
- "[#{media_type}#{path ? ": #{path}" : ""}]"
128
- end
129
- end.join("\n")
130
- end
131
-
132
- def tool_calls(message)
133
- MessageAccess.tool_calls(message)
134
- end
135
-
136
- def tool_call_name(tool_call)
137
- function = value(tool_call, :function) || {}
138
- value(function, :name)
139
- end
140
-
141
- def tool_call_arguments(tool_call)
142
- function = value(tool_call, :function) || {}
143
- arguments = value(function, :arguments)
144
- arguments.is_a?(Hash) ? JSON.dump(arguments) : arguments.to_s
145
- end
146
-
147
- def last_assistant_usage_info(messages)
148
- messages.each_with_index.reverse_each do |message, index|
149
- next unless value(message, :role).to_s == "assistant"
150
-
151
- usage = value(message, :usage)
152
- tokens = usage_tokens(usage)
153
- return { usage: usage, index: index } if tokens.positive?
154
- end
155
- nil
156
- end
157
-
158
- def usage_tokens(usage)
159
- return 0 unless usage.respond_to?(:key?)
160
-
161
- total = usage_value(usage, :total_tokens, "totalTokens")
162
- return total if total.positive?
163
-
164
- usage_value(usage, :input_tokens, "input", "prompt_tokens") +
165
- usage_value(usage, :output_tokens, "output", "completion_tokens") +
166
- usage_value(usage, :cache_read_tokens, "cacheRead", "cacheReadTokens", "cache_read", "cached_tokens") +
167
- usage_value(usage, :cache_write_tokens, "cacheWrite", "cacheWriteTokens", "cache_write")
168
- end
169
-
170
- def usage_value(usage, *keys)
171
- key = keys.find { |candidate| usage.key?(candidate) || usage.key?(candidate.to_s) }
172
- return 0 unless key
173
-
174
- (usage[key] || usage[key.to_s]).to_i
175
- end
176
-
177
- def value(object, key)
178
- ToolCall.value(object, key)
179
- end
180
- end
181
-
182
78
  # Compaction support object used by conversation summarization.
183
79
  class ConversationSerializer
184
80
  TOOL_RESULT_LIMIT = 2_000
@@ -324,17 +220,15 @@ module Kward
324
220
  end
325
221
 
326
222
  def tool_call_id(tool_call)
327
- tool_call["id"] || tool_call[:id]
223
+ ToolCall.id(tool_call)
328
224
  end
329
225
 
330
226
  def tool_call_name(tool_call)
331
- function = tool_call["function"] || tool_call[:function] || {}
332
- function["name"] || function[:name] || "unknown_tool"
227
+ ToolCall.name(tool_call) || "unknown_tool"
333
228
  end
334
229
 
335
230
  def tool_call_args(tool_call)
336
- function = tool_call["function"] || tool_call[:function] || {}
337
- ToolCall.parse_arguments(function["arguments"] || function[:arguments])
231
+ ToolCall.arguments(tool_call)
338
232
  end
339
233
 
340
234
  def tool_command(tool_call)
@@ -449,7 +343,7 @@ module Kward
449
343
  def call
450
344
  branch_entries = entry_messages(@conversation.messages)
451
345
  raise NothingToCompact, "Nothing to compact" if branch_entries.empty?
452
- raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last) || already_compacted?
346
+ raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last)
453
347
 
454
348
  previous_index = latest_previous_compaction_index(branch_entries)
455
349
  previous_entry = previous_index ? branch_entries[previous_index] : nil
@@ -464,7 +358,8 @@ module Kward
464
358
  first_kept_entry_id = entry_id(branch_entries[first_kept_index], first_kept_index)
465
359
  summarized_for_file_ops = cut.messages_to_summarize + cut.turn_prefix_messages
466
360
  file_ops = @file_operation_tracker.call(summarized_for_file_ops, previous_details: compaction_details(previous_entry))
467
- kept_messages = Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
361
+ preserved_skill_messages = activated_skill_messages(summarized_for_file_ops)
362
+ kept_messages = preserved_skill_messages + Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
468
363
 
469
364
  PreparationResult.new(
470
365
  first_kept_entry_id: first_kept_entry_id,
@@ -485,10 +380,6 @@ module Kward
485
380
  Array(messages).reject { |message| message_role(message) == "system" }
486
381
  end
487
382
 
488
- def already_compacted?
489
- @conversation.respond_to?(:last_entry_compaction?) && @conversation.last_entry_compaction?
490
- end
491
-
492
383
  def latest_previous_compaction_index(entries)
493
384
  (0...entries.length).to_a.reverse.find { |index| compaction_entry?(entries[index]) }
494
385
  end
@@ -503,6 +394,41 @@ module Kward
503
394
  previous_index + 1
504
395
  end
505
396
 
397
+ def activated_skill_messages(messages)
398
+ Array(messages).each_with_index.flat_map do |message, index|
399
+ next [] unless read_skill_tool_message?(message)
400
+
401
+ tool_call_id = MessageAccess.tool_call_id(message).to_s
402
+ tool_call_id = "preserved_read_skill_#{index}" if tool_call_id.empty?
403
+ skill_name = skill_name_from_content(MessageAccess.content(message)) || "unknown"
404
+ [
405
+ {
406
+ "role" => "assistant",
407
+ "content" => nil,
408
+ "tool_calls" => [
409
+ {
410
+ "id" => tool_call_id,
411
+ "type" => "function",
412
+ "function" => {
413
+ "name" => "read_skill",
414
+ "arguments" => JSON.generate(name: skill_name)
415
+ }
416
+ }
417
+ ]
418
+ },
419
+ message
420
+ ]
421
+ end
422
+ end
423
+
424
+ def read_skill_tool_message?(message)
425
+ message_role(message) == "tool" && MessageAccess.tool_name(message) == "read_skill" && MessageAccess.content(message).to_s.include?("<skill_content")
426
+ end
427
+
428
+ def skill_name_from_content(content)
429
+ content.to_s[/<skill_content name="([^"]+)"/, 1]
430
+ end
431
+
506
432
  def compaction_entry?(message)
507
433
  message_role(message) == "compactionSummary"
508
434
  end
@@ -2,9 +2,12 @@ require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
4
  require "yaml"
5
+ require_relative "frontmatter"
5
6
  require_relative "private_file"
7
+ require_relative "path_guard"
6
8
  require_relative "ekwsh"
7
9
  require_relative "editor_mode"
10
+ require_relative "diff_view_mode"
8
11
  require_relative "prompts/templates"
9
12
  require_relative "skills/registry"
10
13
 
@@ -41,7 +44,7 @@ module Kward
41
44
  {
42
45
  "key" => "kward",
43
46
  "label" => "Kward",
44
- "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."
47
+ "instruction" => "Your name is Kward, the grim Andruid - robotic keeper of the Forest of Code, protecting the nature of good engineering principles. Speak like an old druid, be suspicious of everyone, but with good intent."
45
48
  }
46
49
  ],
47
50
  "default" => "kward"
@@ -49,7 +52,7 @@ module Kward
49
52
  OVERLAY_ALIGNMENTS = %w[left center right].freeze
50
53
  OVERLAY_WIDTHS = %w[capped maximum].freeze
51
54
 
52
- Skill = Struct.new(:name, :description, :folder, :path, keyword_init: true)
55
+ Skill = Struct.new(:name, :description, :folder, :path, :license, :compatibility, :metadata, :allowed_tools, keyword_init: true)
53
56
  PromptTemplate = Struct.new(:command, :description, :argument_hint, :body, :path, keyword_init: true) do
54
57
  def expand(arguments)
55
58
  body.gsub("$ARGUMENTS", arguments.to_s)
@@ -92,6 +95,14 @@ module Kward
92
95
  File.join(config_dir, "ekwsh.yml")
93
96
  end
94
97
 
98
+ def workspace_hooks_path(root = Dir.pwd)
99
+ File.join(File.expand_path(root), ".kward", "hooks.json")
100
+ end
101
+
102
+ def trusted_workspace_hooks_path
103
+ File.join(config_dir, "trusted_workspace_hooks.json")
104
+ end
105
+
95
106
  def default_config
96
107
  {
97
108
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -109,11 +120,24 @@ module Kward
109
120
  "auto_close_pairs" => true,
110
121
  "soft_wrap" => true,
111
122
  "bar_cursor" => true,
112
- "line_numbers" => "absolute"
123
+ "line_numbers" => "absolute",
124
+ "diff_view" => "auto"
125
+ },
126
+ "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
127
+ "web_search" => {
128
+ "enabled" => true,
129
+ "provider" => "auto",
130
+ "allow_model_providers" => false
131
+ },
132
+ "updates" => {
133
+ "check" => true
113
134
  },
114
135
  "sessions" => {
115
136
  "auto_resume" => false
116
137
  },
138
+ "skills" => {
139
+ "trust_project" => false
140
+ },
117
141
  "enforce_workspace_agents_file" => false,
118
142
  "mcpServers" => {},
119
143
  "tools" => {
@@ -140,6 +164,10 @@ module Kward
140
164
  File.join(cache_dir, "openrouter_models.json")
141
165
  end
142
166
 
167
+ def update_check_cache_path
168
+ File.join(cache_dir, "update_check.json")
169
+ end
170
+
143
171
  def project_browser_state_path
144
172
  File.join(cache_dir, "project_browser_state.json")
145
173
  end
@@ -198,6 +226,72 @@ module Kward
198
226
  PrivateFile.write_json(path, config)
199
227
  end
200
228
 
229
+ def lifecycle_hooks_config(workspace_root = Dir.pwd)
230
+ config = read_config
231
+ workspace_config = read_trusted_workspace_hooks_config(workspace_root)
232
+ return config if workspace_config.empty?
233
+
234
+ merge_hooks_config(config, workspace_config)
235
+ end
236
+
237
+ def read_trusted_workspace_hooks_config(workspace_root = Dir.pwd)
238
+ path = workspace_hooks_path(workspace_root)
239
+ return {} unless workspace_hooks_trusted?(workspace_root)
240
+
241
+ read_config(path)
242
+ end
243
+
244
+ def workspace_hooks_trusted?(workspace_root = Dir.pwd)
245
+ path = workspace_hooks_path(workspace_root)
246
+ return false unless File.file?(path)
247
+
248
+ trusted = read_trusted_workspace_hooks
249
+ trusted[File.expand_path(path)] == workspace_hooks_digest(workspace_root)
250
+ end
251
+
252
+ def trust_workspace_hooks!(workspace_root = Dir.pwd)
253
+ path = workspace_hooks_path(workspace_root)
254
+ raise "No workspace hook config found: #{path}" unless File.file?(path)
255
+
256
+ trusted = read_trusted_workspace_hooks
257
+ trusted[File.expand_path(path)] = workspace_hooks_digest(workspace_root)
258
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
259
+ end
260
+
261
+ def untrust_workspace_hooks!(workspace_root = Dir.pwd)
262
+ path = File.expand_path(workspace_hooks_path(workspace_root))
263
+ trusted = read_trusted_workspace_hooks
264
+ trusted.delete(path)
265
+ PrivateFile.write_json(trusted_workspace_hooks_path, trusted)
266
+ end
267
+
268
+ def workspace_hooks_digest(workspace_root = Dir.pwd)
269
+ Digest::SHA256.file(workspace_hooks_path(workspace_root)).hexdigest
270
+ end
271
+
272
+ def read_trusted_workspace_hooks
273
+ config = read_config(trusted_workspace_hooks_path)
274
+ config.is_a?(Hash) ? config : {}
275
+ rescue ConfigError
276
+ {}
277
+ end
278
+
279
+ def merge_hooks_config(config, workspace_config)
280
+ merged = JSON.parse(JSON.generate(config || {}))
281
+ merged["hooks"] = merge_hook_maps(merged["hooks"], workspace_config["hooks"] || workspace_config[:hooks])
282
+ merged
283
+ end
284
+
285
+ def merge_hook_maps(left, right)
286
+ left = left.is_a?(Hash) ? left : {}
287
+ right = right.is_a?(Hash) ? right : {}
288
+ merged = JSON.parse(JSON.generate(left))
289
+ right.each do |event, entries|
290
+ merged[event.to_s] = Array(merged[event.to_s]) + Array(entries)
291
+ end
292
+ merged
293
+ end
294
+
201
295
  def read_ekwsh_config(path = ekwsh_config_path)
202
296
  path = File.expand_path(path)
203
297
  return normalize_ekwsh_config(nil) unless File.exist?(path)
@@ -364,12 +458,24 @@ module Kward
364
458
  EditorMode.normalize_line_numbers(editor["line_numbers"])
365
459
  end
366
460
 
461
+ # Returns the integrated diff viewer display mode.
462
+ def diff_view(config = read_config)
463
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
464
+ DiffViewMode.normalize(editor["diff_view"])
465
+ end
466
+
367
467
  # Returns whether file tools must stay inside the active workspace root.
368
468
  def workspace_guardrails_enabled?(config = read_config)
369
469
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
370
470
  tools["workspace_guardrails"] != false
371
471
  end
372
472
 
473
+ # Returns whether project-level Agent Skills should be loaded from the workspace.
474
+ def project_skills_trusted?(config = read_config)
475
+ skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
476
+ skills["trust_project"] == true
477
+ end
478
+
373
479
  # Returns whether new frontends should resume the last active session automatically.
374
480
  def session_auto_resume_enabled?(config = read_config)
375
481
  sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
@@ -399,7 +505,7 @@ module Kward
399
505
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
400
506
 
401
507
  config = read_config
402
- overlay = config["overlay"].is_a?(Hash) ? config["overlay"].dup : {}
508
+ overlay = overlay_settings(config)
403
509
  values.each do |key, value|
404
510
  key = key.to_s
405
511
  value = value.to_s
@@ -674,8 +780,8 @@ module Kward
674
780
  # Lists configured skills discovered under the config directory.
675
781
  #
676
782
  # @return [Array<Skill>] skill metadata available to the model
677
- def skills
678
- skills_registry.skills
783
+ def skills(workspace_root: Dir.pwd)
784
+ skills_registry(workspace_root: workspace_root).skills
679
785
  end
680
786
 
681
787
  # @return [String] trusted user plugin directory
@@ -712,16 +818,18 @@ module Kward
712
818
  # @param name [String] configured skill name
713
819
  # @param relative_path [String, nil] path inside the skill directory
714
820
  # @return [String] file contents or an error string
715
- def read_skill_file(name, relative_path = nil)
716
- skills_registry.read_skill_file(name, relative_path)
821
+ def read_skill_file(name, relative_path = nil, workspace_root: Dir.pwd)
822
+ skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
717
823
  end
718
824
 
719
- def skills_registry
825
+ def skills_registry(workspace_root: Dir.pwd)
720
826
  Skills::Registry.new(
721
827
  config_dir: config_dir,
828
+ workspace_root: workspace_root,
829
+ project_skills_trusted: project_skills_trusted?,
722
830
  skill_class: Skill,
723
831
  max_file_bytes: MAX_SKILL_FILE_BYTES,
724
- markdown_parser: method(:markdown_parts),
832
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
725
833
  inside_directory: method(:inside_directory?)
726
834
  )
727
835
  end
@@ -730,25 +838,12 @@ module Kward
730
838
  Prompts::Templates.new(
731
839
  config_dir: config_dir,
732
840
  template_class: PromptTemplate,
733
- markdown_parser: method(:markdown_parts)
841
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
734
842
  )
735
843
  end
736
844
 
737
- def markdown_parts(path)
738
- content = File.read(path)
739
- return [{}, content] unless content.start_with?("---\n", "---\r\n")
740
-
741
- _opening, rest = content.split(/\A---\r?\n/, 2)
742
- yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
743
- raise "missing frontmatter closing delimiter" if body.nil?
744
-
745
- data = yaml_text.to_s.empty? ? {} : YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
746
- frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
747
- [frontmatter, body]
748
- end
749
-
750
845
  def inside_directory?(path, base)
751
- path == base || path.start_with?(base + File::SEPARATOR)
846
+ PathGuard.inside?(path, base)
752
847
  end
753
848
 
754
849
  def presence(value)
@@ -0,0 +1,42 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Small recursive copy/freeze helpers for plain Hash/Array payload objects.
4
+ module DeepCopy
5
+ module_function
6
+
7
+ def dup(value)
8
+ case value
9
+ when Hash
10
+ value.each_with_object({}) { |(key, item), result| result[key] = dup(item) }
11
+ when Array
12
+ value.map { |item| dup(item) }
13
+ else
14
+ value.dup
15
+ end
16
+ rescue TypeError
17
+ value
18
+ end
19
+
20
+ def freeze(value)
21
+ case value
22
+ when Hash
23
+ value.each_value { |item| freeze(item) }
24
+ when Array
25
+ value.each { |item| freeze(item) }
26
+ end
27
+ value.freeze
28
+ end
29
+
30
+ def merge(left, right)
31
+ left = dup(left)
32
+ right.each do |key, value|
33
+ left[key] = if left[key].is_a?(Hash) && value.is_a?(Hash)
34
+ merge(left[key], value)
35
+ else
36
+ dup(value)
37
+ end
38
+ end
39
+ left
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Normalizes and resolves the integrated diff viewer display mode.
4
+ module DiffViewMode
5
+ AUTO = "auto"
6
+ UNIFIED = "unified"
7
+ SIDE_BY_SIDE = "side_by_side"
8
+ MODES = [AUTO, UNIFIED, SIDE_BY_SIDE].freeze
9
+ SIDE_BY_SIDE_MIN_WIDTH = 120
10
+
11
+ module_function
12
+
13
+ def normalize(value)
14
+ text = value.to_s.downcase.tr("-", "_")
15
+ MODES.include?(text) ? text : AUTO
16
+ end
17
+
18
+ def label(value)
19
+ case normalize(value)
20
+ when SIDE_BY_SIDE
21
+ "side-by-side"
22
+ when UNIFIED
23
+ "unified"
24
+ else
25
+ "auto"
26
+ end
27
+ end
28
+
29
+ def resolve(value, terminal_width: nil)
30
+ mode = normalize(value)
31
+ return mode unless mode == AUTO
32
+
33
+ terminal_width.to_i >= SIDE_BY_SIDE_MIN_WIDTH ? SIDE_BY_SIDE : UNIFIED
34
+ end
35
+ end
36
+ end
data/lib/kward/events.rb CHANGED
@@ -3,12 +3,14 @@ module Kward
3
3
  # Frontend-neutral event objects emitted during agent turns.
4
4
  module Events
5
5
  ReasoningDelta = Struct.new(:delta, keyword_init: true)
6
+ ReasoningBoundary = Data.define
6
7
  AssistantDelta = Struct.new(:delta, keyword_init: true)
7
8
  AssistantMessage = Struct.new(:message, keyword_init: true)
8
9
  Retry = Struct.new(:provider, :model, :attempt, :max_attempts, :delay_seconds, :error, :request_bytes, keyword_init: true)
9
10
  Steering = Struct.new(:input, :created_at, keyword_init: true)
10
11
  SteeringApplied = Struct.new(:count, keyword_init: true)
11
12
  ToolCall = Struct.new(:tool_call, keyword_init: true)
13
+ ToolUpdate = Struct.new(:tool_call, :content, :elapsed_ms, keyword_init: true)
12
14
  ToolResult = Struct.new(:tool_call, :content, keyword_init: true)
13
15
  Answer = Struct.new(:content, keyword_init: true)
14
16
  end
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require_relative "path_guard"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -11,7 +12,7 @@ module Kward
11
12
  resolved = File.expand_path(explicit, workspace_root)
12
13
  allowed_roots = [workspace_root, session_dir].compact.map { |root| Pathname.new(root).expand_path }
13
14
  expanded = Pathname.new(resolved).expand_path
14
- unless allowed_roots.any? { |root| inside?(expanded, root) }
15
+ unless allowed_roots.any? { |root| PathGuard.inside?(expanded, root) }
15
16
  raise ArgumentError, "export path outside workspace or session directory: #{path}"
16
17
  end
17
18
 
@@ -21,10 +22,5 @@ module Kward
21
22
 
22
23
  resolved
23
24
  end
24
-
25
- def self.inside?(path, root)
26
- path.to_s == root.to_s || path.to_s.start_with?("#{root}/")
27
- end
28
- private_class_method :inside?
29
25
  end
30
26
  end
@@ -0,0 +1,43 @@
1
+ require "yaml"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Markdown frontmatter parsing shared by prompt templates and skills.
6
+ module Frontmatter
7
+ module_function
8
+
9
+ def markdown_parts(path, lenient: false)
10
+ content = File.read(path)
11
+ return [{}, content] unless content.start_with?("---\n", "---\r\n")
12
+
13
+ _opening, rest = content.split(/\A---\r?\n/, 2)
14
+ yaml_text, body = rest.to_s.split(/\r?\n---\r?\n/, 2)
15
+ raise "missing frontmatter closing delimiter" if body.nil?
16
+
17
+ data = yaml_text.to_s.empty? ? {} : load_frontmatter(yaml_text, lenient: lenient)
18
+ frontmatter = data.is_a?(Hash) ? data.transform_keys(&:to_s) : {}
19
+ [frontmatter, body]
20
+ end
21
+
22
+ def load_frontmatter(yaml_text, lenient: false)
23
+ YAML.safe_load(yaml_text, permitted_classes: [], aliases: false)
24
+ rescue Psych::SyntaxError
25
+ raise unless lenient
26
+
27
+ lenient_frontmatter(yaml_text)
28
+ end
29
+
30
+ def lenient_frontmatter(yaml_text)
31
+ yaml_text.each_line.each_with_object({}) do |line, result|
32
+ match = line.chomp.match(/\A([A-Za-z0-9_-]+):\s*(.*)\z/)
33
+ next unless match
34
+
35
+ key = match[1]
36
+ value = match[2].strip
37
+ next if value.empty?
38
+
39
+ result[key] = value.delete_prefix('"').delete_suffix('"')
40
+ end
41
+ end
42
+ end
43
+ end