kward 0.76.0 → 0.77.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/.github/workflows/ci.yml +8 -1
- data/CHANGELOG.md +36 -0
- data/Gemfile.lock +2 -2
- data/README.md +12 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/composer.md +219 -0
- data/doc/configuration.md +75 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/rpc.md +0 -22
- data/doc/security.md +180 -0
- data/doc/usage.md +15 -32
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +2 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/runtime_helpers.rb +0 -38
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +14 -2
- data/lib/kward/cli.rb +1 -17
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +18 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/plugin_registry.rb +18 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +18 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/server.rb +13 -41
- data/lib/kward/rpc/session_manager.rb +75 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -0
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/registry.rb +11 -16
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/search/web.rb +1 -1
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +5 -1
- data/templates/default/layout/html/layout.erb +0 -2
- metadata +5 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
data/lib/kward/pan/server.rb
CHANGED
|
@@ -13,22 +13,28 @@ require_relative "../plugin_registry"
|
|
|
13
13
|
require_relative "../prompts/commands"
|
|
14
14
|
require_relative "../rpc/transcript_normalizer"
|
|
15
15
|
require_relative "../session_store"
|
|
16
|
+
require_relative "../session_trash"
|
|
16
17
|
require_relative "../tools/tool_call"
|
|
17
18
|
require_relative "../tools/registry"
|
|
19
|
+
require_relative "../version"
|
|
18
20
|
require_relative "../workspace"
|
|
19
21
|
|
|
20
22
|
# Namespace for the Kward CLI agent runtime.
|
|
21
23
|
module Kward
|
|
22
|
-
#
|
|
24
|
+
# Local HTTP server for the mobile-friendly Pan web UI.
|
|
23
25
|
class PanServer
|
|
24
26
|
DEFAULT_HOST = "0.0.0.0"
|
|
25
27
|
DEFAULT_PORT = 8765
|
|
26
28
|
MAX_REQUEST_BODY_BYTES = 64 * 1024
|
|
29
|
+
ROUTING_PROBE_ADDRESS = "8.8.8.8"
|
|
30
|
+
ROUTING_PROBE_PORT = 80
|
|
27
31
|
|
|
28
|
-
def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr)
|
|
32
|
+
def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr, udp_socket_class: UDPSocket)
|
|
29
33
|
@client = client
|
|
30
34
|
@output = output
|
|
35
|
+
@udp_socket_class = udp_socket_class
|
|
31
36
|
@workspace = Workspace.new(root: working_directory)
|
|
37
|
+
@full_config = config
|
|
32
38
|
@config = pan_config(config)
|
|
33
39
|
@host = @config.fetch("host", DEFAULT_HOST).to_s
|
|
34
40
|
@port = positive_port(@config.fetch("port", DEFAULT_PORT))
|
|
@@ -37,28 +43,22 @@ module Kward
|
|
|
37
43
|
raise "Pan mode requires pan_mode.username and pan_mode.password in #{ConfigFiles.config_path}" if @username.empty? || @password.empty?
|
|
38
44
|
|
|
39
45
|
@session_store = SessionStore.new(config_dir: config_dir, cwd: @workspace.root.to_s)
|
|
40
|
-
@session = @session_store.create
|
|
41
|
-
|
|
42
|
-
workspace_root: @workspace.root.to_s,
|
|
46
|
+
@session = @session_store.create(
|
|
47
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
43
48
|
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
44
49
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
45
50
|
)
|
|
51
|
+
@conversation = new_conversation
|
|
46
52
|
@session.attach(@conversation)
|
|
47
|
-
|
|
48
|
-
hook_manager = lifecycle_hook_manager
|
|
49
|
-
@agent = Agent.new(
|
|
50
|
-
client: @client,
|
|
51
|
-
tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
|
|
52
|
-
conversation: @conversation,
|
|
53
|
-
hook_manager: hook_manager,
|
|
54
|
-
hook_context: hook_context
|
|
55
|
-
)
|
|
53
|
+
@agent = build_agent
|
|
56
54
|
@prompt_queue = Queue.new
|
|
57
55
|
@subscribers = []
|
|
58
56
|
@subscribers_mutex = Mutex.new
|
|
59
57
|
@worker_started = false
|
|
60
58
|
@active = false
|
|
59
|
+
@pending_turns = 0
|
|
61
60
|
@state_mutex = Mutex.new
|
|
61
|
+
@session_mutex = Mutex.new
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
attr_reader :host, :port, :session, :workspace
|
|
@@ -91,8 +91,11 @@ module Kward
|
|
|
91
91
|
text = prompt.to_s
|
|
92
92
|
return { ok: false, error: "Prompt is required" } if text.strip.empty?
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
@session_mutex.synchronize do
|
|
95
|
+
queued_at = Time.now.utc.iso8601(3)
|
|
96
|
+
@state_mutex.synchronize { @pending_turns += 1 }
|
|
97
|
+
@prompt_queue << { prompt: text, queued_at: queued_at }
|
|
98
|
+
end
|
|
96
99
|
broadcast("queue", queue_payload)
|
|
97
100
|
{ ok: true, queued: @prompt_queue.size, active: active? }
|
|
98
101
|
end
|
|
@@ -103,6 +106,27 @@ module Kward
|
|
|
103
106
|
|
|
104
107
|
private
|
|
105
108
|
|
|
109
|
+
def new_conversation
|
|
110
|
+
Conversation.new(
|
|
111
|
+
workspace_root: @workspace.root.to_s,
|
|
112
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
113
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
114
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_agent
|
|
119
|
+
hook_context = lifecycle_hook_context
|
|
120
|
+
hook_manager = lifecycle_hook_manager
|
|
121
|
+
Agent.new(
|
|
122
|
+
client: @client,
|
|
123
|
+
tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
|
|
124
|
+
conversation: @conversation,
|
|
125
|
+
hook_manager: hook_manager,
|
|
126
|
+
hook_context: hook_context
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
106
130
|
def lifecycle_hook_manager
|
|
107
131
|
manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
|
|
108
132
|
manager.on_result = method(:broadcast_lifecycle_hook_event)
|
|
@@ -185,7 +209,10 @@ module Kward
|
|
|
185
209
|
broadcast("error", { message: e.message })
|
|
186
210
|
broadcast("turn_finished", queue_payload.merge(status: "failed"))
|
|
187
211
|
ensure
|
|
188
|
-
|
|
212
|
+
@state_mutex.synchronize do
|
|
213
|
+
@active = false
|
|
214
|
+
@pending_turns -= 1 if @pending_turns.positive?
|
|
215
|
+
end
|
|
189
216
|
broadcast("queue", queue_payload)
|
|
190
217
|
end
|
|
191
218
|
|
|
@@ -201,12 +228,18 @@ module Kward
|
|
|
201
228
|
case [request[:method], request[:path]]
|
|
202
229
|
when ["GET", "/"]
|
|
203
230
|
write_response(socket, 200, { "Content-Type" => "text/html; charset=utf-8" }, render_index)
|
|
231
|
+
when ["GET", "/kward-logo.png"]
|
|
232
|
+
write_response(socket, 200, { "Content-Type" => "image/png", "Cache-Control" => "public, max-age=86400" }, File.binread(File.join(__dir__, "kward_logo.png")))
|
|
204
233
|
when ["GET", "/transcript"]
|
|
205
|
-
write_json(socket, 200, transcript: transcript_items, session:
|
|
234
|
+
write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
|
|
235
|
+
when ["GET", "/sessions"]
|
|
236
|
+
write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
|
|
206
237
|
when ["GET", "/events"]
|
|
207
238
|
stream_events(socket)
|
|
208
239
|
when ["POST", "/turn"]
|
|
209
|
-
handle_turn(socket, request[:body])
|
|
240
|
+
handle_turn(socket, request[:body], request[:headers])
|
|
241
|
+
when ["POST", "/sessions"]
|
|
242
|
+
handle_session_action(socket, request[:body], request[:headers])
|
|
210
243
|
else
|
|
211
244
|
write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
|
|
212
245
|
end
|
|
@@ -254,7 +287,9 @@ module Kward
|
|
|
254
287
|
left.bytes.zip(right.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
|
|
255
288
|
end
|
|
256
289
|
|
|
257
|
-
def handle_turn(socket, body)
|
|
290
|
+
def handle_turn(socket, body, headers)
|
|
291
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
292
|
+
|
|
258
293
|
params = JSON.parse(body.empty? ? "{}" : body)
|
|
259
294
|
result = enqueue_prompt(params["prompt"])
|
|
260
295
|
status = result[:ok] ? 202 : 422
|
|
@@ -263,6 +298,155 @@ module Kward
|
|
|
263
298
|
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
264
299
|
end
|
|
265
300
|
|
|
301
|
+
def handle_session_action(socket, body, headers)
|
|
302
|
+
return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
|
|
303
|
+
|
|
304
|
+
params = JSON.parse(body.empty? ? "{}" : body)
|
|
305
|
+
result = update_session(params)
|
|
306
|
+
write_json(socket, result.delete(:status), result)
|
|
307
|
+
rescue JSON::ParserError
|
|
308
|
+
write_json(socket, 400, ok: false, error: "Invalid JSON")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def update_session(params)
|
|
312
|
+
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
313
|
+
|
|
314
|
+
result = @session_mutex.synchronize do
|
|
315
|
+
return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
|
|
316
|
+
|
|
317
|
+
case params["action"]
|
|
318
|
+
when "new" then create_session
|
|
319
|
+
when "resume" then resume_session(params["id"])
|
|
320
|
+
when "rename" then rename_session(params["id"], params["name"])
|
|
321
|
+
when "delete" then delete_session(params["id"])
|
|
322
|
+
else { status: 422, ok: false, error: "Unknown session action" }
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
broadcast("session_changed", result[:session]) if result[:ok] && result[:session]
|
|
326
|
+
result
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
{ status: 422, ok: false, error: e.message }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def create_session
|
|
332
|
+
previous = @session
|
|
333
|
+
session = @session_store.create(
|
|
334
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
335
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
336
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
337
|
+
)
|
|
338
|
+
conversation = new_conversation
|
|
339
|
+
session.attach(conversation)
|
|
340
|
+
activate_session(session, conversation)
|
|
341
|
+
previous.delete_if_unused
|
|
342
|
+
{ status: 200, ok: true, session: active_session_payload }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def resume_session(id)
|
|
346
|
+
info = find_session(id)
|
|
347
|
+
return { status: 404, ok: false, error: "Session not found" } unless info
|
|
348
|
+
return { status: 200, ok: true, session: active_session_payload } if info.id == @session.id
|
|
349
|
+
|
|
350
|
+
previous = @session
|
|
351
|
+
session, conversation = @session_store.load(
|
|
352
|
+
info.path,
|
|
353
|
+
workspace: @workspace,
|
|
354
|
+
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
355
|
+
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
356
|
+
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
|
|
357
|
+
)
|
|
358
|
+
activate_session(session, conversation)
|
|
359
|
+
previous.delete_if_unused
|
|
360
|
+
{ status: 200, ok: true, session: active_session_payload }
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def rename_session(id, name)
|
|
364
|
+
clean_name = name.to_s.strip
|
|
365
|
+
return { status: 422, ok: false, error: "Session name is too long" } if clean_name.length > 120
|
|
366
|
+
|
|
367
|
+
target = id.to_s == @session.id ? @session : find_session(id)
|
|
368
|
+
return { status: 404, ok: false, error: "Session not found" } unless target
|
|
369
|
+
|
|
370
|
+
session = target.respond_to?(:rename) ? target : @session_store.load(target.path, workspace: @workspace).first
|
|
371
|
+
session.rename(clean_name)
|
|
372
|
+
@session.name = session.name if session.id == @session.id
|
|
373
|
+
{ status: 200, ok: true, session: session_payload(session) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def delete_session(id)
|
|
377
|
+
if id.to_s == @session.id
|
|
378
|
+
deleted_id = @session.id
|
|
379
|
+
deleted_path = @session.path
|
|
380
|
+
create_session
|
|
381
|
+
SessionTrash.new.delete(deleted_path)
|
|
382
|
+
return { status: 200, ok: true, deletedSessionId: deleted_id, session: active_session_payload }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
info = find_session(id)
|
|
386
|
+
return { status: 404, ok: false, error: "Session not found" } unless info
|
|
387
|
+
|
|
388
|
+
SessionTrash.new.delete(info.path)
|
|
389
|
+
{ status: 200, ok: true, deletedSessionId: info.id }
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def activate_session(session, conversation)
|
|
393
|
+
@session = session
|
|
394
|
+
@conversation = conversation
|
|
395
|
+
@agent = build_agent
|
|
396
|
+
@session_store.remember_last_session(session)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def find_session(id)
|
|
400
|
+
session_infos.find { |info| info.id.to_s == id.to_s }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def session_infos
|
|
404
|
+
@session_store.recent(limit: 50, keep_empty_path: @session.path)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def session_payloads
|
|
408
|
+
sessions = session_infos.map { |info| session_payload(info) }
|
|
409
|
+
sessions.unshift(active_session_payload) unless sessions.any? { |item| item[:id] == @session.id }
|
|
410
|
+
sessions.sort_by { |item| item[:modifiedAt].to_s }.reverse
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def active_session_payload
|
|
414
|
+
info = session_infos.find { |item| item.id == @session.id }
|
|
415
|
+
return session_payload(info).merge(active: true, path: @session.path) if info
|
|
416
|
+
|
|
417
|
+
session_payload(@session).merge(
|
|
418
|
+
active: true,
|
|
419
|
+
path: @session.path,
|
|
420
|
+
modifiedAt: File.mtime(@session.path).utc.iso8601(3),
|
|
421
|
+
preview: transcript_items.find { |item| item[:role] == "user" }&.fetch(:text, "") || "",
|
|
422
|
+
messageCount: transcript_items.length
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def session_payload(session)
|
|
427
|
+
name = session.name.to_s.strip
|
|
428
|
+
preview = session.respond_to?(:first_message) ? session.first_message.to_s : ""
|
|
429
|
+
{
|
|
430
|
+
id: session.id,
|
|
431
|
+
name: name.empty? ? nil : name,
|
|
432
|
+
title: name.empty? ? (preview.empty? ? "New session" : preview) : name,
|
|
433
|
+
preview: preview,
|
|
434
|
+
createdAt: session.created_at&.utc&.iso8601(3),
|
|
435
|
+
modifiedAt: (session.respond_to?(:modified_at) ? session.modified_at&.utc&.iso8601(3) : nil),
|
|
436
|
+
messageCount: (session.respond_to?(:message_count) ? session.message_count.to_i : 0),
|
|
437
|
+
model: (session.respond_to?(:model) ? session.model : @conversation.model),
|
|
438
|
+
active: session.id == @session.id
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def turns_pending?
|
|
443
|
+
@state_mutex.synchronize { @pending_turns.positive? }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def json_request?(headers)
|
|
447
|
+
headers["content-type"].to_s.downcase.start_with?("application/json")
|
|
448
|
+
end
|
|
449
|
+
|
|
266
450
|
def stream_events(socket)
|
|
267
451
|
subscriber = Queue.new
|
|
268
452
|
subscribe(subscriber)
|
|
@@ -316,11 +500,38 @@ module Kward
|
|
|
316
500
|
|
|
317
501
|
def render_index
|
|
318
502
|
@workspace_root = @workspace.root.to_s
|
|
503
|
+
@workspace_label = pan_workspace_label
|
|
504
|
+
@assistant_label = assistant_label
|
|
319
505
|
@session_path = @session.path
|
|
506
|
+
@version = Kward::VERSION
|
|
320
507
|
template = File.read(File.join(__dir__, "index.html.erb"))
|
|
321
508
|
ERB.new(template).result(binding)
|
|
322
509
|
end
|
|
323
510
|
|
|
511
|
+
def assistant_label
|
|
512
|
+
ConfigFiles.active_persona_label(
|
|
513
|
+
workspace_root: @conversation.workspace_root,
|
|
514
|
+
model: @conversation.model,
|
|
515
|
+
config: @full_config
|
|
516
|
+
) || "Assistant"
|
|
517
|
+
rescue StandardError
|
|
518
|
+
"Assistant"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def pan_workspace_label
|
|
522
|
+
root = File.expand_path(@workspace.root.to_s)
|
|
523
|
+
home = Dir.home
|
|
524
|
+
if root == home || root.start_with?("#{home}/")
|
|
525
|
+
relative = root.delete_prefix(home).delete_prefix("/")
|
|
526
|
+
return "~" if relative.empty?
|
|
527
|
+
return "~/#{relative}" unless relative.include?("/")
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
[File.basename(File.dirname(root)), File.basename(root)].reject(&:empty?).join("/")
|
|
531
|
+
rescue StandardError
|
|
532
|
+
@workspace.root.to_s
|
|
533
|
+
end
|
|
534
|
+
|
|
324
535
|
def pan_config(config)
|
|
325
536
|
values = config["pan_mode"]
|
|
326
537
|
values.is_a?(Hash) ? values : {}
|
|
@@ -332,7 +543,19 @@ module Kward
|
|
|
332
543
|
end
|
|
333
544
|
|
|
334
545
|
def display_host
|
|
335
|
-
@host == "0.0.0.0"
|
|
546
|
+
return @host unless @host == "0.0.0.0"
|
|
547
|
+
|
|
548
|
+
lan_address || "<lan-address>"
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def lan_address
|
|
552
|
+
socket = @udp_socket_class.new
|
|
553
|
+
socket.connect(ROUTING_PROBE_ADDRESS, ROUTING_PROBE_PORT)
|
|
554
|
+
socket.addr.last
|
|
555
|
+
rescue SocketError, SystemCallError
|
|
556
|
+
nil
|
|
557
|
+
ensure
|
|
558
|
+
socket&.close
|
|
336
559
|
end
|
|
337
560
|
|
|
338
561
|
def set_active(value)
|
|
@@ -385,9 +608,9 @@ module Kward
|
|
|
385
608
|
text.to_s.empty? ? nil : { role: "reasoning", label: "Reasoning", text: text }
|
|
386
609
|
when "text"
|
|
387
610
|
text = part[:text] || part["text"]
|
|
388
|
-
text.to_s.empty? ? nil : { role: "assistant", label:
|
|
611
|
+
text.to_s.empty? ? nil : { role: "assistant", label: assistant_label, text: text }
|
|
389
612
|
when "image"
|
|
390
|
-
{ role: "assistant", label:
|
|
613
|
+
{ role: "assistant", label: assistant_label, text: image_part_text(part) }
|
|
391
614
|
when "toolCall"
|
|
392
615
|
{ role: "tool", label: "Tool", text: tool_call_part_text(part) }
|
|
393
616
|
end
|
|
@@ -442,6 +665,8 @@ module Kward
|
|
|
442
665
|
when 400 then "Bad Request"
|
|
443
666
|
when 401 then "Unauthorized"
|
|
444
667
|
when 404 then "Not Found"
|
|
668
|
+
when 409 then "Conflict"
|
|
669
|
+
when 415 then "Unsupported Media Type"
|
|
445
670
|
when 422 then "Unprocessable Content"
|
|
446
671
|
else "Internal Server Error"
|
|
447
672
|
end
|
|
@@ -87,14 +87,17 @@ module Kward
|
|
|
87
87
|
nil
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# @return [String, nil] active session identifier
|
|
90
91
|
def session_id
|
|
91
92
|
@session&.id
|
|
92
93
|
end
|
|
93
94
|
|
|
95
|
+
# @return [String, nil] human-readable active session name
|
|
94
96
|
def session_name
|
|
95
97
|
@session&.name
|
|
96
98
|
end
|
|
97
99
|
|
|
100
|
+
# @return [String, nil] saved active session path
|
|
98
101
|
def session_path
|
|
99
102
|
@session&.path
|
|
100
103
|
end
|
|
@@ -108,30 +111,45 @@ module Kward
|
|
|
108
111
|
nil
|
|
109
112
|
end
|
|
110
113
|
|
|
114
|
+
# Allows the current lifecycle event to continue.
|
|
115
|
+
# @return [Hooks::Decision]
|
|
111
116
|
def allow(message = nil, metadata: nil)
|
|
112
117
|
Hooks::Decision.allow(message, metadata: metadata)
|
|
113
118
|
end
|
|
114
119
|
|
|
120
|
+
# Denies the current lifecycle event.
|
|
121
|
+
# @return [Hooks::Decision]
|
|
115
122
|
def deny(message = nil, metadata: nil)
|
|
116
123
|
Hooks::Decision.deny(message, metadata: metadata)
|
|
117
124
|
end
|
|
118
125
|
|
|
126
|
+
# Requests frontend approval for the current lifecycle event.
|
|
127
|
+
# @return [Hooks::Decision]
|
|
119
128
|
def ask(message = nil, metadata: nil)
|
|
120
129
|
Hooks::Decision.ask(message, metadata: metadata)
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
# Continues with an event-specific payload replacement.
|
|
133
|
+
# @param payload [Hash] replacement fields supported by the event
|
|
134
|
+
# @return [Hooks::Decision]
|
|
123
135
|
def modify(payload, message: nil, metadata: nil)
|
|
124
136
|
Hooks::Decision.modify(payload, message: message, metadata: metadata)
|
|
125
137
|
end
|
|
126
138
|
|
|
139
|
+
# Allows the event while recording a warning.
|
|
140
|
+
# @return [Hooks::Decision]
|
|
127
141
|
def warn(message = nil, metadata: nil)
|
|
128
142
|
Hooks::Decision.warn(message, metadata: metadata)
|
|
129
143
|
end
|
|
130
144
|
|
|
145
|
+
# Requests a retry when the current event supports it.
|
|
146
|
+
# @return [Hooks::Decision]
|
|
131
147
|
def retry(message = nil, payload: nil, metadata: nil)
|
|
132
148
|
Hooks::Decision.retry(message, payload: payload, metadata: metadata)
|
|
133
149
|
end
|
|
134
150
|
|
|
151
|
+
# Defers the event when the current workflow supports it.
|
|
152
|
+
# @return [Hooks::Decision]
|
|
135
153
|
def defer(message = nil, payload: nil, metadata: nil)
|
|
136
154
|
Hooks::Decision.defer(message, payload: payload, metadata: metadata)
|
|
137
155
|
end
|
|
@@ -177,7 +177,7 @@ module Kward
|
|
|
177
177
|
def editor_expected_indent_for_line(line_index)
|
|
178
178
|
line = @editor_state.lines[line_index].to_s
|
|
179
179
|
code = editor_indent_code(line, editor_syntax_language).strip
|
|
180
|
-
matching_indent = editor_matching_indent_for_line(code)
|
|
180
|
+
matching_indent = editor_matching_indent_for_line(code, line_index)
|
|
181
181
|
return matching_indent if matching_indent
|
|
182
182
|
|
|
183
183
|
previous_line = previous_non_blank_editor_line(line_index)
|
|
@@ -188,11 +188,11 @@ module Kward
|
|
|
188
188
|
indent
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
-
def editor_matching_indent_for_line(code)
|
|
191
|
+
def editor_matching_indent_for_line(code, line_index = nil)
|
|
192
192
|
return nil if code.empty?
|
|
193
|
-
return editor_matching_punctuation_indent(code[0]) if editor_closing_punctuation?(code[0])
|
|
193
|
+
return editor_matching_punctuation_indent(code[0], line_index) if editor_closing_punctuation?(code[0])
|
|
194
194
|
|
|
195
|
-
editor_matching_word_indent if editor_completed_word_closer?(code, editor_syntax_language)
|
|
195
|
+
editor_matching_word_indent(line_index) if editor_completed_word_closer?(code, editor_syntax_language)
|
|
196
196
|
end
|
|
197
197
|
|
|
198
198
|
def previous_non_blank_editor_line(line_index)
|
|
@@ -371,7 +371,8 @@ module Kward
|
|
|
371
371
|
def editor_reindent_for_closing_punctuation(text)
|
|
372
372
|
return unless editor_cursor_in_leading_indent?
|
|
373
373
|
|
|
374
|
-
|
|
374
|
+
line_index, = @editor_state.cursor_line_and_column
|
|
375
|
+
indent = editor_matching_punctuation_indent(text, line_index)
|
|
375
376
|
editor_reindent_current_line(indent) if indent
|
|
376
377
|
end
|
|
377
378
|
|
|
@@ -381,7 +382,7 @@ module Kward
|
|
|
381
382
|
before_cursor = line[0...column].to_s
|
|
382
383
|
return unless editor_completed_word_closer?(before_cursor, editor_syntax_language)
|
|
383
384
|
|
|
384
|
-
indent = editor_matching_word_indent
|
|
385
|
+
indent = editor_matching_word_indent(line_index)
|
|
385
386
|
editor_reindent_current_line(indent) if indent
|
|
386
387
|
end
|
|
387
388
|
|
|
@@ -394,14 +395,21 @@ module Kward
|
|
|
394
395
|
def editor_reindent_current_line(indent)
|
|
395
396
|
line_index, column = @editor_state.cursor_line_and_column
|
|
396
397
|
line_start = @editor_state.line_start_offset(line_index)
|
|
397
|
-
|
|
398
|
-
|
|
398
|
+
old_indent = @editor_state.lines[line_index].to_s[/\A[ \t]*/].to_s
|
|
399
|
+
return false unless editor_reindent_line(line_index, indent)
|
|
400
|
+
|
|
401
|
+
content_column = [column - old_indent.length, 0].max
|
|
402
|
+
@editor_state.cursor = line_start + indent.to_s.length + content_column
|
|
403
|
+
true
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def editor_reindent_line(line_index, indent)
|
|
407
|
+
line_start = @editor_state.line_start_offset(line_index)
|
|
408
|
+
old_indent = @editor_state.lines[line_index].to_s[/\A[ \t]*/].to_s
|
|
399
409
|
new_indent = indent.to_s
|
|
400
410
|
return false if old_indent == new_indent
|
|
401
411
|
|
|
402
|
-
content_column = [column - old_indent.length, 0].max
|
|
403
412
|
@editor_state.replace_range(line_start, line_start + old_indent.length, new_indent)
|
|
404
|
-
@editor_state.cursor = line_start + new_indent.length + content_column
|
|
405
413
|
true
|
|
406
414
|
end
|
|
407
415
|
|
|
@@ -421,23 +429,23 @@ module Kward
|
|
|
421
429
|
end
|
|
422
430
|
end
|
|
423
431
|
|
|
424
|
-
def editor_matching_word_indent
|
|
432
|
+
def editor_matching_word_indent(line_index = nil)
|
|
425
433
|
case editor_syntax_language
|
|
426
434
|
when :ruby
|
|
427
|
-
editor_matching_keyword_indent("end", %w[end])
|
|
435
|
+
editor_matching_keyword_indent("end", %w[end], line_index)
|
|
428
436
|
when :lua
|
|
429
|
-
editor_matching_keyword_indent("end", %w[end until])
|
|
437
|
+
editor_matching_keyword_indent("end", %w[end until], line_index)
|
|
430
438
|
when :shell
|
|
431
|
-
editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS)
|
|
439
|
+
editor_matching_keyword_indent(nil, SHELL_DEDENT_KEYWORDS, line_index)
|
|
432
440
|
when :html
|
|
433
|
-
editor_matching_html_indent
|
|
441
|
+
editor_matching_html_indent(line_index)
|
|
434
442
|
end
|
|
435
443
|
end
|
|
436
444
|
|
|
437
|
-
def editor_matching_punctuation_indent(text)
|
|
445
|
+
def editor_matching_punctuation_indent(text, line_index = nil)
|
|
438
446
|
opener = PUNCTUATION_PAIRS[text]
|
|
439
447
|
stack = []
|
|
440
|
-
editor_previous_code_lines.each do |line|
|
|
448
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
441
449
|
editor_scan_punctuation_tokens(line[:code]).each do |token|
|
|
442
450
|
if token == opener
|
|
443
451
|
stack << line[:indent]
|
|
@@ -449,9 +457,9 @@ module Kward
|
|
|
449
457
|
stack.last || ""
|
|
450
458
|
end
|
|
451
459
|
|
|
452
|
-
def editor_matching_keyword_indent(opener = nil, closers = [])
|
|
460
|
+
def editor_matching_keyword_indent(opener = nil, closers = [], line_index = nil)
|
|
453
461
|
stack = []
|
|
454
|
-
editor_previous_code_lines.each do |line|
|
|
462
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
455
463
|
code = line[:code].strip
|
|
456
464
|
next if code.empty?
|
|
457
465
|
|
|
@@ -461,9 +469,9 @@ module Kward
|
|
|
461
469
|
stack.last || ""
|
|
462
470
|
end
|
|
463
471
|
|
|
464
|
-
def editor_matching_html_indent
|
|
472
|
+
def editor_matching_html_indent(line_index = nil)
|
|
465
473
|
stack = []
|
|
466
|
-
editor_previous_code_lines.each do |line|
|
|
474
|
+
editor_previous_code_lines(line_index).each do |line|
|
|
467
475
|
code = line[:code].strip
|
|
468
476
|
next if code.empty?
|
|
469
477
|
|
|
@@ -473,8 +481,8 @@ module Kward
|
|
|
473
481
|
stack.last || ""
|
|
474
482
|
end
|
|
475
483
|
|
|
476
|
-
def editor_previous_code_lines
|
|
477
|
-
line_index
|
|
484
|
+
def editor_previous_code_lines(line_index = nil)
|
|
485
|
+
line_index ||= @editor_state.cursor_line_and_column.first
|
|
478
486
|
@editor_state.lines.first(line_index).filter_map do |line|
|
|
479
487
|
code = editor_indent_code(line, editor_syntax_language).rstrip
|
|
480
488
|
next if code.strip.empty?
|
|
@@ -452,9 +452,10 @@ module Kward
|
|
|
452
452
|
return true if command.match?(/\A\d*g\z/)
|
|
453
453
|
return true if command.match?(/\A\d*g[uU~]\z/)
|
|
454
454
|
return true if command.match?(/\A\d*z\z/)
|
|
455
|
-
return true if command.match?(/\A\d*[cdy]\d
|
|
456
|
-
return true if command.match?(/\A\d*[cdy]\d*
|
|
457
|
-
return true if command.match?(/\A\d*[cdy]\d*[
|
|
455
|
+
return true if command.match?(/\A\d*[=cdy](?:[1-9]\d*)?\z/)
|
|
456
|
+
return true if command.match?(/\A\d*[=cdy]\d*g\z/)
|
|
457
|
+
return true if command.match?(/\A\d*[=cdy]\d*[ai]\z/)
|
|
458
|
+
return true if command.match?(/\A\d*[=cdy]\d*[fFtT]\z/)
|
|
458
459
|
return true if command.match?(/\A\d*[fFtT]\z/)
|
|
459
460
|
return true if command.match?(/\A\d*r\z/)
|
|
460
461
|
return true if command.match?(/\Am\z/)
|
|
@@ -635,6 +636,10 @@ module Kward
|
|
|
635
636
|
when "<<"
|
|
636
637
|
vibe_indent_lines(count, :left)
|
|
637
638
|
vibe_remember_change(command)
|
|
639
|
+
when "=="
|
|
640
|
+
vibe_reindent_lines(count, command)
|
|
641
|
+
when /\A=(.+)\z/
|
|
642
|
+
vibe_reindent_operator(Regexp.last_match(1), count, command)
|
|
638
643
|
when "p"
|
|
639
644
|
vibe_active_register_linewise? ? vibe_paste_line(:below, original_command) : vibe_paste_after(original_command)
|
|
640
645
|
when "P"
|
|
@@ -704,6 +709,8 @@ module Kward
|
|
|
704
709
|
vibe_indent_visual_selection(:right)
|
|
705
710
|
when "<"
|
|
706
711
|
vibe_indent_visual_selection(:left)
|
|
712
|
+
when "="
|
|
713
|
+
vibe_reindent_visual_selection
|
|
707
714
|
when "J"
|
|
708
715
|
vibe_join_visual_selection
|
|
709
716
|
when "~"
|
|
@@ -1077,6 +1084,18 @@ module Kward
|
|
|
1077
1084
|
vibe_cancel_visual_mode
|
|
1078
1085
|
end
|
|
1079
1086
|
|
|
1087
|
+
def vibe_reindent_visual_selection
|
|
1088
|
+
ranges = @editor_state.selection_ranges
|
|
1089
|
+
return false if ranges.empty?
|
|
1090
|
+
|
|
1091
|
+
start_index = ranges.map(&:first).min
|
|
1092
|
+
end_index = ranges.map(&:last).max
|
|
1093
|
+
start_line, = @editor_state.cursor_line_and_column_for(start_index)
|
|
1094
|
+
end_line, = @editor_state.cursor_line_and_column_for([end_index - 1, start_index].max)
|
|
1095
|
+
vibe_reindent_line_range(start_line, end_line)
|
|
1096
|
+
vibe_cancel_visual_mode
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1080
1099
|
def vibe_count_and_body(command)
|
|
1081
1100
|
return [0, "0"] if command == "0"
|
|
1082
1101
|
|
|
@@ -1284,6 +1303,56 @@ module Kward
|
|
|
1284
1303
|
true
|
|
1285
1304
|
end
|
|
1286
1305
|
|
|
1306
|
+
def vibe_reindent_lines(count, command = nil)
|
|
1307
|
+
line, = @editor_state.cursor_line_and_column
|
|
1308
|
+
vibe_reindent_line_range(line, line + count - 1, command)
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
def vibe_reindent_operator(motion, count, command = nil)
|
|
1312
|
+
motion_count, motion = vibe_count_and_body(motion)
|
|
1313
|
+
return vibe_reindent_to_line(motion_count, command) if motion == "G"
|
|
1314
|
+
return vibe_reindent_to_line(motion_count.positive? ? motion_count : 1, command) if motion == "gg"
|
|
1315
|
+
return vibe_reindent_lines(count, command) if motion == "=" || motion == "g_"
|
|
1316
|
+
|
|
1317
|
+
count *= motion_count if motion_count.positive?
|
|
1318
|
+
target = vibe_operator_target(motion, count)
|
|
1319
|
+
return false unless target
|
|
1320
|
+
|
|
1321
|
+
vibe_reindent_target(target, motion, command)
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
def vibe_reindent_to_line(line_count, command)
|
|
1325
|
+
line, = @editor_state.cursor_line_and_column
|
|
1326
|
+
target_line = line_count.positive? ? line_count - 1 : @editor_state.lines.length - 1
|
|
1327
|
+
vibe_reindent_line_range(line, target_line, command)
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
def vibe_reindent_target(target, motion, command)
|
|
1331
|
+
start_line, = @editor_state.cursor_line_and_column_for(target.start_index)
|
|
1332
|
+
end_line = if motion.match?(/\A[ai].\z/)
|
|
1333
|
+
@editor_state.cursor_line_and_column_for([target.end_index - 1, target.start_index].max).first
|
|
1334
|
+
else
|
|
1335
|
+
@editor_state.cursor_line_and_column.first
|
|
1336
|
+
end
|
|
1337
|
+
vibe_reindent_line_range(start_line, end_line, command)
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
def vibe_reindent_line_range(first_line, last_line, command = nil)
|
|
1341
|
+
start_line, end_line = [first_line, last_line].minmax
|
|
1342
|
+
end_line = [end_line, @editor_state.lines.length - 1].min
|
|
1343
|
+
vibe_record_undo do
|
|
1344
|
+
(start_line..end_line).each do |line_index|
|
|
1345
|
+
next if @editor_state.lines[line_index].to_s.strip.empty?
|
|
1346
|
+
|
|
1347
|
+
editor_reindent_line(line_index, editor_expected_indent_for_line(line_index))
|
|
1348
|
+
end
|
|
1349
|
+
end
|
|
1350
|
+
@editor_state.move_to_line_first_non_blank(end_line)
|
|
1351
|
+
@editor_state.status = "Reindented #{end_line - start_line + 1} line#{end_line == start_line ? "" : "s"}"
|
|
1352
|
+
vibe_remember_change(command) if command
|
|
1353
|
+
true
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1287
1356
|
def vibe_change_lines(count, command = nil)
|
|
1288
1357
|
start_index, end_index = vibe_linewise_change_range(count)
|
|
1289
1358
|
@editor_state.copy_range(start_index, end_index)
|