kward 0.80.0 → 0.80.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9128f608c2a864ad996598d285d4042758ea1bc1b3a76a1224984ef23129544
4
- data.tar.gz: c9d718290398f95e1420e5e12225e43aaba6bf6c64c868fb6d175d5725aaf19d
3
+ metadata.gz: dceefb01256daa642d6fe1d06d2e332cac1154c93072524b4aa5e98779a26807
4
+ data.tar.gz: cc8b3c0f226c0b58ea5704a613f84a39d93c5da3ae4d4b9769c891aad2d704d0
5
5
  SHA512:
6
- metadata.gz: 6b0e53fddfa2de055b11b6bbe0965496e2abc860162ed0888642bb353ae64e0e0fdbfbe14ed7412198c8c60583c1d55fd6d9cd7fec7a43eb37db456a494f5a1a
7
- data.tar.gz: 2396aceedf67453422924616d92031160d3eee67ae448347d2f367a05d48a97d9ae39789b2100c3c47bdbc5925c85e4922146a50c72ee662d863f8ad597dacbd
6
+ metadata.gz: 4e433643946a04c43fc833edb1a7a550d4333a4184e5c68e5a54b8ab8f24317821587176fc85337a5682fee8caa87cc0f404cf2717490338fc3d9f4988c6e3c4
7
+ data.tar.gz: 3ea451ab6978d7e726270de4d0e6bc14e1c5a5b6792bb00de551cc137d0e1875579da03437eb2687d8e1d8d93e099ba87a1bfd42fdd18070ddf215fc7ad88ed7
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to Kward will be documented in this file.
4
4
 
5
+ ## [0.80.1] - 2026-07-27
6
+
7
+ ### Fixed
8
+
9
+ - Avoided repeated full-buffer syntax scans while rendering generic-language editor lines, keeping Python and other supported languages responsive during cursor movement.
10
+ - Routed interactive configuration, plugin, skill, compaction, memory, and logging warnings through synchronized `Runtime>` output so diagnostics cannot corrupt the terminal layout.
11
+
5
12
  ## [0.80.0] - 2026-07-27
6
13
 
7
14
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kward (0.80.0)
4
+ kward (0.80.1)
5
5
  base64
6
6
  nokogiri
7
7
  tiktoken_ruby
@@ -146,7 +146,7 @@ CHECKSUMS
146
146
  html-proofer (5.2.1) sha256=fdd958a7cbf9c3255fb96fe7cfc4e611f64e2706e469488a3326309ad007d2fd
147
147
  io-event (1.16.2) sha256=9f9cb0a96ea5c3850a672606c65f27bc96d7621399ef6196acbfe2be0cd1279c
148
148
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
149
- kward (0.80.0)
149
+ kward (0.80.1)
150
150
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
151
151
  metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072
152
152
  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
data/doc/skills.md CHANGED
@@ -92,6 +92,8 @@ Or set it manually:
92
92
  }
