kward 0.74.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +56 -3
- 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 +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -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 +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 +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/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 +26 -6
- data/lib/kward/cli.rb +26 -3
- 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 +117 -25
- 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 +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 +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 +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.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 -14
- 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 +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 +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 +25 -1
data/lib/kward/agent.rb
CHANGED
|
@@ -4,9 +4,13 @@ require_relative "compactor"
|
|
|
4
4
|
require_relative "model/context_overflow"
|
|
5
5
|
require_relative "conversation"
|
|
6
6
|
require_relative "events"
|
|
7
|
+
require_relative "deep_copy"
|
|
8
|
+
require_relative "hooks"
|
|
9
|
+
require_relative "message_access"
|
|
7
10
|
require_relative "steering"
|
|
8
11
|
require_relative "telemetry/logger"
|
|
9
12
|
require_relative "tools/registry"
|
|
13
|
+
require_relative "tools/tool_call"
|
|
10
14
|
|
|
11
15
|
# Namespace for the Kward CLI agent runtime.
|
|
12
16
|
module Kward
|
|
@@ -24,11 +28,13 @@ module Kward
|
|
|
24
28
|
# lowest layer that owns the behavior, and use `Agent` only for cross-step turn
|
|
25
29
|
# coordination.
|
|
26
30
|
class Agent
|
|
27
|
-
def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new)
|
|
31
|
+
def initialize(client:, tool_registry: ToolRegistry.new, conversation: Conversation.new, telemetry_logger: TelemetryLogger.new, hook_manager: nil, hook_context: nil)
|
|
28
32
|
@client = client
|
|
29
33
|
@tool_registry = tool_registry
|
|
30
34
|
@conversation = conversation
|
|
31
35
|
@telemetry_logger = telemetry_logger
|
|
36
|
+
@hook_manager = hook_manager
|
|
37
|
+
@hook_context = hook_context
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
attr_reader :conversation, :tool_registry
|
|
@@ -39,15 +45,24 @@ module Kward
|
|
|
39
45
|
# @param display_input [String, nil] alternate text kept for transcripts
|
|
40
46
|
# @yieldparam event [Object] streamed turn event for frontends
|
|
41
47
|
# @return [String] final assistant answer
|
|
42
|
-
def ask(input, display_input: nil, on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, &block)
|
|
48
|
+
def ask(input, display_input: nil, on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil, &block)
|
|
43
49
|
started_at = @telemetry_logger.monotonic_now
|
|
44
50
|
status = "completed"
|
|
45
51
|
error = nil
|
|
46
52
|
cancellation&.raise_if_cancelled!
|
|
53
|
+
turn_start = run_hook("turn_start", payload: { input: input, display_input: display_input })
|
|
54
|
+
return hook_denied_answer(turn_start) if turn_start.denied?
|
|
55
|
+
|
|
56
|
+
input = turn_start.payload[:input] || turn_start.payload["input"] || input
|
|
57
|
+
display_input = turn_start.payload[:display_input] || turn_start.payload["display_input"] || display_input
|
|
47
58
|
@conversation.refresh_system_message_if_workspace_agents_changed!
|
|
48
59
|
@conversation.append_user(input, display_content: display_input)
|
|
60
|
+
run_hook("turn_context_build_before", payload: { message_count: @conversation.messages.length })
|
|
49
61
|
auto_compact_if_needed
|
|
50
|
-
|
|
62
|
+
run_hook("turn_context_build_after", payload: { message_count: @conversation.messages.length })
|
|
63
|
+
answer = run_turn(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry, &block)
|
|
64
|
+
run_hook("turn_end", payload: { input: input, answer: answer })
|
|
65
|
+
answer
|
|
51
66
|
rescue StandardError => e
|
|
52
67
|
status = "failed"
|
|
53
68
|
error = e
|
|
@@ -61,7 +76,7 @@ module Kward
|
|
|
61
76
|
#
|
|
62
77
|
# @yieldparam event [Object] streamed turn event for frontends
|
|
63
78
|
# @return [String] final assistant answer
|
|
64
|
-
def run_turn(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil)
|
|
79
|
+
def run_turn(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
|
|
65
80
|
overflow_retried = false
|
|
66
81
|
steering_state = build_steering_state(steering) do |event|
|
|
67
82
|
yield event if block_given?
|
|
@@ -69,7 +84,7 @@ module Kward
|
|
|
69
84
|
loop do
|
|
70
85
|
cancellation&.raise_if_cancelled!
|
|
71
86
|
begin
|
|
72
|
-
message = chat(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering) do |event|
|
|
87
|
+
message = chat(on_reasoning_delta: on_reasoning_delta, on_retry: on_retry, cancellation: cancellation, steering: steering, options: options, tool_registry: tool_registry) do |event|
|
|
73
88
|
yield event if block_given?
|
|
74
89
|
end
|
|
75
90
|
rescue StandardError => e
|
|
@@ -85,11 +100,11 @@ module Kward
|
|
|
85
100
|
steered_after_message = append_steering_events(steering_state)
|
|
86
101
|
yield Events::SteeringApplied.new(count: steered_after_message) if block_given? && steered_after_message.positive?
|
|
87
102
|
|
|
88
|
-
tool_calls =
|
|
103
|
+
tool_calls = MessageAccess.tool_calls(message)
|
|
89
104
|
if tool_calls.empty?
|
|
90
105
|
next if steered_after_message.positive?
|
|
91
106
|
|
|
92
|
-
answer = safe_answer(
|
|
107
|
+
answer = safe_answer(MessageAccess.content(message).to_s)
|
|
93
108
|
yield Events::Answer.new(content: answer) if block_given?
|
|
94
109
|
return answer
|
|
95
110
|
end
|
|
@@ -102,7 +117,7 @@ module Kward
|
|
|
102
117
|
status = "completed"
|
|
103
118
|
error = nil
|
|
104
119
|
begin
|
|
105
|
-
content = @tool_registry.dispatch(tool_call, @conversation, cancellation: cancellation)
|
|
120
|
+
content = (tool_registry || @tool_registry).dispatch(tool_call, @conversation, cancellation: cancellation)
|
|
106
121
|
rescue StandardError => e
|
|
107
122
|
status = "failed"
|
|
108
123
|
error = e
|
|
@@ -111,7 +126,11 @@ module Kward
|
|
|
111
126
|
log_tool(tool_call, content: content, duration_ms: @telemetry_logger.duration_ms(tool_started_at), status: status, error: error)
|
|
112
127
|
end
|
|
113
128
|
cancellation&.raise_if_cancelled!
|
|
114
|
-
|
|
129
|
+
if block_given?
|
|
130
|
+
elapsed_ms = @telemetry_logger.duration_ms(tool_started_at)
|
|
131
|
+
yield Events::ToolUpdate.new(tool_call: tool_call, content: content, elapsed_ms: elapsed_ms)
|
|
132
|
+
yield Events::ToolResult.new(tool_call: tool_call, content: content)
|
|
133
|
+
end
|
|
115
134
|
end
|
|
116
135
|
steered_after_tools = append_steering_events(steering_state)
|
|
117
136
|
yield Events::SteeringApplied.new(count: steered_after_tools) if block_given? && steered_after_tools.positive?
|
|
@@ -154,7 +173,7 @@ module Kward
|
|
|
154
173
|
|
|
155
174
|
def log_tool(tool_call, content:, duration_ms:, status:, error:)
|
|
156
175
|
payload = {
|
|
157
|
-
"tool_name" =>
|
|
176
|
+
"tool_name" => ToolCall.name(tool_call),
|
|
158
177
|
"duration_ms" => duration_ms,
|
|
159
178
|
"status" => status,
|
|
160
179
|
"result_bytes" => content.to_s.bytesize
|
|
@@ -170,11 +189,6 @@ module Kward
|
|
|
170
189
|
@telemetry_logger.log("errors", event, payload.merge(TelemetryLogger.error_payload(error)))
|
|
171
190
|
end
|
|
172
191
|
|
|
173
|
-
def tool_name(tool_call)
|
|
174
|
-
function = tool_call["function"] || tool_call[:function] || {}
|
|
175
|
-
function["name"] || function[:name]
|
|
176
|
-
end
|
|
177
|
-
|
|
178
192
|
def auto_compact_if_needed
|
|
179
193
|
context_window = @client.current_context_window if @client.respond_to?(:current_context_window)
|
|
180
194
|
Compactor.new(conversation: @conversation, client: @client).auto_compact_if_needed(context_window: context_window)
|
|
@@ -195,7 +209,7 @@ module Kward
|
|
|
195
209
|
nil
|
|
196
210
|
end
|
|
197
211
|
|
|
198
|
-
def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil)
|
|
212
|
+
def chat(on_reasoning_delta: nil, on_retry: nil, cancellation: nil, steering: nil, options: {}, tool_registry: nil)
|
|
199
213
|
reasoning_delta = lambda do |delta|
|
|
200
214
|
cancellation&.raise_if_cancelled!
|
|
201
215
|
on_reasoning_delta&.call(delta)
|
|
@@ -211,21 +225,37 @@ module Kward
|
|
|
211
225
|
on_retry&.call(event)
|
|
212
226
|
yield event if block_given?
|
|
213
227
|
end
|
|
214
|
-
|
|
228
|
+
registry = tool_registry || @tool_registry
|
|
229
|
+
request = {
|
|
230
|
+
messages: @conversation.context_messages,
|
|
231
|
+
tools: registry.schemas,
|
|
232
|
+
provider: options[:provider] || @conversation.provider,
|
|
233
|
+
model: options[:model] || @conversation.model,
|
|
234
|
+
reasoning: options[:reasoning] || @conversation.reasoning_effort
|
|
235
|
+
}
|
|
236
|
+
before = run_hook("model_request_before", payload: request)
|
|
237
|
+
request = DeepCopy.merge(request, before.payload) if before.decision.modify?
|
|
238
|
+
return { "role" => "assistant", "content" => hook_denied_answer(before) } if before.denied?
|
|
239
|
+
|
|
240
|
+
run_hook("turn_model_request_before", payload: request)
|
|
241
|
+
response = ChatInvocation.call(
|
|
215
242
|
@client,
|
|
216
|
-
|
|
243
|
+
request[:messages] || request["messages"],
|
|
217
244
|
{
|
|
218
|
-
tools:
|
|
245
|
+
tools: request[:tools] || request["tools"],
|
|
219
246
|
on_reasoning_delta: reasoning_delta,
|
|
220
247
|
on_assistant_delta: assistant_delta,
|
|
221
248
|
on_retry: retry_callback,
|
|
222
249
|
cancellation: cancellation,
|
|
223
250
|
steering: steering,
|
|
224
|
-
provider:
|
|
225
|
-
model:
|
|
226
|
-
reasoning:
|
|
251
|
+
provider: request[:provider] || request["provider"],
|
|
252
|
+
model: request[:model] || request["model"],
|
|
253
|
+
reasoning: request[:reasoning] || request["reasoning"]
|
|
227
254
|
}
|
|
228
255
|
)
|
|
256
|
+
run_hook("model_response_after_parse", payload: { message: response })
|
|
257
|
+
run_hook("turn_model_response_complete", payload: { message: response })
|
|
258
|
+
response
|
|
229
259
|
end
|
|
230
260
|
|
|
231
261
|
def update_conversation_runtime(message)
|
|
@@ -248,6 +278,26 @@ module Kward
|
|
|
248
278
|
"I have not changed any files. I need to use write_file or edit_file successfully before claiming a file change."
|
|
249
279
|
end
|
|
250
280
|
|
|
281
|
+
def run_hook(name, payload: {})
|
|
282
|
+
return Hooks::Manager::Result.new(event: nil, decision: Hooks::Decision.allow, decisions: [], warnings: [], messages: [], payload: payload) unless @hook_manager
|
|
283
|
+
|
|
284
|
+
@hook_manager.run(Hooks::Event.new(
|
|
285
|
+
name: name,
|
|
286
|
+
session: { id: @conversation.respond_to?(:session_id) ? @conversation.session_id : nil },
|
|
287
|
+
workspace: { root: @conversation.respond_to?(:workspace_root) ? @conversation.workspace_root : nil },
|
|
288
|
+
agent: {
|
|
289
|
+
provider: @conversation.provider,
|
|
290
|
+
model: @conversation.model,
|
|
291
|
+
reasoning: @conversation.reasoning_effort
|
|
292
|
+
},
|
|
293
|
+
payload: payload
|
|
294
|
+
), context: @hook_context)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def hook_denied_answer(result)
|
|
298
|
+
"Declined: #{result.decision.message || "lifecycle hook denied the operation"}"
|
|
299
|
+
end
|
|
300
|
+
|
|
251
301
|
def claims_file_edit?(text)
|
|
252
302
|
text.match?(/\b(?:I|I've|I have)\s+(?:changed|updated|modified|edited|created|deleted|wrote)\s+(?:the\s+)?(?:file|files|[\w.\/-]+\.[\w-]+)\b/i)
|
|
253
303
|
end
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
require "base64"
|
|
2
|
-
require "digest"
|
|
3
2
|
require "json"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "securerandom"
|
|
6
|
-
require "socket"
|
|
7
3
|
require "time"
|
|
8
4
|
require "uri"
|
|
9
5
|
require_relative "file"
|
|
6
|
+
require_relative "oauth_helpers"
|
|
10
7
|
require_relative "../config_files"
|
|
11
8
|
|
|
12
9
|
# Namespace for the Kward CLI agent runtime.
|
|
13
10
|
module Kward
|
|
14
11
|
# OAuth helper for Anthropic Claude Pro/Max subscription credentials.
|
|
15
12
|
class AnthropicOAuth
|
|
13
|
+
include OAuthHelpers
|
|
14
|
+
|
|
16
15
|
AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
17
16
|
TOKEN_URL = URI("https://platform.claude.com/v1/oauth/token")
|
|
18
17
|
DEFAULT_PORT = 53_692
|
|
@@ -189,49 +188,8 @@ module Kward
|
|
|
189
188
|
DEFAULT_CLIENT_ID
|
|
190
189
|
end
|
|
191
190
|
|
|
192
|
-
def generate_pkce
|
|
193
|
-
verifier = random_urlsafe(64)
|
|
194
|
-
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
195
|
-
{ verifier: verifier, challenge: challenge }
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def random_urlsafe(bytes)
|
|
199
|
-
Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
191
|
def start_callback_server
|
|
203
|
-
|
|
204
|
-
rescue Errno::EADDRINUSE
|
|
205
|
-
TCPServer.new("localhost", 0)
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
def wait_for_callback(server, expected_state:, timeout_seconds:)
|
|
209
|
-
ready = IO.select([server], nil, nil, timeout_seconds)
|
|
210
|
-
return nil unless ready
|
|
211
|
-
|
|
212
|
-
socket = server.accept
|
|
213
|
-
request_line = socket.gets.to_s
|
|
214
|
-
path = request_line.split[1].to_s
|
|
215
|
-
params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
|
|
216
|
-
|
|
217
|
-
code = nil
|
|
218
|
-
status = "200 OK"
|
|
219
|
-
body = "Login complete. You can close this window."
|
|
220
|
-
if params["error"]
|
|
221
|
-
body = "Login failed. Return to the terminal."
|
|
222
|
-
elsif params["state"].to_s != expected_state.to_s
|
|
223
|
-
status = "400 Bad Request"
|
|
224
|
-
body = "Invalid OAuth state. Return to the terminal."
|
|
225
|
-
else
|
|
226
|
-
code = params["code"]
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
230
|
-
code
|
|
231
|
-
rescue URI::InvalidURIError
|
|
232
|
-
nil
|
|
233
|
-
ensure
|
|
234
|
-
socket&.close
|
|
192
|
+
oauth_callback_server(port_env: "KWARD_ANTHROPIC_OAUTH_PORT", default_port: DEFAULT_PORT)
|
|
235
193
|
end
|
|
236
194
|
|
|
237
195
|
def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
|
|
@@ -245,47 +203,7 @@ module Kward
|
|
|
245
203
|
end
|
|
246
204
|
|
|
247
205
|
def post_json(uri, params)
|
|
248
|
-
|
|
249
|
-
request["Content-Type"] = "application/json"
|
|
250
|
-
request["Accept"] = "application/json"
|
|
251
|
-
request.body = JSON.dump(params)
|
|
252
|
-
|
|
253
|
-
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def token_expired?(auth)
|
|
257
|
-
expires_at = auth&.fetch("expires_at", nil)
|
|
258
|
-
return false unless expires_at
|
|
259
|
-
|
|
260
|
-
Time.parse(expires_at) <= Time.now.utc + 60
|
|
261
|
-
rescue ArgumentError
|
|
262
|
-
false
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def expires_at_for(tokens)
|
|
266
|
-
expires_in = tokens["expires_in"] || tokens[:expires_in]
|
|
267
|
-
return tokens["expires_at"] || tokens[:expires_at] unless expires_in
|
|
268
|
-
|
|
269
|
-
(Time.now.utc + expires_in.to_i).iso8601
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
def parse_successful_json(response, label)
|
|
273
|
-
raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
274
|
-
|
|
275
|
-
JSON.parse(response.body)
|
|
276
|
-
rescue JSON::ParserError
|
|
277
|
-
raise "#{label} returned invalid JSON"
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def open_url(url)
|
|
281
|
-
command = if RUBY_PLATFORM.match?(/darwin/)
|
|
282
|
-
"open"
|
|
283
|
-
elsif RUBY_PLATFORM.match?(/linux/)
|
|
284
|
-
"xdg-open"
|
|
285
|
-
end
|
|
286
|
-
return false unless command
|
|
287
|
-
|
|
288
|
-
system(command, url, out: File::NULL, err: File::NULL)
|
|
206
|
+
super(uri, params, headers: { "Accept" => "application/json" })
|
|
289
207
|
end
|
|
290
208
|
end
|
|
291
209
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
# Namespace for the Kward CLI agent runtime.
|
|
11
|
+
module Kward
|
|
12
|
+
# Shared browser OAuth mechanics used by provider-specific credential flows.
|
|
13
|
+
module OAuthHelpers
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def generate_pkce
|
|
17
|
+
verifier = random_urlsafe(64)
|
|
18
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
19
|
+
{ verifier: verifier, challenge: challenge }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def random_urlsafe(bytes)
|
|
23
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def oauth_callback_server(port_env:, default_port:)
|
|
27
|
+
TCPServer.new("localhost", Integer(ENV.fetch(port_env, default_port)))
|
|
28
|
+
rescue Errno::EADDRINUSE
|
|
29
|
+
TCPServer.new("localhost", 0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def wait_for_callback(server, expected_state:, timeout_seconds:)
|
|
33
|
+
ready = IO.select([server], nil, nil, timeout_seconds)
|
|
34
|
+
return nil unless ready
|
|
35
|
+
|
|
36
|
+
socket = server.accept
|
|
37
|
+
request_line = socket.gets.to_s
|
|
38
|
+
path = request_line.split[1].to_s
|
|
39
|
+
params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
|
|
40
|
+
|
|
41
|
+
code = nil
|
|
42
|
+
status = "200 OK"
|
|
43
|
+
body = "Login complete. You can close this window."
|
|
44
|
+
if params["error"]
|
|
45
|
+
body = "Login failed. Return to the terminal."
|
|
46
|
+
elsif params["state"].to_s != expected_state.to_s
|
|
47
|
+
status = "400 Bad Request"
|
|
48
|
+
body = "Invalid OAuth state. Return to the terminal."
|
|
49
|
+
else
|
|
50
|
+
code = params["code"]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
54
|
+
code
|
|
55
|
+
rescue URI::InvalidURIError
|
|
56
|
+
nil
|
|
57
|
+
ensure
|
|
58
|
+
socket&.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def post_json(uri, params, headers: {})
|
|
62
|
+
request = Net::HTTP::Post.new(uri)
|
|
63
|
+
request["Content-Type"] = "application/json"
|
|
64
|
+
headers.each { |key, value| request[key] = value }
|
|
65
|
+
request.body = JSON.dump(params)
|
|
66
|
+
|
|
67
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def token_expired?(auth)
|
|
71
|
+
expires_at = auth&.fetch("expires_at", nil)
|
|
72
|
+
return false unless expires_at
|
|
73
|
+
|
|
74
|
+
Time.parse(expires_at) <= Time.now.utc + 60
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def expires_at_for(tokens)
|
|
80
|
+
expires_in = tokens["expires_in"] || tokens[:expires_in]
|
|
81
|
+
return tokens["expires_at"] || tokens[:expires_at] unless expires_in
|
|
82
|
+
|
|
83
|
+
(Time.now.utc + expires_in.to_i).iso8601
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_successful_json(response, label)
|
|
87
|
+
raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
88
|
+
|
|
89
|
+
JSON.parse(response.body)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
raise "#{label} returned invalid JSON"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def open_url(url)
|
|
95
|
+
command = if RUBY_PLATFORM.match?(/darwin/)
|
|
96
|
+
"open"
|
|
97
|
+
elsif RUBY_PLATFORM.match?(/linux/)
|
|
98
|
+
"xdg-open"
|
|
99
|
+
end
|
|
100
|
+
return false unless command
|
|
101
|
+
|
|
102
|
+
system(command, url, out: File::NULL, err: File::NULL)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
require "base64"
|
|
2
|
-
require "digest"
|
|
3
2
|
require "json"
|
|
4
3
|
require "net/http"
|
|
5
|
-
require "securerandom"
|
|
6
|
-
require "socket"
|
|
7
4
|
require "time"
|
|
8
5
|
require "uri"
|
|
9
6
|
require_relative "file"
|
|
7
|
+
require_relative "oauth_helpers"
|
|
10
8
|
|
|
11
9
|
# Namespace for the Kward CLI agent runtime.
|
|
12
10
|
module Kward
|
|
13
11
|
# OAuth helper for ChatGPT/OpenAI Codex credentials.
|
|
14
12
|
class OpenAIOAuth
|
|
13
|
+
include OAuthHelpers
|
|
14
|
+
|
|
15
15
|
ISSUER = "https://auth.openai.com"
|
|
16
16
|
TOKEN_URL = URI("#{ISSUER}/oauth/token")
|
|
17
17
|
DEFAULT_PORT = 1455
|
|
@@ -200,49 +200,8 @@ module Kward
|
|
|
200
200
|
raise "Invalid Kward config JSON: #{@config_path}"
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
def generate_pkce
|
|
204
|
-
verifier = random_urlsafe(64)
|
|
205
|
-
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
206
|
-
{ verifier: verifier, challenge: challenge }
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def random_urlsafe(bytes)
|
|
210
|
-
Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
203
|
def start_callback_server
|
|
214
|
-
|
|
215
|
-
rescue Errno::EADDRINUSE
|
|
216
|
-
TCPServer.new("localhost", 0)
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def wait_for_callback(server, expected_state:, timeout_seconds:)
|
|
220
|
-
ready = IO.select([server], nil, nil, timeout_seconds)
|
|
221
|
-
return nil unless ready
|
|
222
|
-
|
|
223
|
-
socket = server.accept
|
|
224
|
-
request_line = socket.gets.to_s
|
|
225
|
-
path = request_line.split[1].to_s
|
|
226
|
-
params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
|
|
227
|
-
|
|
228
|
-
code = nil
|
|
229
|
-
status = "200 OK"
|
|
230
|
-
body = "Login complete. You can close this window."
|
|
231
|
-
if params["error"]
|
|
232
|
-
body = "Login failed. Return to the terminal."
|
|
233
|
-
elsif params["state"] != expected_state
|
|
234
|
-
status = "400 Bad Request"
|
|
235
|
-
body = "Invalid OAuth state. Return to the terminal."
|
|
236
|
-
else
|
|
237
|
-
code = params["code"]
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
241
|
-
code
|
|
242
|
-
rescue URI::InvalidURIError
|
|
243
|
-
nil
|
|
244
|
-
ensure
|
|
245
|
-
socket&.close
|
|
204
|
+
oauth_callback_server(port_env: "KWARD_OAUTH_PORT", default_port: DEFAULT_PORT)
|
|
246
205
|
end
|
|
247
206
|
|
|
248
207
|
def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
|
|
@@ -263,29 +222,6 @@ module Kward
|
|
|
263
222
|
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
264
223
|
end
|
|
265
224
|
|
|
266
|
-
def post_json(uri, params)
|
|
267
|
-
request = Net::HTTP::Post.new(uri)
|
|
268
|
-
request["Content-Type"] = "application/json"
|
|
269
|
-
request.body = JSON.dump(params)
|
|
270
|
-
|
|
271
|
-
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def token_expired?(auth)
|
|
275
|
-
expires_at = auth&.fetch("expires_at", nil)
|
|
276
|
-
return false unless expires_at
|
|
277
|
-
|
|
278
|
-
Time.parse(expires_at) <= Time.now.utc + 60
|
|
279
|
-
rescue ArgumentError
|
|
280
|
-
false
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def expires_at_for(tokens)
|
|
284
|
-
expires_in = tokens["expires_in"] || tokens[:expires_in]
|
|
285
|
-
return tokens["expires_at"] || tokens[:expires_at] unless expires_in
|
|
286
|
-
|
|
287
|
-
(Time.now.utc + expires_in.to_i).iso8601
|
|
288
|
-
end
|
|
289
225
|
|
|
290
226
|
def extract_account_id(tokens)
|
|
291
227
|
[tokens["id_token"], tokens[:id_token], tokens["access_token"], tokens[:access_token]].each do |token|
|
|
@@ -305,23 +241,5 @@ module Kward
|
|
|
305
241
|
{}
|
|
306
242
|
end
|
|
307
243
|
|
|
308
|
-
def parse_successful_json(response, label)
|
|
309
|
-
raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
310
|
-
|
|
311
|
-
JSON.parse(response.body)
|
|
312
|
-
rescue JSON::ParserError
|
|
313
|
-
raise "#{label} returned invalid JSON"
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def open_url(url)
|
|
317
|
-
command = if RUBY_PLATFORM.match?(/darwin/)
|
|
318
|
-
"open"
|
|
319
|
-
elsif RUBY_PLATFORM.match?(/linux/)
|
|
320
|
-
"xdg-open"
|
|
321
|
-
end
|
|
322
|
-
return false unless command
|
|
323
|
-
|
|
324
|
-
system(command, url, out: File::NULL, err: File::NULL)
|
|
325
|
-
end
|
|
326
244
|
end
|
|
327
245
|
end
|
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -53,11 +53,12 @@ module Kward
|
|
|
53
53
|
#{command.call("kward auth status")} Show saved credential status
|
|
54
54
|
#{command.call("kward init")} Install starter prompts and PRINCIPLES.md
|
|
55
55
|
#{command.call("kward doctor")} Check local Kward setup
|
|
56
|
+
#{command.call("kward hooks doctor")} Inspect lifecycle hook setup
|
|
56
57
|
#{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
|
|
57
58
|
#{command.call("kward sysprompt")} Inspect the effective system prompt
|
|
58
59
|
#{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
|
|
59
60
|
#{command.call("kward pan")} Start Pan mode web UI
|
|
60
|
-
#{command.call("kward rpc")} Start the
|
|
61
|
+
#{command.call("kward rpc")} Start the JSON-RPC backend
|
|
61
62
|
|
|
62
63
|
#{heading.call("Commands")}
|
|
63
64
|
#{command.call("help")} Show this help
|
|
@@ -66,6 +67,7 @@ module Kward
|
|
|
66
67
|
#{command.call("auth status|logout")} Show or clear saved credentials
|
|
67
68
|
#{command.call("init")} Install starter prompts and PRINCIPLES.md
|
|
68
69
|
#{command.call("doctor")} Check local Kward setup
|
|
70
|
+
#{command.call("hooks list|events|logs|doctor|trust|untrust")} Inspect lifecycle hooks
|
|
69
71
|
#{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
|
|
70
72
|
#{command.call("sysprompt")} [--raw] Inspect the effective system prompt
|
|
71
73
|
#{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
|
|
@@ -127,6 +129,11 @@ module Kward
|
|
|
127
129
|
description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
|
|
128
130
|
examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
|
|
129
131
|
},
|
|
132
|
+
"hooks" => {
|
|
133
|
+
usage: "kward hooks [list|events|logs|doctor|trust|untrust]",
|
|
134
|
+
description: "Inspect lifecycle hooks, recent audit records, diagnostics, and workspace hook trust.",
|
|
135
|
+
examples: ["kward hooks list", "kward hooks doctor", "kward hooks logs 50", "kward hooks trust"]
|
|
136
|
+
},
|
|
130
137
|
"edit" => {
|
|
131
138
|
usage: "kward edit <filename>",
|
|
132
139
|
description: "Open a file in the integrated editor.",
|
|
@@ -154,7 +161,7 @@ module Kward
|
|
|
154
161
|
},
|
|
155
162
|
"rpc" => {
|
|
156
163
|
usage: "kward rpc",
|
|
157
|
-
description: "Start the
|
|
164
|
+
description: "Start the JSON-RPC backend for trusted local UI clients.",
|
|
158
165
|
examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
|
|
159
166
|
}
|
|
160
167
|
}
|
data/lib/kward/cli/compaction.rb
CHANGED
|
@@ -7,11 +7,18 @@ module Kward
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def compact_context(agent, argument)
|
|
10
|
+
before = run_lifecycle_hook("session_compact_before", conversation: agent.conversation, payload: { instructions: argument.to_s })
|
|
11
|
+
if before.denied? || before.approval_required?
|
|
12
|
+
runtime_output("Declined: #{before.decision.message || "session compaction denied"}")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
result = Compactor.new(
|
|
11
17
|
conversation: agent.conversation,
|
|
12
18
|
client: @client,
|
|
13
19
|
tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
|
|
14
20
|
).compact(custom_instructions: argument)
|
|
21
|
+
run_lifecycle_hook("session_compact_after", conversation: agent.conversation, payload: { old_message_count: result.old_message_count, new_message_count: result.new_message_count })
|
|
15
22
|
runtime_output("Compacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.")
|
|
16
23
|
render_transcript_block("Assistant", result.summary)
|
|
17
24
|
rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
|