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
@@ -0,0 +1,158 @@
1
+ require_relative "../deep_copy"
2
+ require_relative "audit_log"
3
+ require_relative "catalog"
4
+ require_relative "decision"
5
+ require_relative "event"
6
+ require_relative "matcher"
7
+
8
+ # Namespace for the Kward CLI agent runtime.
9
+ module Kward
10
+ module Hooks
11
+ # Dispatches lifecycle events to registered hooks and combines decisions.
12
+ class Manager
13
+ Handler = Struct.new(:id, :event, :source, :order, :match, :callback, :failure_policy, keyword_init: true)
14
+ Result = Struct.new(:event, :decision, :decisions, :warnings, :messages, :payload, keyword_init: true) do
15
+ def allowed?
16
+ !decision.deny? && !decision.ask?
17
+ end
18
+
19
+ def denied?
20
+ decision.deny?
21
+ end
22
+
23
+ def approval_required?
24
+ decision.ask?
25
+ end
26
+ end
27
+
28
+ attr_reader :handlers
29
+ attr_writer :on_result
30
+
31
+ def initialize(audit_log: AuditLog.new, on_result: nil)
32
+ @handlers = []
33
+ @audit_log = audit_log
34
+ @on_result = on_result
35
+ end
36
+
37
+ def register(event, id: nil, source: nil, order: 100, match: nil, failure_policy: nil, &callback)
38
+ raise ArgumentError, "Hook requires an event name" if event.to_s.empty?
39
+ raise ArgumentError, "Hook requires a handler" unless callback
40
+
41
+ handler = Handler.new(
42
+ id: id || "#{source || "hook"}:#{event}:#{@handlers.length + 1}",
43
+ event: event.to_s,
44
+ source: source,
45
+ order: order.to_i,
46
+ match: Matcher.new(match),
47
+ callback: callback,
48
+ failure_policy: Catalog.failure_policy(event, failure_policy)
49
+ )
50
+ @handlers << handler
51
+ handler
52
+ end
53
+
54
+ def empty?
55
+ @handlers.empty?
56
+ end
57
+
58
+ def run(event, context: nil)
59
+ event = normalize_event(event)
60
+ decisions = []
61
+ warnings = []
62
+ messages = []
63
+ payload = DeepCopy.dup(event.payload)
64
+
65
+ handlers_for(event).each do |handler|
66
+ decision = call_handler(handler, event, context)
67
+ decisions << decision
68
+ warnings << decision.message if decision.warning? && decision.message
69
+ messages << decision.message if decision.message && !decision.warning?
70
+
71
+ if decision.modify?
72
+ modifications = Catalog.filter_modifications(event.name, decision.payload)
73
+ payload = DeepCopy.merge(payload, modifications)
74
+ event = event_with_payload(event, payload)
75
+ end
76
+
77
+ break if decision.deny?
78
+ end
79
+
80
+ final_decision = final_decision(decisions)
81
+ result = Result.new(
82
+ event: event,
83
+ decision: final_decision,
84
+ decisions: decisions,
85
+ warnings: warnings,
86
+ messages: messages,
87
+ payload: payload
88
+ )
89
+ @audit_log&.log_result(event: event, result: result)
90
+ @on_result&.call(event, result) unless decisions.empty?
91
+ result
92
+ end
93
+
94
+ private
95
+
96
+ def normalize_event(event)
97
+ return event if event.is_a?(Event)
98
+
99
+ Event.new(**event)
100
+ end
101
+
102
+ def handlers_for(event)
103
+ @handlers
104
+ .select { |handler| handler.event == event.name && handler.match.match?(event) }
105
+ .sort_by { |handler| [handler.order, handler.id.to_s] }
106
+ end
107
+
108
+ def call_handler(handler, event, context)
109
+ started_at = @audit_log&.monotonic_now
110
+ value = if handler.callback.arity == 1
111
+ handler.callback.call(event)
112
+ else
113
+ handler.callback.call(event, context)
114
+ end
115
+ Decision.normalize(value).tap do |decision|
116
+ log_handler(event, handler, decision, started_at)
117
+ end
118
+ rescue StandardError => e
119
+ Catalog.failure_decision(
120
+ handler.failure_policy,
121
+ "Hook #{handler.id} failed: #{e.message}",
122
+ metadata: { hook_id: handler.id, source: handler.source }
123
+ ).tap do |decision|
124
+ log_handler(event, handler, decision, started_at)
125
+ end
126
+ end
127
+
128
+ def log_handler(event, handler, decision, started_at)
129
+ @audit_log&.log_handler(
130
+ event: event,
131
+ handler: handler,
132
+ decision: decision,
133
+ duration_ms: started_at ? @audit_log.duration_ms(started_at) : nil
134
+ )
135
+ end
136
+
137
+ def final_decision(decisions)
138
+ decisions.find(&:deny?) || decisions.find(&:ask?) || decisions.reverse.find(&:modify?) || decisions.find(&:warning?) || Decision.allow
139
+ end
140
+
141
+ def event_with_payload(event, payload)
142
+ Event.new(
143
+ id: event.id,
144
+ name: event.name,
145
+ phase: event.phase,
146
+ timestamp: event.timestamp,
147
+ session: event.session,
148
+ turn: event.turn,
149
+ workspace: event.workspace,
150
+ frontend: event.frontend,
151
+ agent: event.agent,
152
+ payload: payload
153
+ )
154
+ end
155
+
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,75 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ module Hooks
4
+ # Matches lifecycle hook selectors against event payloads.
5
+ class Matcher
6
+ def initialize(match = nil)
7
+ @match = match || {}
8
+ end
9
+
10
+ def match?(event)
11
+ return true if @match.empty?
12
+
13
+ @match.all? { |key, expected| matches_key?(event, key.to_s, expected) }
14
+ end
15
+
16
+ private
17
+
18
+ def matches_key?(event, key, expected)
19
+ case key
20
+ when "event", "name"
21
+ matches_value?(event.name, expected)
22
+ when "phase"
23
+ matches_value?(event.phase, expected)
24
+ when "tool", "tool_name"
25
+ matches_value?(payload_value(event, "tool_name") || payload_value(event, "tool"), expected)
26
+ when "mcp_server", "server"
27
+ matches_value?(payload_value(event, "server_name") || payload_value(event, "mcp_server"), expected)
28
+ when "mcp_tool", "remote_name"
29
+ matches_value?(payload_value(event, "remote_name") || payload_value(event, "mcp_tool"), expected)
30
+ when "operation"
31
+ matches_value?(payload_value(event, "operation"), expected)
32
+ when "frontend"
33
+ matches_value?(event.frontend[:name] || event.frontend["name"], expected)
34
+ when "provider"
35
+ matches_value?(event.agent[:provider] || event.agent["provider"], expected)
36
+ when "model"
37
+ matches_value?(event.agent[:model] || event.agent["model"], expected)
38
+ when "path", "paths"
39
+ matches_path?(payload_paths(event), expected)
40
+ when "command_regex"
41
+ value = payload_value(event, "command").to_s
42
+ Array(expected).any? { |pattern| Regexp.new(pattern.to_s).match?(value) }
43
+ else
44
+ matches_value?(payload_value(event, key), expected)
45
+ end
46
+ end
47
+
48
+ def payload_value(event, key)
49
+ event.payload[key.to_sym] || event.payload[key]
50
+ end
51
+
52
+ def payload_paths(event)
53
+ values = []
54
+ values << payload_value(event, "path")
55
+ values.concat(Array(payload_value(event, "paths")))
56
+ Array(payload_value(event, "files")).each do |file|
57
+ values << (file[:path] || file["path"]) if file.respond_to?(:[])
58
+ end
59
+ values.compact.map(&:to_s)
60
+ end
61
+
62
+ def matches_path?(paths, expected)
63
+ patterns = Array(expected).map(&:to_s)
64
+ paths.any? do |path|
65
+ patterns.any? { |pattern| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
66
+ end
67
+ end
68
+
69
+ def matches_value?(actual, expected)
70
+ expected_values = Array(expected).map(&:to_s)
71
+ expected_values.include?(actual.to_s)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,16 @@
1
+ require_relative "hooks/audit_log"
2
+ require_relative "hooks/catalog"
3
+ require_relative "hooks/decision"
4
+ require_relative "hooks/event"
5
+ require_relative "hooks/matcher"
6
+ require_relative "hooks/manager"
7
+ require_relative "hooks/command_handler"
8
+ require_relative "hooks/http_handler"
9
+ require_relative "hooks/config_loader"
10
+
11
+ # Namespace for the Kward CLI agent runtime.
12
+ module Kward
13
+ # Lifecycle hook primitives and dispatch helpers.
14
+ module Hooks
15
+ end
16
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative "message_access"
2
+ require_relative "message_text"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -37,10 +38,10 @@ module Kward
37
38
  end
38
39
 
39
40
  def message_display_text(message)
40
- display_content = MessageAccess.value(message, :display_content) || MessageAccess.value(message, :displayContent)
41
- return display_content.to_s unless display_content.nil?
41
+ return MessageText.full_text(message) unless MessageAccess.display_content(message).nil?
42
42
 
43
- markdown_content(MessageAccess.content(message))
43
+ content = MessageAccess.content(message)
44
+ content.is_a?(Array) ? markdown_content(content) : MessageText.full_text(message)
44
45
  end
45
46
 
46
47
  def markdown_content(content)
@@ -8,7 +8,7 @@ module Kward
8
8
 
9
9
  # Minimal MCP client for discovering and invoking server tools.
10
10
  class Client
11
- attr_reader :name
11
+ attr_reader :name, :transport
12
12
 
13
13
  def initialize(name:, transport:)
14
14
  @name = name.to_s
@@ -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"
@@ -93,7 +94,7 @@ module Kward
93
94
  @openrouter_models = nil
94
95
  end
95
96
 
96
- def chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
97
+ def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
97
98
  cancellation&.raise_if_cancelled!
98
99
  requested_provider = provider
99
100
  url, token, resolved_provider, account_id = credentials(provider: requested_provider)
@@ -125,6 +126,7 @@ module Kward
125
126
  request_body: request_body,
126
127
  current_model: current_model,
127
128
  on_reasoning_delta: on_reasoning_delta,
129
+ on_reasoning_boundary: on_reasoning_boundary,
128
130
  on_assistant_delta: on_assistant_delta,
129
131
  cancellation: cancellation,
130
132
  max_tokens: max_tokens
@@ -227,8 +229,8 @@ module Kward
227
229
  end
228
230
 
229
231
  # 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)
232
+ def current_context_parts(messages, tools, provider: current_provider, model: nil)
233
+ build_context_parts(ModelInfo.provider_label(provider), messages, tools, model: model)
232
234
  end
233
235
 
234
236
  # Returns whether the active provider can accept steering while a turn is streaming.
@@ -256,7 +258,7 @@ module Kward
256
258
  }
257
259
  end
258
260
 
259
- def chat_provider_request(provider:, url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:, max_tokens:)
261
+ def chat_provider_request(provider:, url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:, max_tokens:)
260
262
  case provider
261
263
  when "Codex"
262
264
  chat_codex_provider(
@@ -268,6 +270,7 @@ module Kward
268
270
  request_body: request_body,
269
271
  current_model: current_model,
270
272
  on_reasoning_delta: on_reasoning_delta,
273
+ on_reasoning_boundary: on_reasoning_boundary,
271
274
  on_assistant_delta: on_assistant_delta,
272
275
  cancellation: cancellation,
273
276
  max_tokens: max_tokens
@@ -305,7 +308,7 @@ module Kward
305
308
  end
306
309
  end
307
310
 
308
- def chat_codex_provider(url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:, max_tokens:)
311
+ def chat_codex_provider(url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:, max_tokens:)
309
312
  message = codex_chat(
310
313
  url,
311
314
  token,
@@ -314,6 +317,7 @@ module Kward
314
317
  tools,
315
318
  request_body: request_body,
316
319
  on_reasoning_delta: on_reasoning_delta,
320
+ on_reasoning_boundary: on_reasoning_boundary,
317
321
  on_assistant_delta: on_assistant_delta,
318
322
  cancellation: cancellation,
319
323
  max_tokens: max_tokens
@@ -525,30 +529,15 @@ module Kward
525
529
  live_models = fetch_copilot_models
526
530
  return static_copilot_model_choices if live_models.empty?
527
531
 
528
- supported_copilot_model_choices(live_models)
532
+ CopilotModels.supported_choices(live_models)
529
533
  end
530
534
 
531
535
  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
536
+ CopilotModels.supported_choices(ModelInfo::COPILOT_MODEL_CHOICES)
537
537
  end
538
538
 
539
539
  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-)/)
540
+ CopilotModels.resolved_chat_model(configured_model, fetch_copilot_models)
552
541
  end
553
542
 
554
543
  def fetch_copilot_models
@@ -566,33 +555,11 @@ module Kward
566
555
  response = Net::HTTP.start(url.hostname, url.port, use_ssl: true) { |http| http.request(request) }
567
556
  return [] unless response.is_a?(Net::HTTPSuccess)
568
557
 
569
- @copilot_models = parse_copilot_models(response.body)
558
+ @copilot_models = CopilotModels.parse(response.body)
570
559
  rescue StandardError
571
560
  []
572
561
  end
573
562
 
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
563
  def copilot_responses_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
597
564
  parts = build_context_parts("CopilotResponses", messages, tools, model: model)
598
565
  payload = {
@@ -685,7 +652,7 @@ module Kward
685
652
  role.to_s == "user" ? "user" : "agent"
686
653
  end
687
654
 
688
- def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
655
+ def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
689
656
  request = Net::HTTP::Post.new(url)
690
657
  request["Authorization"] = "Bearer #{token}"
691
658
  request["ChatGPT-Account-Id"] = account_id if account_id
@@ -705,7 +672,7 @@ module Kward
705
672
  raise RequestError.new(provider: "Codex", code: response.code, body: redact(body, token))
706
673
  end
707
674
 
708
- message = parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
675
+ message = parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
709
676
  end
710
677
  end
711
678
 
@@ -717,12 +684,12 @@ module Kward
717
684
  raise e
718
685
  end
719
686
 
720
- 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)
687
+ def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
688
+ ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
722
689
  end
723
690
 
724
- 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)
691
+ def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil)
692
+ ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, 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
693
  end
727
694
 
728
695
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
@@ -871,6 +838,10 @@ module Kward
871
838
  ConfigFiles.config_value(@config, *keys)
872
839
  end
873
840
 
841
+ def codex_show_raw_reasoning?
842
+ @config["codex_show_raw_reasoning"] == true
843
+ end
844
+
874
845
  def load_config
875
846
  ConfigFiles.read_config(@config_path)
876
847
  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