kward 0.73.1 → 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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  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/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -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)
@@ -0,0 +1,56 @@
1
+ require_relative "stdio_transport"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model Context Protocol client support.
6
+ module MCP
7
+ PROTOCOL_VERSION = "2025-11-25"
8
+
9
+ # Minimal MCP client for discovering and invoking server tools.
10
+ class Client
11
+ attr_reader :name, :transport
12
+
13
+ def initialize(name:, transport:)
14
+ @name = name.to_s
15
+ @transport = transport
16
+ @initialized = false
17
+ end
18
+
19
+ def initialize!
20
+ return if @initialized
21
+
22
+ @transport.request("initialize", initialize_params)
23
+ @transport.notify("notifications/initialized")
24
+ @initialized = true
25
+ end
26
+
27
+ def list_tools
28
+ initialize!
29
+ result = @transport.request("tools/list", {})
30
+ Array(result["tools"] || result[:tools])
31
+ end
32
+
33
+ def call_tool(name, arguments = {})
34
+ initialize!
35
+ @transport.request("tools/call", { name: name, arguments: arguments || {} })
36
+ end
37
+
38
+ def close
39
+ @transport.close
40
+ end
41
+
42
+ private
43
+
44
+ def initialize_params
45
+ {
46
+ protocolVersion: PROTOCOL_VERSION,
47
+ capabilities: {},
48
+ clientInfo: {
49
+ name: "kward",
50
+ version: Kward.const_defined?(:VERSION) ? Kward::VERSION : "0.0.0"
51
+ }
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,59 @@
1
+ require_relative "../config_files"
2
+ require_relative "client"
3
+ require_relative "stdio_transport"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Model Context Protocol client support.
8
+ module MCP
9
+ # Builds MCP clients from Kward configuration.
10
+ module ServerConfig
11
+ module_function
12
+
13
+ def clients_from_config(config)
14
+ configured_servers(config).filter_map do |name, settings|
15
+ client_from_settings(name, settings)
16
+ end
17
+ end
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
+
25
+ def client_from_settings(name, settings)
26
+ return nil unless settings.is_a?(Hash)
27
+ return nil if settings["enabled"] == false
28
+
29
+ command = settings["command"].to_s
30
+ return nil if command.empty?
31
+
32
+ Client.new(
33
+ name: name,
34
+ transport: StdioTransport.new(
35
+ command: command,
36
+ args: settings["args"] || [],
37
+ env: normalized_env(settings["env"]),
38
+ timeout_seconds: positive_number(settings["timeout_seconds"] || settings["timeoutSeconds"])
39
+ )
40
+ )
41
+ end
42
+
43
+ def normalized_env(value)
44
+ return {} unless value.is_a?(Hash)
45
+
46
+ value.each_with_object({}) do |(key, item), env|
47
+ env[key.to_s] = item.to_s
48
+ end
49
+ end
50
+
51
+ def positive_number(value)
52
+ number = value.to_f
53
+ number.positive? ? number : nil
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,112 @@
1
+ require "json"
2
+ require "open3"
3
+ require "timeout"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Model Context Protocol client support.
8
+ module MCP
9
+ # JSON-RPC transport for local MCP servers that communicate over stdio.
10
+ class StdioTransport
11
+ DEFAULT_TIMEOUT_SECONDS = 10
12
+
13
+ attr_reader :command
14
+
15
+ def initialize(command:, args: [], env: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS)
16
+ @command = command.to_s
17
+ @args = Array(args).map(&:to_s)
18
+ @env = env || {}
19
+ @timeout_seconds = timeout_seconds || DEFAULT_TIMEOUT_SECONDS
20
+ @mutex = Mutex.new
21
+ @started = false
22
+ end
23
+
24
+ def request(method, params = nil)
25
+ @mutex.synchronize do
26
+ start
27
+ id = next_id
28
+ write_message({ jsonrpc: "2.0", id: id, method: method, params: params }.compact)
29
+ read_response(id)
30
+ end
31
+ end
32
+
33
+ def notify(method, params = nil)
34
+ @mutex.synchronize do
35
+ start
36
+ write_message({ jsonrpc: "2.0", method: method, params: params }.compact)
37
+ end
38
+ end
39
+
40
+ def close
41
+ @stdin&.close unless @stdin&.closed?
42
+ @stdout&.close unless @stdout&.closed?
43
+ @stderr&.close unless @stderr&.closed?
44
+ @stderr_thread&.kill
45
+ terminate_process
46
+ rescue IOError
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ def terminate_process
53
+ return unless @wait_thread&.alive?
54
+
55
+ Process.kill("TERM", @wait_thread.pid)
56
+ @wait_thread.join(1)
57
+ Process.kill("KILL", @wait_thread.pid) if @wait_thread.alive?
58
+ rescue Errno::ESRCH, IOError
59
+ nil
60
+ end
61
+
62
+ def start
63
+ return if @started
64
+ raise ArgumentError, "MCP server command is required" if @command.empty?
65
+
66
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
67
+ @stderr_thread = Thread.new do
68
+ @stderr.each_line { |_line| }
69
+ rescue IOError
70
+ nil
71
+ end
72
+ @started = true
73
+ rescue SystemCallError => e
74
+ raise "Failed to start MCP server #{@command}: #{e.message}"
75
+ end
76
+
77
+ def next_id
78
+ @next_id ||= 0
79
+ @next_id += 1
80
+ end
81
+
82
+ def write_message(message)
83
+ @stdin.write(JSON.generate(message))
84
+ @stdin.write("\n")
85
+ @stdin.flush
86
+ rescue IOError, Errno::EPIPE => e
87
+ raise "MCP server #{@command} is not accepting requests: #{e.message}"
88
+ end
89
+
90
+ def read_response(expected_id)
91
+ Timeout.timeout(@timeout_seconds) do
92
+ loop do
93
+ line = @stdout.gets
94
+ raise "MCP server #{@command} closed stdout" unless line
95
+
96
+ message = JSON.parse(line)
97
+ next unless message["id"] == expected_id
98
+
99
+ error = message["error"]
100
+ raise "MCP request failed: #{error["message"] || error.inspect}" if error
101
+
102
+ return message["result"] || {}
103
+ end
104
+ end
105
+ rescue JSON::ParserError
106
+ raise "MCP server #{@command} returned invalid JSON"
107
+ rescue Timeout::Error
108
+ raise "MCP server #{@command} did not respond within #{@timeout_seconds} seconds"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -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