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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +75 -10
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +2 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +111 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +77 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +3 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +28 -7
- data/lib/kward/cli/rendering.rb +8 -4
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +21 -18
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +120 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +23 -52
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +38 -5
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +183 -18
- data/lib/kward/pan/index.html.erb +1 -0
- data/lib/kward/pan/server.rb +53 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +88 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +95 -15
- data/lib/kward/rpc/session_manager.rb +260 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +19 -20
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +122 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- 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
|
data/lib/kward/hooks.rb
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/kward/mcp/client.rb
CHANGED
|
@@ -11,13 +11,17 @@ module Kward
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
13
|
def clients_from_config(config)
|
|
14
|
-
|
|
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
|
|
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
|
data/lib/kward/message_access.rb
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
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
|
|
data/lib/kward/message_text.rb
CHANGED
|
@@ -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
|
data/lib/kward/model/client.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
532
|
+
CopilotModels.supported_choices(live_models)
|
|
529
533
|
end
|
|
530
534
|
|
|
531
535
|
def static_copilot_model_choices
|
|
532
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
100
|
+
@encodings[model.to_s] ||= begin
|
|
101
|
+
require "tiktoken_ruby"
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|