93
93
  ```
94
94
 
95
+ In the interactive TUI, skipped or malformed skill diagnostics are shown as synchronized `Runtime>` messages so they do not interrupt screen rendering. Other interactive warnings use the same channel; non-interactive commands continue to report diagnostics on stderr.
96
+
95
97
  ## How Kward uses skills
96
98
 
97
99
  Kward uses progressive loading:
data/lib/kward/agent.rb CHANGED
@@ -28,11 +28,12 @@ module Kward
28
28
  # lowest layer that owns the behavior, and use `Agent` only for cross-step turn
29
29
  # coordination.
30
30
  class Agent
31
- def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new, hook_manager: nil, hook_context: nil)
31
+ def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: nil, warning_sink: nil, hook_manager: nil, hook_context: nil)
32
32
  @client = client
33
33
  @tool_registry = tool_registry
34
34
  @conversation = conversation
35
- @telemetry_logger = telemetry_logger
35
+ @warning_sink = warning_sink
36
+ @telemetry_logger = telemetry_logger || TelemetryLogger.new(warning_sink: warning_sink)
36
37
  @hook_manager = hook_manager
37
38
  @hook_context = hook_context
38
39
  end
@@ -191,9 +192,9 @@ module Kward
191
192
 
192
193
  def auto_compact_if_needed
193
194
  context_window = @client.current_context_window if @client.respond_to?(:current_context_window)
194
- Compactor.new(conversation: @conversation, client: @client).auto_compact_if_needed(context_window: context_window)
195
+ Compactor.new(conversation: @conversation, client: @client, warning_sink: @warning_sink).auto_compact_if_needed(context_window: context_window)
195
196
  rescue StandardError => e
196
- warn "Auto-compaction failed: #{e.message}"
197
+ emit_warning "Auto-compaction failed: #{e.message}"
197
198
  nil
198
199
  end
199
200
 
@@ -201,14 +202,18 @@ module Kward
201
202
  settings = Compaction::Settings.from_config
202
203
  return nil unless settings.enabled
203
204
 
204
- Compactor.new(conversation: @conversation, client: @client, settings: settings).compact(
205
+ Compactor.new(conversation: @conversation, client: @client, settings: settings, warning_sink: @warning_sink).compact(
205
206
  custom_instructions: "The previous model request exceeded the context window. Preserve the current task state and critical details needed to retry."
206
207
  )
207
208
  rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted, StandardError => compaction_error
208
- warn "Context overflow recovery failed: #{compaction_error.message}; original error: #{error.message}"
209
+ emit_warning "Context overflow recovery failed: #{compaction_error.message}; original error: #{error.message}"
209
210
  nil
210
211
  end
211
212
 
213
+ def emit_warning(message)
214
+ @warning_sink ? @warning_sink.call(message) : warn(message)
215
+ end
216
+
212
217
  def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
213
218
  reasoning_delta = lambda do |delta|
214
219
  cancellation&.raise_if_cancelled!
@@ -104,7 +104,7 @@ module Kward
104
104
  def prepare_memory_context(conversation, input)
105
105
  Memory::TurnContext.apply(conversation: conversation, input: input)
106
106
  rescue StandardError => e
107
- warn "Memory retrieval failed: #{e.message}"
107
+ emit_warning "Memory retrieval failed: #{e.message}"
108
108
  nil
109
109
  end
110
110
 
@@ -120,7 +120,7 @@ module Kward
120
120
 
121
121
  summarize_memory(conversation, manager: manager)
122
122
  rescue StandardError => e
123
- warn "Memory auto-summary failed: #{e.message}"
123
+ emit_warning "Memory auto-summary failed: #{e.message}"
124
124
  nil
125
125
  end
126
126
 
@@ -15,6 +15,8 @@ module Kward
15
15
  prompt_interface = load_prompt_interface
16
16
  return unless prompt_interface
17
17
 
18
+ @interactive_warning_sink_active = true
19
+ ConfigFiles.warning_sink = interactive_warning_sink
18
20
  @prompt = prompt_interface.new(
19
21
  slash_commands: slash_command_entries,
20
22
  overlay_settings: ConfigFiles.overlay_settings,
@@ -48,6 +50,28 @@ module Kward
48
50
  else
49
51
  @prompt.start
50
52
  end
53
+ flush_interactive_warnings
54
+ end
55
+
56
+ def interactive_warning_sink
57
+ @interactive_warning_sink ||= lambda do |message|
58
+ unless @interactive_warning_sink_active
59
+ warn message
60
+ next
61
+ end
62
+
63
+ if prompt_interface?
64
+ runtime_output(message)
65
+ else
66
+ (@pending_interactive_warnings ||= []) << message
67
+ end
68
+ end
69
+ end
70
+
71
+ def flush_interactive_warnings
72
+ warnings = @pending_interactive_warnings
73
+ @pending_interactive_warnings = []
74
+ Array(warnings).each { |message| runtime_output(message) }
51
75
  end
52
76
 
53
77
  def load_prompt_interface
@@ -68,6 +92,18 @@ module Kward
68
92
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
69
93
  end
70
94
 
95
+ def emit_warning(message)
96
+ sink = ConfigFiles.warning_sink
97
+ sink ? sink.call(message) : warn(message)
98
+ end
99
+
100
+ def clear_interactive_warning_sink
101
+ ConfigFiles.warning_sink = nil
102
+ @interactive_warning_sink_active = false
103
+ @pending_interactive_warnings = nil
104
+ @interactive_warning_sink = nil
105
+ end
106
+
71
107
  def update_prompt_workspace_root(root)
72
108
  return unless @prompt.respond_to?(:update_workspace_root)
73
109
 
@@ -179,7 +215,7 @@ module Kward
179
215
  context = plugin_context(current_footer_conversation, "")
180
216
  renderer.call(context).to_s
181
217
  rescue StandardError => e
182
- warn "Warning: Kward plugin footer error: #{e.message}"
218
+ emit_warning "Warning: Kward plugin footer error: #{e.message}"
183
219
  ""
184
220
  end
185
221
  end
@@ -60,6 +60,7 @@ module Kward
60
60
  client: @client,
61
61
  tool_registry: tool_registry,
62
62
  conversation: conversation,
63
+ warning_sink: ConfigFiles.warning_sink,
63
64
  hook_manager: hook_manager,
64
65
  hook_context: hook_context
65
66
  )
@@ -345,7 +345,7 @@ module Kward
345
345
  percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
346
346
  "Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
347
347
  rescue StandardError => e
348
- warn "Auto-compaction status unavailable: #{e.message}"
348
+ emit_warning "Auto-compaction status unavailable: #{e.message}"
349
349
  nil
350
350
  end
351
351
 
@@ -230,6 +230,7 @@ module Kward
230
230
  client: @client,
231
231
  tool_registry: tool_registry,
232
232
  conversation: conversation,
233
+ warning_sink: ConfigFiles.warning_sink,
233
234
  hook_manager: hook_manager,
234
235
  hook_context: hook_context
235
236
  )
data/lib/kward/cli.rb CHANGED
@@ -333,6 +333,7 @@ module Kward
333
333
  @prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
334
334
  ensure
335
335
  @prompt.close if @prompt.respond_to?(:close) && prompt_interface?
336
+ clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
336
337
  end
337
338
 
338
339
  def run_prompt_or_interactive
@@ -546,6 +547,7 @@ module Kward
546
547
  stop_tabs if respond_to?(:stop_tabs, true)
547
548
  @prompt.close if prompt_interface?
548
549
  ensure
550
+ clear_interactive_warning_sink if respond_to?(:clear_interactive_warning_sink, true)
549
551
  cleanup_unused_sessions
550
552
  remember_active_session(session_store)
551
553
  end
@@ -734,9 +734,10 @@ module Kward
734
734
  AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
735
735
 
736
736
  # Creates an object for conversation compaction.
737
- def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
737
+ def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil, warning_sink: nil)
738
738
  @conversation = conversation
739
739
  @client = client
740
+ @warning_sink = warning_sink
740
741
  @settings = settings || Compaction::Settings.from_config
741
742
  @prompt_builder = Compaction::PromptBuilder.new(
742
743
  serializer: Compaction::ConversationSerializer.new(tool_result_summarizer: tool_result_summarizer)
@@ -791,7 +792,7 @@ module Kward
791
792
  rescue Compaction::NothingToCompact, Compaction::AlreadyCompacted
792
793
  nil
793
794
  rescue StandardError => e
794
- warn "Auto-compaction failed: #{e.message}"
795
+ emit_warning "Auto-compaction failed: #{e.message}"
795
796
  nil
796
797
  end
797
798
 
@@ -814,6 +815,10 @@ module Kward
814
815
 
815
816
  private
816
817
 
818
+ def emit_warning(message)
819
+ @warning_sink ? @warning_sink.call(message) : warn(message)
820
+ end
821
+
817
822
  def prepare
818
823
  @conversation.refresh_system_message_if_workspace_agents_changed!
819
824
  Compaction::Preparation.new(conversation: @conversation, settings: @settings).call
@@ -66,6 +66,7 @@ module Kward
66
66
  end
67
67
 
68
68
  @skip_config = false
69
+ @warning_sink = nil
69
70
 
70
71
  module_function
71
72
 
@@ -73,6 +74,14 @@ module Kward
73
74
  @skip_config = value
74
75
  end
75
76
 
77
+ def warning_sink=(sink)
78
+ @warning_sink = sink
79
+ end
80
+
81
+ def warning_sink
82
+ @warning_sink
83
+ end
84
+
76
85
  def skip_config?
77
86
  @skip_config == true
78
87
  end
@@ -791,13 +800,13 @@ module Kward
791
800
 
792
801
  size = File.size(path)
793
802
  if size > MAX_PROMPT_FILE_BYTES
794
- warn "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
803
+ emit_warning "Warning: skipping #{label} #{path}: file too large (#{size} bytes; limit is #{MAX_PROMPT_FILE_BYTES} bytes)"
795
804
  return nil
796
805
  end
797
806
 
798
807
  File.read(path)
799
808
  rescue StandardError => e
800
- warn "Warning: skipping #{label} #{path}: #{e.message}"
809
+ emit_warning "Warning: skipping #{label} #{path}: #{e.message}"
801
810
  nil
802
811
  end
803
812
 
@@ -908,8 +917,8 @@ module Kward
908
917
  # Lists configured skills discovered under the config directory.
909
918
  #
910
919
  # @return [Array<Skill>] skill metadata available to the model
911
- def skills(workspace_root: Dir.pwd)
912
- skills_registry(workspace_root: workspace_root).skills
920
+ def skills(workspace_root: Dir.pwd, warning_sink: nil)
921
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).skills
913
922
  end
914
923
 
915
924
  # @return [String] trusted user plugin directory
@@ -923,7 +932,7 @@ module Kward
923
932
  # workspace or custom `KWARD_CONFIG_PATH` directory.
924
933
  #
925
934
  # @return [Array<String>] sorted plugin file paths
926
- def plugin_paths
935
+ def plugin_paths(warning_sink: nil)
927
936
  plugins_root = plugin_dir
928
937
  return [] unless Dir.exist?(plugins_root)
929
938
 
@@ -931,7 +940,7 @@ module Kward
931
940
  paths.concat(Dir.glob(File.join(plugins_root, "*", "plugin.rb")))
932
941
  paths.sort
933
942
  rescue StandardError => e
934
- warn "Warning: skipping Kward plugins in #{plugins_root}: #{e.message}"
943
+ emit_warning("Warning: skipping Kward plugins in #{plugins_root}: #{e.message}", warning_sink: warning_sink)
935
944
  []
936
945
  end
937
946
 
@@ -952,7 +961,7 @@ module Kward
952
961
  skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
953
962
  end
954
963
 
955
- def skills_registry(workspace_root: Dir.pwd)
964
+ def skills_registry(workspace_root: Dir.pwd, warning_sink: nil)
956
965
  Skills::Registry.new(
957
966
  config_dir: config_dir,
958
967
  workspace_root: workspace_root,
@@ -960,7 +969,8 @@ module Kward
960
969
  skill_class: Skill,
961
970
  max_file_bytes: MAX_SKILL_FILE_BYTES,
962
971
  markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
963
- inside_directory: method(:inside_directory?)
972
+ inside_directory: method(:inside_directory?),
973
+ warning_sink: warning_sink || @warning_sink
964
974
  )
965
975
  end
966
976
 
@@ -968,10 +978,16 @@ module Kward
968
978
  Prompts::Templates.new(
969
979
  config_dir: config_dir,
970
980
  template_class: PromptTemplate,
971
- markdown_parser: ->(path) { Frontmatter.markdown_parts(path) }
981
+ markdown_parser: ->(path) { Frontmatter.markdown_parts(path) },
982
+ warning_sink: @warning_sink
972
983
  )
973
984
  end
974
985
 
986
+ def emit_warning(message, warning_sink: nil)
987
+ sink = warning_sink || @warning_sink
988
+ sink ? sink.call(message) : warn(message)
989
+ end
990
+
975
991
  def inside_directory?(path, base)
976
992
  PathGuard.inside?(path, base)
977
993
  end
@@ -11,13 +11,14 @@ module Kward
11
11
  class AuditLog
12
12
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
13
13
 
14
- def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
14
+ def initialize(path: nil, config_path: ConfigFiles.config_path, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
15
15
  @path = path
16
16
  @config_path = config_path
17
17
  @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
18
18
  @clock = clock
19
19
  @monotonic_clock = monotonic_clock
20
20
  @error_output = error_output
21
+ @warning_sink = warning_sink
21
22
  @mutex = Mutex.new
22
23
  @warned = false
23
24
  end
@@ -112,7 +113,9 @@ module Kward
112
113
  return if @warned
113
114
 
114
115
  @warned = true
115
- @error_output&.puts("Warning: hook audit logging failed: #{error.message}")
116
+ message = "Warning: hook audit logging failed: #{error.message}"
117
+ sink = @warning_sink || ConfigFiles.warning_sink
118
+ sink ? sink.call(message) : @error_output&.puts(message)
116
119
  rescue StandardError
117
120
  nil
118
121
  end
@@ -313,16 +313,19 @@ module Kward
313
313
  class << self
314
314
  attr_accessor :loading_registry, :loading_path
315
315
 
316
- def load(paths: ConfigFiles.plugin_paths, reserved_commands: [])
317
- registry = new(reserved_commands: reserved_commands)
316
+ def load(paths: nil, reserved_commands: [], warning_sink: nil)
317
+ warning_sink ||= ConfigFiles.warning_sink
318
+ paths ||= ConfigFiles.plugin_paths(warning_sink: warning_sink)
319
+ registry = new(reserved_commands: reserved_commands, warning_sink: warning_sink)
318
320
  paths.each { |path| registry.load_file(path) }
319
321
  registry
320
322
  end
321
323
  end
322
324
 
323
325
  # Creates an object for trusted plugin loading and dispatch.
324
- def initialize(reserved_commands: [])
326
+ def initialize(reserved_commands: [], warning_sink: nil)
325
327
  @reserved_commands = reserved_commands.map(&:to_s)
328
+ @warning_sink = warning_sink
326
329
  @commands = {}
327
330
  @interactive_commands = {}
328
331
  @tab_types = {}
@@ -419,7 +422,7 @@ module Kward
419
422
  rendered = entry[:renderer].call(context)
420
423
  parts << rendered.to_s unless rendered.to_s.empty?
421
424
  rescue StandardError => e
422
- warn "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
425
+ emit_warning "Warning: Kward plugin prompt context error in #{entry[:path]}: #{e.message}"
423
426
  end
424
427
  parts.empty? ? nil : parts.join("\n\n")
425
428
  end
@@ -431,7 +434,7 @@ module Kward
431
434
  @transcript_event_handlers.each do |entry|
432
435
  entry[:handler].call(transcript_event, context)
433
436
  rescue StandardError => e
434
- warn "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
437
+ emit_warning "Warning: Kward plugin transcript event error in #{entry[:path]}: #{e.message}"
435
438
  end
436
439
  nil
437
440
  end
@@ -444,7 +447,7 @@ module Kward
444
447
  Kernel.load(path, true)
445
448
  @paths << path
446
449
  rescue StandardError => e
447
- warn "Warning: skipping Kward plugin #{path}: #{e.message}"
450
+ emit_warning "Warning: skipping Kward plugin #{path}: #{e.message}"
448
451
  ensure
449
452
  self.class.loading_registry = previous_registry
450
453
  self.class.loading_path = previous_path
@@ -462,11 +465,11 @@ module Kward
462
465
  raise "Plugin command /#{name} requires a handler" unless handler
463
466
 
464
467
  if @reserved_commands.include?(name)
465
- warn "Warning: skipping Kward plugin command /#{name}: reserved command"
468
+ emit_warning "Warning: skipping Kward plugin command /#{name}: reserved command"
466
469
  return nil
467
470
  end
468
471
  if @commands.key?(name)
469
- warn "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
472
+ emit_warning "Warning: skipping duplicate Kward plugin command /#{name}: #{path}"
470
473
  return nil
471
474
  end
472
475
 
@@ -485,11 +488,11 @@ module Kward
485
488
  raise "Interactive command /#{name} requires a handler" unless handler
486
489
 
487
490
  if @reserved_commands.include?(name) || @commands.key?(name)
488
- warn "Warning: skipping Kward interactive command /#{name}: reserved command"
491
+ emit_warning "Warning: skipping Kward interactive command /#{name}: reserved command"
489
492
  return nil
490
493
  end
491
494
  if @interactive_commands.key?(name)
492
- warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
495
+ emit_warning "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
493
496
  return nil
494
497
  end
495
498
 
@@ -512,7 +515,7 @@ module Kward
512
515
  raise "Plugin tab type #{name} requires a handler" unless handler
513
516
 
514
517
  if @tab_types.key?(name) || @tab_types_by_id.key?(id)
515
- warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
518
+ emit_warning "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
516
519
  return nil
517
520
  end
518
521
 
@@ -529,7 +532,7 @@ module Kward
529
532
  raise "Plugin transport #{name} requires a handler" unless handler
530
533
 
531
534
  if @transports.key?(name) || @transports_by_id.key?(id)
532
- warn "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
535
+ emit_warning "Warning: skipping duplicate Kward plugin transport #{id}: #{path}"
533
536
  return nil
534
537
  end
535
538
 
@@ -543,11 +546,15 @@ module Kward
543
546
  def register_footer(path: nil, &renderer)
544
547
  raise "Plugin footer requires a renderer" unless renderer
545
548
 
546
- warn "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
549
+ emit_warning "Warning: replacing Kward plugin footer from #{@footer_path}: #{path}" if @footer
547
550
  @footer = renderer
548
551
  @footer_path = path
549
552
  end
550
553
 
554
+ def emit_warning(message)
555
+ @warning_sink ? @warning_sink.call(message) : warn(message)
556
+ end
557
+
551
558
  def register_transcript_event(path: nil, &handler)
552
559
  raise "Plugin transcript event requires a handler" unless handler
553
560
 
@@ -23,10 +23,12 @@ module Kward
23
23
  LANGUAGE_DEFINITIONS = {
24
24
  javascript: {
25
25
  extensions: %w[.js .jsx .mjs .cjs],
26
+ block_comment: true,
26
27
  keywords: %w[async await break case catch class const continue debugger default delete do else export extends false finally for from function if import in instanceof let new null of return static super switch this throw true try typeof undefined var void while with yield]
27
28
  },
28
29
  typescript: {
29
30
  extensions: %w[.ts .tsx],
31
+ block_comment: true,
30
32
  keywords: %w[abstract any as async await boolean break case catch class const constructor continue debugger declare default delete do else enum export extends false finally for from function if implements import in infer instanceof interface is keyof let module namespace never new null number object of private protected public readonly return static string super switch symbol this throw true try type typeof undefined unknown var void while with yield]
31
33
  },
32
34
  shell: {
@@ -62,34 +64,42 @@ module Kward
62
64
  },
63
65
  go: {
64
66
  extensions: %w[.go],
67
+ block_comment: true,
65
68
  keywords: %w[break case chan const continue default defer else fallthrough false for func go goto if import interface map nil package range return select struct switch true type var]
66
69
  },
67
70
  rust: {
68
71
  extensions: %w[.rs],
72
+ block_comment: true,
69
73
  keywords: %w[as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while]
70
74
  },
71
75
  java: {
72
76
  extensions: %w[.java],
77
+ block_comment: true,
73
78
  keywords: %w[abstract assert boolean break byte case catch char class const continue default do double else enum extends false final finally float for if implements import instanceof int interface long native new null package private protected public return short static strictfp super switch synchronized this throw throws transient true try void volatile while]
74
79
  },
75
80
  csharp: {
76
81
  extensions: %w[.cs],
82
+ block_comment: true,
77
83
  keywords: %w[abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var async await]
78
84
  },
79
85
  c: {
80
86
  extensions: %w[.c .h],
87
+ block_comment: true,
81
88
  keywords: %w[auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while]
82
89
  },
83
90
  cpp: {
84
91
  extensions: %w[.cc .cpp .cxx .hpp .hh .hxx],
92
+ block_comment: true,
85
93
  keywords: %w[alignas alignof and asm auto bool break case catch char char16_t char32_t class const constexpr const_cast continue decltype default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept nullptr operator or private protected public register reinterpret_cast return short signed sizeof static static_assert static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while]
86
94
  },
87
95
  swift: {
88
96
  extensions: %w[.swift],
97
+ block_comment: true,
89
98
  keywords: %w[as associatedtype break case catch class continue default defer deinit do else enum extension false fileprivate for func guard if import in init inout internal is let nil open operator private protocol public repeat rethrows return self Self static struct subscript super switch throw throws true try typealias var where while]
90
99
  },
91
100
  kotlin: {
92
101
  extensions: %w[.kt .kts],
102
+ block_comment: true,
93
103
  keywords: %w[as break class continue do else false for fun if in interface is null object package return super this throw true try typealias typeof val var when while by catch constructor delegate dynamic field file finally get import init param property receiver set setparam where actual abstract annotation companion const crossinline data enum expect external final infix inline inner internal lateinit noinline open operator out override private protected public reified sealed suspend tailrec vararg]
94
104
  },
95
105
  lua: {
@@ -296,8 +306,7 @@ module Kward
296
306
  return line.to_s unless definition
297
307
 
298
308
  text = line.to_s
299
- return colored(text, :gray) if editor_c_style_block_comment_line?(line_index)
300
-
309
+ return colored(text, :gray) if definition[:block_comment] && editor_c_style_block_comment_line?(line_index)
301
310
  marker = definition[:line_comment] || "//"
302
311
  comment_index = editor_comment_index(text, marker)
303
312
  return editor_highlight_generic_code(text, definition[:keywords]) unless comment_index
@@ -313,7 +322,8 @@ module Kward
313
322
  end
314
323
 
315
324
  def editor_generic_pattern(keywords)
316
- /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(keywords)})\b)/
325
+ @editor_generic_patterns ||= {}
326
+ @editor_generic_patterns[keywords] ||= /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b\d+(?:\.\d+)?\b|\b[A-Z]\w*\b|\b(?:#{Regexp.union(keywords)})\b)/
317
327
  end
318
328
 
319
329
  def editor_highlight_generic_token(token, keywords)
@@ -333,16 +343,31 @@ module Kward
333
343
  def editor_c_style_block_comment_line?(line_index)
334
344
  return false unless line_index && @editor_state
335
345
 
336
- in_comment = false
337
- @editor_state.lines.first(line_index.to_i + 1).each_with_index do |line, index|
346
+ lines = @editor_state.lines
347
+ unless @editor_block_comment_lines.equal?(lines)
348
+ @editor_block_comment_lines = lines
349
+ @editor_block_comment_states = []
350
+ @editor_block_comment_in_comment = false
351
+ end
352
+
353
+ target = line_index.to_i
354
+ return false if target.negative?
355
+ return @editor_block_comment_states[target] if target < @editor_block_comment_states.length
356
+
357
+ (@editor_block_comment_states.length..target).each do |index|
358
+ line = lines[index].to_s
338
359
  starts_block = editor_comment_index(line, "/*")
339
- ends_block = in_comment && line.include?("*/")
340
- return true if index == line_index && (in_comment || starts_block)
360
+ ends_block = @editor_block_comment_in_comment && line.include?("*/")
361
+ @editor_block_comment_states << (@editor_block_comment_in_comment || !starts_block.nil?)
341
362
 
342
- in_comment = true if starts_block && !line[starts_block..].to_s.include?("*/")
343
- in_comment = false if ends_block
363
+ if starts_block && !line[starts_block..].to_s.include?("*/")
364
+ @editor_block_comment_in_comment = true
365
+ elsif ends_block
366
+ @editor_block_comment_in_comment = false
367
+ end
344
368
  end
345
- false
369
+
370
+ @editor_block_comment_states[target]
346
371
  end
347
372
 
348
373
  def editor_comment_index(line, marker)
@@ -4,10 +4,11 @@ module Kward
4
4
  module Prompts
5
5
  # Parsed prompt template loaded from disk.
6
6
  class Templates
7
- def initialize(config_dir:, template_class:, markdown_parser:)
7
+ def initialize(config_dir:, template_class:, markdown_parser:, warning_sink: nil)
8
8
  @config_dir = config_dir
9
9
  @template_class = template_class
10
10
  @markdown_parser = markdown_parser
11
+ @warning_sink = warning_sink
11
12
  end
12
13
 
13
14
  def prompt_templates(reserved_commands: [])
@@ -21,11 +22,11 @@ module Kward
21
22
  next unless template
22
23
 
23
24
  if reserved.include?(template.command)
24
- warn "Warning: skipping Kward prompt command /#{template.command}: reserved command"
25
+ emit_warning "Warning: skipping Kward prompt command /#{template.command}: reserved command"
25
26
  next
26
27
  end
27
28
  if seen[template.command]
28
- warn "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
29
+ emit_warning "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
29
30
  next
30
31
  end
31
32
 
@@ -33,7 +34,7 @@ module Kward
33
34
  template
34
35
  end
35
36
  rescue StandardError => e
36
- warn "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
37
+ emit_warning "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
37
38
  []
38
39
  end
39
40
 
@@ -42,7 +43,7 @@ module Kward
42
43
  def parse_prompt_template(path)
43
44
  command = File.basename(path, ".md")
44
45
  unless command.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
45
- warn "Warning: skipping Kward prompt template #{path}: invalid command name"
46
+ emit_warning "Warning: skipping Kward prompt template #{path}: invalid command name"
46
47
  return nil
47
48
  end
48
49
 
@@ -55,9 +56,13 @@ module Kward
55
56
  path: path
56
57
  )
57
58
  rescue StandardError => e
58
- warn "Warning: skipping Kward prompt template #{path}: #{e.message}"
59
+ emit_warning "Warning: skipping Kward prompt template #{path}: #{e.message}"
59
60
  nil
60
61
  end
62
+
63
+ def emit_warning(message)
64
+ @warning_sink ? @warning_sink.call(message) : warn(message)
65
+ end
61
66
  end
62
67
  end
63
68
  end
@@ -14,7 +14,7 @@ module Kward
14
14
  class Registry
15
15
  SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
16
16
 
17
- def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:)
17
+ def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil)
18
18
  @config_dir = config_dir
19
19
  @workspace_root = workspace_root
20
20
  @project_skills_trusted = project_skills_trusted
@@ -22,6 +22,7 @@ module Kward
22
22
  @max_file_bytes = max_file_bytes
23
23
  @markdown_parser = markdown_parser
24
24
  @inside_directory = inside_directory
25
+ @warning_sink = warning_sink
25
26
  end
26
27
 
27
28
  # Returns discovered, validated skills in precedence order.
@@ -36,7 +37,7 @@ module Kward
36
37
  next unless skill
37
38
 
38
39
  if seen[skill.name]
39
- warn "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
40
+ emit_warning "Warning: skipping duplicate Kward skill #{skill.name.inspect}: #{path}"
40
41
  next
41
42
  end
42
43
 
@@ -45,7 +46,7 @@ module Kward
45
46
  end
46
47
  end
47
48
  rescue StandardError => e
48
- warn "Warning: skipping Kward skills: #{e.message}"
49
+ emit_warning "Warning: skipping Kward skills: #{e.message}"
49
50
  []
50
51
  end
51
52
 
@@ -99,13 +100,13 @@ module Kward
99
100
  def scan_source(source)
100
101
  return [] unless Dir.exist?(source.root)
101
102
  if source.scope == :project && !@project_skills_trusted
102
- warn "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
103
+ emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
103
104
  return []
104
105
  end
105
106
 
106
107
  Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
107
108
  rescue StandardError => e
108
- warn "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
109
+ emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
109
110
  []
110
111
  end
111
112
 
@@ -151,12 +152,12 @@ module Kward
151
152
  return warn_skip(path, "missing description") if description.empty?
152
153
  return warn_skip(path, "description exceeds 1024 characters") if description.length > 1024
153
154
 
154
- warn "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
155
- warn "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
156
- warn "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
155
+ emit_warning "Warning: Kward skill #{path}: name does not match parent directory" if name != File.basename(File.dirname(path))
156
+ emit_warning "Warning: Kward skill #{path}: name exceeds 64 characters" if name.length > 64
157
+ emit_warning "Warning: Kward skill #{path}: name contains invalid characters" unless valid_name?(name)
157
158
 
158
159
  compatibility = optional_text(frontmatter["compatibility"])
159
- warn "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
160
+ emit_warning "Warning: Kward skill #{path}: compatibility exceeds 500 characters" if compatibility && compatibility.length > 500
160
161
 
161
162
  @skill_class.new(
162
163
  name: name,
@@ -169,7 +170,7 @@ module Kward
169
170
  allowed_tools: optional_text(frontmatter["allowed-tools"])
170
171
  )
171
172
  rescue StandardError => e
172
- warn "Warning: skipping Kward skill #{path}: #{e.message}"
173
+ emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
173
174
  nil
174
175
  end
175
176
 
@@ -183,9 +184,13 @@ module Kward
183
184
  end
184
185
 
185
186
  def warn_skip(path, reason)
186
- warn "Warning: skipping Kward skill #{path}: #{reason}"
187
+ emit_warning "Warning: skipping Kward skill #{path}: #{reason}"
187
188
  nil
188
189
  end
190
+
191
+ def emit_warning(message)
192
+ @warning_sink ? @warning_sink.call(message) : warn(message)
193
+ end
189
194
  end
190
195
  end
191
196
  end
@@ -20,13 +20,14 @@ module Kward
20
20
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
21
21
 
22
22
  # Creates an object for telemetry event logging.
23
- def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr)
23
+ def initialize(config_path: ConfigFiles.config_path, log_dir: nil, max_bytes: DEFAULT_MAX_BYTES, clock: Time, monotonic_clock: Process, error_output: $stderr, warning_sink: nil)
24
24
  @config_path = config_path
25
25
  @log_dir = log_dir
26
26
  @max_bytes = max_bytes.to_i.positive? ? max_bytes.to_i : DEFAULT_MAX_BYTES
27
27
  @clock = clock
28
28
  @monotonic_clock = monotonic_clock
29
29
  @error_output = error_output
30
+ @warning_sink = warning_sink
30
31
  @mutex = Mutex.new
31
32
  @warned = false
32
33
  end
@@ -192,7 +193,9 @@ module Kward
192
193
  return if @warned
193
194
 
194
195
  @warned = true
195
- @error_output&.puts("Warning: telemetry logging failed: #{error.message}")
196
+ message = "Warning: telemetry logging failed: #{error.message}"
197
+ sink = @warning_sink || ConfigFiles.warning_sink
198
+ sink ? sink.call(message) : @error_output&.puts(message)
196
199
  rescue StandardError
197
200
  nil
198
201
  end
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.80.0"
4
+ VERSION = "0.80.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.80.0
4
+ version: 0.80.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood