kward 0.74.0 → 0.75.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +56 -3
  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 +1 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +108 -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 +72 -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/memory_commands.rb +1 -5
  27. data/lib/kward/cli/plugins.rb +41 -1
  28. data/lib/kward/cli/prompt_interface.rb +32 -3
  29. data/lib/kward/cli/rendering.rb +1 -1
  30. data/lib/kward/cli/runtime_helpers.rb +44 -5
  31. data/lib/kward/cli/sessions.rb +13 -0
  32. data/lib/kward/cli/settings.rb +226 -152
  33. data/lib/kward/cli/slash_commands.rb +81 -42
  34. data/lib/kward/cli/tabs.rb +26 -6
  35. data/lib/kward/cli.rb +26 -3
  36. data/lib/kward/cli_transcript_formatter.rb +0 -7
  37. data/lib/kward/compaction/token_estimator.rb +112 -0
  38. data/lib/kward/compactor.rb +42 -116
  39. data/lib/kward/config_files.rb +117 -25
  40. data/lib/kward/deep_copy.rb +42 -0
  41. data/lib/kward/diff_view_mode.rb +36 -0
  42. data/lib/kward/events.rb +1 -0
  43. data/lib/kward/export_path.rb +2 -6
  44. data/lib/kward/frontmatter.rb +43 -0
  45. data/lib/kward/hooks/audit_log.rb +121 -0
  46. data/lib/kward/hooks/catalog.rb +110 -0
  47. data/lib/kward/hooks/command_handler.rb +66 -0
  48. data/lib/kward/hooks/config_loader.rb +104 -0
  49. data/lib/kward/hooks/decision.rb +102 -0
  50. data/lib/kward/hooks/event.rb +60 -0
  51. data/lib/kward/hooks/http_handler.rb +72 -0
  52. data/lib/kward/hooks/manager.rb +158 -0
  53. data/lib/kward/hooks/matcher.rb +75 -0
  54. data/lib/kward/hooks.rb +16 -0
  55. data/lib/kward/markdown_transcript.rb +4 -3
  56. data/lib/kward/mcp/client.rb +1 -1
  57. data/lib/kward/mcp/server_config.rb +7 -3
  58. data/lib/kward/mcp/stdio_transport.rb +7 -1
  59. data/lib/kward/memory/turn_context.rb +19 -0
  60. data/lib/kward/message_access.rb +2 -2
  61. data/lib/kward/message_text.rb +19 -0
  62. data/lib/kward/model/client.rb +13 -45
  63. data/lib/kward/model/context_usage.rb +10 -4
  64. data/lib/kward/model/copilot_models.rb +44 -0
  65. data/lib/kward/model/model_info.rb +24 -0
  66. data/lib/kward/model/payloads.rb +15 -17
  67. data/lib/kward/model/stream_parser.rb +178 -13
  68. data/lib/kward/pan/server.rb +51 -2
  69. data/lib/kward/path_guard.rb +13 -0
  70. data/lib/kward/plugin_registry.rb +86 -25
  71. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  72. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  73. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  74. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  75. data/lib/kward/prompt_interface.rb +4 -1
  76. data/lib/kward/prompts/commands.rb +3 -3
  77. data/lib/kward/prompts.rb +7 -7
  78. data/lib/kward/rpc/mcp_status.rb +50 -0
  79. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  80. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  81. data/lib/kward/rpc/server.rb +94 -14
  82. data/lib/kward/rpc/session_manager.rb +258 -33
  83. data/lib/kward/rpc/session_metrics.rb +1 -1
  84. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  85. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  86. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  87. data/lib/kward/rpc/turn_context.rb +109 -0
  88. data/lib/kward/session_store.rb +6 -20
  89. data/lib/kward/session_tree_nodes.rb +98 -18
  90. data/lib/kward/session_tree_renderer.rb +6 -61
  91. data/lib/kward/skills/registry.rb +109 -17
  92. data/lib/kward/tools/mcp_tool.rb +4 -0
  93. data/lib/kward/tools/read_skill.rb +9 -4
  94. data/lib/kward/tools/registry.rb +266 -8
  95. data/lib/kward/tools/search/code.rb +4 -22
  96. data/lib/kward/tools/search/web.rb +28 -2
  97. data/lib/kward/update_check.rb +117 -0
  98. data/lib/kward/version.rb +1 -1
  99. data/lib/kward/workers/manager.rb +43 -3
  100. data/lib/kward/workspace.rb +3 -1
  101. data/templates/default/fulldoc/html/css/kward.css +22 -0
  102. data/templates/default/fulldoc/html/js/kward.js +2 -0
  103. data/templates/default/kward_navigation.rb +4 -2
  104. data/templates/default/layout/html/setup.rb +2 -0
  105. metadata +25 -1
@@ -11,13 +11,17 @@ module Kward
11
11
  module_function
12
12
 
13
13
  def clients_from_config(config)
14
- servers = ConfigFiles.mcp_servers(config)
15
-
16
- servers.filter_map do |name, settings|
14
+ configured_servers(config).filter_map do |name, settings|
17
15
  client_from_settings(name, settings)
18
16
  end
19
17
  end
20
18
 
19
+ def configured_servers(config)
20
+ ConfigFiles.mcp_servers(config).select do |_name, settings|
21
+ settings.is_a?(Hash) && settings["enabled"] != false
22
+ end
23
+ end
24
+
21
25
  def client_from_settings(name, settings)
22
26
  return nil unless settings.is_a?(Hash)
23
27
  return nil if settings["enabled"] == false
@@ -10,6 +10,8 @@ module Kward
10
10
  class StdioTransport
11
11
  DEFAULT_TIMEOUT_SECONDS = 10
12
12
 
13
+ attr_reader :command
14
+
13
15
  def initialize(command:, args: [], env: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS)
14
16
  @command = command.to_s
15
17
  @args = Array(args).map(&:to_s)
@@ -62,7 +64,11 @@ module Kward
62
64
  raise ArgumentError, "MCP server command is required" if @command.empty?
63
65
 
64
66
  @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
65
- @stderr_thread = Thread.new { @stderr.each_line { |_line| } }
67
+ @stderr_thread = Thread.new do
68
+ @stderr.each_line { |_line| }
69
+ rescue IOError
70
+ nil
71
+ end
66
72
  @started = true
67
73
  rescue SystemCallError => e
68
74
  raise "Failed to start MCP server #{@command}: #{e.message}"
@@ -0,0 +1,19 @@
1
+ require_relative "manager"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ module Memory
6
+ # Applies retrieved memory context to a conversation before a model turn.
7
+ module TurnContext
8
+ module_function
9
+
10
+ def apply(conversation:, input:, manager: Manager.new)
11
+ retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
12
+ conversation.last_memory_retrieval = retrieval
13
+ conversation.memory_context = manager.memory_block(retrieval)
14
+ conversation.refresh_system_message!
15
+ retrieval
16
+ end
17
+ end
18
+ end
19
+ end
@@ -5,8 +5,8 @@ module Kward
5
5
  # Kward stores transcript entries as plain hashes because model payloads,
6
6
  # JSONL sessions, plugins, and RPC normalizers all need to pass them around
7
7
  # without framework objects. Restored sessions may contain either symbol keys,
8
- # string keys, or Tauren-style camelCase aliases. `MessageAccess` centralizes
9
- # those lookup rules so callers do not grow one-off compatibility branches.
8
+ # string keys, or camelCase aliases. `MessageAccess` centralizes those lookup
9
+ # rules so callers do not grow one-off compatibility branches.
10
10
  module MessageAccess
11
11
  module_function
12
12
 
@@ -28,6 +28,20 @@ module Kward
28
28
  content_text(MessageAccess.content(message)).strip
29
29
  end
30
30
 
31
+ # Returns compact one-line text for session tree and list previews.
32
+ #
33
+ # @param message [Hash] persisted conversation message
34
+ # @param length [Integer] maximum preview length
35
+ # @return [String] whitespace-normalized text, truncated with ellipsis
36
+ def preview(message, length: 120)
37
+ text = if MessageAccess.role(message) == "compactionSummary"
38
+ MessageAccess.summary(message).to_s
39
+ else
40
+ full_text(message)
41
+ end
42
+ truncate_preview(text, length: length)
43
+ end
44
+
31
45
  # Converts message content into plain text without applying display-content
32
46
  # overrides.
33
47
  #
@@ -41,5 +55,10 @@ module Kward
41
55
  content.to_s
42
56
  end
43
57
  end
58
+
59
+ def truncate_preview(text, length: 120)
60
+ normalized = text.to_s.gsub(/\s+/, " ").strip
61
+ normalized.length > length ? "#{normalized.slice(0, length - 3)}..." : normalized
62
+ end
44
63
  end
45
64
  end
@@ -8,6 +8,7 @@ require_relative "../cancellation"
8
8
  require_relative "../config_files"
9
9
  require_relative "../openrouter_model_cache"
10
10
  require_relative "context_overflow"
11
+ require_relative "copilot_models"
11
12
  require_relative "model_info"
12
13
  require_relative "payloads"
13
14
  require_relative "../telemetry/logger"
@@ -227,8 +228,8 @@ module Kward
227
228
  end
228
229
 
229
230
  # Projects messages/tools into the provider-specific context shape without sending it.
230
- def current_context_parts(messages, tools)
231
- build_context_parts(current_provider, messages, tools)
231
+ def current_context_parts(messages, tools, provider: current_provider, model: nil)
232
+ build_context_parts(ModelInfo.provider_label(provider), messages, tools, model: model)
232
233
  end
233
234
 
234
235
  # Returns whether the active provider can accept steering while a turn is streaming.
@@ -525,30 +526,15 @@ module Kward
525
526
  live_models = fetch_copilot_models
526
527
  return static_copilot_model_choices if live_models.empty?
527
528
 
528
- supported_copilot_model_choices(live_models)
529
+ CopilotModels.supported_choices(live_models)
529
530
  end
530
531
 
531
532
  def static_copilot_model_choices
532
- supported_copilot_model_choices(ModelInfo::COPILOT_MODEL_CHOICES)
533
- end
534
-
535
- def supported_copilot_model_choices(choices)
536
- choices.select { |model| copilot_supported_model?(model) }.uniq
533
+ CopilotModels.supported_choices(ModelInfo::COPILOT_MODEL_CHOICES)
537
534
  end
538
535
 
539
536
  def resolved_copilot_chat_model(configured_model)
540
- choices = fetch_copilot_models
541
- return configured_model if choices.empty? || choices.include?(configured_model)
542
-
543
- supported = choices.find { |model| copilot_supported_model?(model) }
544
- raise "No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5 Responses and Gemini/GPT-4.1 chat models." unless supported
545
-
546
- supported
547
- end
548
-
549
- def copilot_supported_model?(model)
550
- text = model.to_s
551
- copilot_responses_model?(text) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
537
+ CopilotModels.resolved_chat_model(configured_model, fetch_copilot_models)
552
538
  end
553
539
 
554
540
  def fetch_copilot_models
@@ -566,33 +552,11 @@ module Kward
566
552
  response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
567
553
  return [] unless response.is_a?(Net::HTTPSuccess)
568
554
 
569
- @copilot_models = parse_copilot_models(response.body)
555
+ @copilot_models = CopilotModels.parse(response.body)
570
556
  rescue StandardError
571
557
  []
572
558
  end
573
559
 
574
- def parse_copilot_models(body)
575
- model_catalog_entries(body).filter_map do |entry|
576
- copilot_model_id(entry)
577
- end.uniq
578
- rescue JSON::ParserError
579
- []
580
- end
581
-
582
- def model_catalog_entries(body)
583
- data = JSON.parse(body.to_s)
584
- entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
585
- Array(entries)
586
- end
587
-
588
- def copilot_model_id(entry)
589
- return entry.to_s.strip unless entry.is_a?(Hash)
590
- return nil if entry.key?("model_picker_enabled") && entry["model_picker_enabled"] == false
591
-
592
- id = entry["id"] || entry["model"] || entry["name"]
593
- id.to_s.strip unless id.to_s.strip.empty?
594
- end
595
-
596
560
  def copilot_responses_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
597
561
  parts = build_context_parts("CopilotResponses", messages, tools, model: model)
598
562
  payload = {
@@ -718,11 +682,11 @@ module Kward
718
682
  end
719
683
 
720
684
  def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil)
721
- ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
685
+ ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
722
686
  end
723
687
 
724
688
  def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
725
- ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
689
+ ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
726
690
  end
727
691
 
728
692
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
@@ -871,6 +835,10 @@ module Kward
871
835
  ConfigFiles.config_value(@config, *keys)
872
836
  end
873
837
 
838
+ def codex_show_raw_reasoning?
839
+ @config["codex_show_raw_reasoning"] == true
840
+ end
841
+
874
842
  def load_config
875
843
  ConfigFiles.read_config(@config_path)
876
844
  end
@@ -80,6 +80,10 @@ module Kward
80
80
 
81
81
  # Structured context usage result returned to frontends.
82
82
  class TiktokenTokenCounter
83
+ def initialize
84
+ @encodings = {}
85
+ end
86
+
83
87
  def count(text, model:)
84
88
  text = text.to_s
85
89
  tokenizer = encoding(model)
@@ -93,11 +97,13 @@ module Kward
93
97
  private
94
98
 
95
99
  def encoding(model)
96
- require "tiktoken_ruby"
100
+ @encodings[model.to_s] ||= begin
101
+ require "tiktoken_ruby"
97
102
 
98
- Tiktoken.encoding_for_model(model.to_s) || Tiktoken.get_encoding(encoding_name_for_model(model))
99
- rescue StandardError
100
- Tiktoken.get_encoding(encoding_name_for_model(model)) if defined?(Tiktoken)
103
+ Tiktoken.encoding_for_model(model.to_s) || Tiktoken.get_encoding(encoding_name_for_model(model))
104
+ rescue StandardError
105
+ Tiktoken.get_encoding(encoding_name_for_model(model)) if defined?(Tiktoken)
106
+ end
101
107
  end
102
108
 
103
109
  def rough_count(text)
@@ -0,0 +1,44 @@
1
+ require "json"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Parses and filters GitHub Copilot model catalog responses.
6
+ module CopilotModels
7
+ module_function
8
+
9
+ def parse(body)
10
+ catalog_entries(body).filter_map { |entry| model_id(entry) }.uniq
11
+ rescue JSON::ParserError
12
+ []
13
+ end
14
+
15
+ def supported?(model)
16
+ text = model.to_s
17
+ text.match?(/\Agpt-5(?:\.|-|\z)/) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
18
+ end
19
+
20
+ def supported_choices(choices)
21
+ choices.select { |model| supported?(model) }.uniq
22
+ end
23
+
24
+ def resolved_chat_model(configured_model, choices)
25
+ return configured_model if choices.empty? || choices.include?(configured_model)
26
+
27
+ choices.find { |model| supported?(model) } || raise("No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5 Responses and Gemini/GPT-4.1 chat models.")
28
+ end
29
+
30
+ def catalog_entries(body)
31
+ data = JSON.parse(body.to_s)
32
+ entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
33
+ Array(entries)
34
+ end
35
+
36
+ def model_id(entry)
37
+ return entry.to_s.strip unless entry.is_a?(Hash)
38
+ return nil if entry.key?("model_picker_enabled") && entry["model_picker_enabled"] == false
39
+
40
+ id = entry["id"] || entry["model"] || entry["name"]
41
+ id.to_s.strip unless id.to_s.strip.empty?
42
+ end
43
+ end
44
+ end
@@ -412,6 +412,30 @@ module Kward
412
412
  }.compact
413
413
  end
414
414
 
415
+ def normalize_available(models, current_provider:, current_model:, current_reasoning_effort: nil)
416
+ Array(models).map do |model|
417
+ normalize(
418
+ model,
419
+ current_provider: current_provider,
420
+ current_model: current_model,
421
+ current_reasoning_effort: current_reasoning_effort
422
+ )
423
+ end
424
+ end
425
+
426
+ def current_payload(provider:, model:, reasoning_effort: nil, context_window: nil)
427
+ normalize(
428
+ {
429
+ provider: provider,
430
+ id: model,
431
+ model: model,
432
+ reasoningEffort: reasoning_effort,
433
+ contextWindow: context_window,
434
+ current: true
435
+ }
436
+ )
437
+ end
438
+
415
439
  def boolean_value(value, default: false)
416
440
  return default if value.nil?
417
441
  return value if value == true || value == false
@@ -188,14 +188,12 @@ module Kward
188
188
  text = plain_content(content).to_s
189
189
  blocks << { type: "text", text: text } unless text.empty?
190
190
  MessageAccess.tool_calls(message).each do |tool_call|
191
- function = tool_call[:function] || tool_call["function"] || {}
192
- name = function[:name] || function["name"]
193
- arguments = function[:arguments] || function["arguments"] || "{}"
191
+ name = ToolCall.name(tool_call)
194
192
  blocks << {
195
193
  type: "tool_use",
196
- id: normalize_anthropic_tool_call_id(tool_call[:id] || tool_call["id"] || "call_#{name}"),
194
+ id: normalize_anthropic_tool_call_id(ToolCall.id(tool_call) || "call_#{name}"),
197
195
  name: claude_code_tool_name(name),
198
- input: parse_tool_arguments(arguments)
196
+ input: parse_tool_arguments(ToolCall.raw_arguments(tool_call) || "{}")
199
197
  }
200
198
  end
201
199
  output << { role: "assistant", content: blocks } unless blocks.empty?
@@ -260,11 +258,11 @@ module Kward
260
258
  end
261
259
 
262
260
  def anthropic_tool_schema(tool)
263
- function = tool[:function] || tool["function"] || {}
264
- schema = function[:parameters] || function["parameters"] || {}
261
+ function = ToolCall.function(tool)
262
+ schema = ToolCall.value(function, :parameters) || {}
265
263
  {
266
- name: claude_code_tool_name(function[:name] || function["name"]),
267
- description: function[:description] || function["description"] || "",
264
+ name: claude_code_tool_name(ToolCall.value(function, :name)),
265
+ description: ToolCall.value(function, :description) || "",
268
266
  input_schema: {
269
267
  type: "object",
270
268
  properties: schema[:properties] || schema["properties"] || {},
@@ -296,12 +294,12 @@ module Kward
296
294
  content = plain_content(content)
297
295
  input << codex_message("assistant", content.to_s) unless content.to_s.empty?
298
296
  MessageAccess.tool_calls(message).each do |tool_call|
299
- function = tool_call[:function] || tool_call["function"] || {}
297
+ name = ToolCall.name(tool_call)
300
298
  input << {
301
299
  type: "function_call",
302
- call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
303
- name: function[:name] || function["name"],
304
- arguments: function[:arguments] || function["arguments"] || "{}"
300
+ call_id: ToolCall.id(tool_call) || name || "tool-call",
301
+ name: name,
302
+ arguments: ToolCall.raw_arguments(tool_call) || "{}"
305
303
  }
306
304
  end
307
305
  else
@@ -420,12 +418,12 @@ module Kward
420
418
  end
421
419
 
422
420
  def codex_tool_schema(tool)
423
- function = tool[:function] || tool["function"] || {}
421
+ function = ToolCall.function(tool)
424
422
  {
425
423
  type: "function",
426
- name: function[:name] || function["name"],
427
- description: function[:description] || function["description"] || "",
428
- parameters: function[:parameters] || function["parameters"] || {},
424
+ name: ToolCall.value(function, :name),
425
+ description: ToolCall.value(function, :description) || "",
426
+ parameters: ToolCall.value(function, :parameters) || {},
429
427
  strict: false
430
428
  }
431
429
  end