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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +70 -4
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/mcp.md +72 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -15
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +14 -7
- data/lib/kward/agent.rb +72 -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 +12 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +9 -3
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +32 -3
- data/lib/kward/cli/rendering.rb +1 -1
- 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 +32 -15
- data/lib/kward/cli.rb +64 -4
- data/lib/kward/cli_transcript_formatter.rb +0 -7
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +152 -27
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +1 -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 +56 -0
- data/lib/kward/mcp/server_config.rb +59 -0
- data/lib/kward/mcp/stdio_transport.rb +112 -0
- 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 +13 -45
- 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 +24 -0
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +178 -13
- data/lib/kward/pan/server.rb +51 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +86 -25
- data/lib/kward/prompt_interface/editor/controller.rb +124 -4
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +72 -13
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- 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 +94 -13
- data/lib/kward/rpc/session_manager.rb +258 -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 +9 -10
- 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 +122 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +297 -9
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +117 -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 +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
|
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)
|
|
@@ -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
|
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"
|
|
@@ -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(
|
|
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
|
-
|
|
529
|
+
CopilotModels.supported_choices(live_models)
|
|
529
530
|
end
|
|
530
531
|
|
|
531
532
|
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
|
|
533
|
+
CopilotModels.supported_choices(ModelInfo::COPILOT_MODEL_CHOICES)
|
|
537
534
|
end
|
|
538
535
|
|
|
539
536
|
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-)/)
|
|
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 =
|
|
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
|