kward 0.70.0 → 0.72.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/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +67 -4
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/prompts.rb
CHANGED
|
@@ -32,9 +32,11 @@ module Kward
|
|
|
32
32
|
|
|
33
33
|
def base_prompt
|
|
34
34
|
<<~PROMPT.strip
|
|
35
|
-
You are Kward, a concise practical CLI coding agent.
|
|
35
|
+
You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
|
|
36
36
|
|
|
37
|
-
For web research, use web_search to discover sources,
|
|
37
|
+
For web research, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on.
|
|
38
|
+
|
|
39
|
+
Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. Use context_budget_stats when asked about context savings.
|
|
38
40
|
PROMPT
|
|
39
41
|
end
|
|
40
42
|
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "../memory/manager"
|
|
|
5
5
|
require_relative "../plugin_registry"
|
|
6
6
|
require_relative "../prompts/commands"
|
|
7
7
|
require_relative "../tools/registry"
|
|
8
|
+
require_relative "../workers"
|
|
8
9
|
require_relative "../workspace"
|
|
9
10
|
require_relative "../telemetry/logger"
|
|
10
11
|
require_relative "../telemetry/stats"
|
|
@@ -49,7 +50,7 @@ module Kward
|
|
|
49
50
|
"sessions/tree", "sessions/tree/setLabel", "sessions/tree/navigate",
|
|
50
51
|
"sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"
|
|
51
52
|
].freeze
|
|
52
|
-
MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"
|
|
53
|
+
MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
|
|
53
54
|
AUTH_METHODS = [
|
|
54
55
|
"auth/status", "auth/providers", "auth/loginWithApiKey", "auth/logoutProvider",
|
|
55
56
|
"auth/loginWithOAuth", "auth/startOpenAILogin", "auth/submitOpenAICode", "auth/loginStatus"
|
|
@@ -60,14 +61,18 @@ module Kward
|
|
|
60
61
|
"memory/forget", "memory/promote", "memory/relax", "memory/inspect",
|
|
61
62
|
"memory/why", "memory/summarize"
|
|
62
63
|
].freeze
|
|
64
|
+
WORKER_METHODS = ["workers/list", "workers/show"].freeze
|
|
63
65
|
|
|
64
66
|
# Creates the RPC server and its stateful managers.
|
|
65
|
-
def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new)
|
|
67
|
+
def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new, experimental_workers: false)
|
|
66
68
|
@transport = Transport.new(input: input, output: output)
|
|
67
69
|
@error_output = error_output
|
|
70
|
+
@client = client
|
|
68
71
|
@config_manager = ConfigManager.new
|
|
69
72
|
@session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
|
|
70
73
|
@auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
|
|
74
|
+
@worker_store = Workers::Store.new
|
|
75
|
+
@experimental_workers = experimental_workers
|
|
71
76
|
@shutdown = false
|
|
72
77
|
end
|
|
73
78
|
|
|
@@ -88,7 +93,7 @@ module Kward
|
|
|
88
93
|
end
|
|
89
94
|
end
|
|
90
95
|
ensure
|
|
91
|
-
@session_manager.
|
|
96
|
+
@session_manager.shutdown_sessions
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
# Sends a redacted JSON-RPC notification to the client.
|
|
@@ -166,8 +171,6 @@ module Kward
|
|
|
166
171
|
prompts_expand(params)
|
|
167
172
|
when "models/list"
|
|
168
173
|
models_list
|
|
169
|
-
when "openrouter/catalog"
|
|
170
|
-
openrouter_catalog
|
|
171
174
|
when "models/current"
|
|
172
175
|
models_current
|
|
173
176
|
when "models/set"
|
|
@@ -224,6 +227,12 @@ module Kward
|
|
|
224
227
|
@session_manager.memory_why(session_id: params["sessionId"])
|
|
225
228
|
when "memory/summarize"
|
|
226
229
|
@session_manager.memory_summarize(session_id: params.fetch("sessionId"))
|
|
230
|
+
when "workers/list"
|
|
231
|
+
require_experimental_workers!
|
|
232
|
+
workers_list(params)
|
|
233
|
+
when "workers/show"
|
|
234
|
+
require_experimental_workers!
|
|
235
|
+
workers_show(params)
|
|
227
236
|
when "auth/status"
|
|
228
237
|
@auth_manager.status
|
|
229
238
|
when "auth/providers"
|
|
@@ -345,7 +354,7 @@ module Kward
|
|
|
345
354
|
reasoning: { start: false, delta: true, end: false },
|
|
346
355
|
modelRetry: { supported: true, event: "modelRetry" },
|
|
347
356
|
steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
|
|
348
|
-
tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: false, workspaceGuardrails: workspace_guardrails_enabled
|
|
357
|
+
tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: false, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
|
|
349
358
|
errors: true,
|
|
350
359
|
sessionUpdates: false
|
|
351
360
|
},
|
|
@@ -362,7 +371,8 @@ module Kward
|
|
|
362
371
|
supported: true,
|
|
363
372
|
methods: MODEL_METHODS,
|
|
364
373
|
fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
|
|
365
|
-
scopedModels: false
|
|
374
|
+
scopedModels: false,
|
|
375
|
+
openRouterRefresh: { supported: false, reason: "cliOnlyCacheRefresh" }
|
|
366
376
|
},
|
|
367
377
|
runtime: {
|
|
368
378
|
supported: true,
|
|
@@ -385,9 +395,11 @@ module Kward
|
|
|
385
395
|
logout: true
|
|
386
396
|
},
|
|
387
397
|
memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
|
|
398
|
+
workers: workers_capability,
|
|
388
399
|
commands: { supported: true, methods: ["commands/list", "commands/run"], method: "commands/list", runMethod: "commands/run", sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
389
400
|
startupResources: { supported: true, method: "resources/startup" },
|
|
390
401
|
starterPack: { supported: false, reason: "cliOnlyInstallCommand" },
|
|
402
|
+
shell: { supported: false, reason: "interactiveTuiOnly" },
|
|
391
403
|
extensionUi: {
|
|
392
404
|
question: { supported: true, notification: "ui/question", method: "ui/answerQuestion", maxQuestions: 4, multiSelect: false, preview: false },
|
|
393
405
|
select: false,
|
|
@@ -427,6 +439,18 @@ module Kward
|
|
|
427
439
|
}
|
|
428
440
|
end
|
|
429
441
|
|
|
442
|
+
def workers_capability
|
|
443
|
+
return { supported: false, reason: "experimentalWorkersFlagRequired", flag: "--experimental-workers" } unless @experimental_workers
|
|
444
|
+
|
|
445
|
+
{ supported: true, methods: WORKER_METHODS, roles: ["implementation", "request"], statuses: Workers::Worker::STATUSES, transcriptStorage: "sessions", metadataStorage: "json" }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def require_experimental_workers!
|
|
449
|
+
return if @experimental_workers
|
|
450
|
+
|
|
451
|
+
raise NoMethodError, "workers require --experimental-workers"
|
|
452
|
+
end
|
|
453
|
+
|
|
430
454
|
def workspace_info(root)
|
|
431
455
|
root = @session_manager.validate_workspace_root(root)
|
|
432
456
|
{ root: root, basename: File.basename(root), writable: File.writable?(root) }
|
|
@@ -458,10 +482,6 @@ module Kward
|
|
|
458
482
|
{ models: @session_manager.available_models }
|
|
459
483
|
end
|
|
460
484
|
|
|
461
|
-
def openrouter_catalog
|
|
462
|
-
{ models: @session_manager.openrouter_catalog }
|
|
463
|
-
end
|
|
464
|
-
|
|
465
485
|
def config_update(params)
|
|
466
486
|
config = @config_manager.update(params.fetch("values"))
|
|
467
487
|
@session_manager.refresh_client_config
|
|
@@ -576,6 +596,20 @@ module Kward
|
|
|
576
596
|
{ sections: sections }
|
|
577
597
|
end
|
|
578
598
|
|
|
599
|
+
def workers_list(params)
|
|
600
|
+
include_archived = params["includeArchived"] == true
|
|
601
|
+
workers = @worker_store.list(include_archived: include_archived)
|
|
602
|
+
{ workers: workers }
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def workers_show(params)
|
|
606
|
+
id = params.fetch("id").to_s.delete_prefix("#")
|
|
607
|
+
worker = @worker_store.find(id)
|
|
608
|
+
return { worker: worker } if worker
|
|
609
|
+
|
|
610
|
+
raise ArgumentError, "Unknown worker: #{id}"
|
|
611
|
+
end
|
|
612
|
+
|
|
579
613
|
def auth_login_with_api_key(params)
|
|
580
614
|
result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
|
|
581
615
|
@session_manager.refresh_client_config
|
|
@@ -295,12 +295,21 @@ module Kward
|
|
|
295
295
|
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
296
296
|
rpc_sessions.reverse_each do |rpc_session|
|
|
297
297
|
next unless session_idle?(rpc_session)
|
|
298
|
+
next unless rpc_session.session.respond_to?(:delete_if_unused)
|
|
299
|
+
next unless rpc_session.session.delete_if_unused
|
|
298
300
|
|
|
299
|
-
|
|
301
|
+
remove_live_session(rpc_session)
|
|
300
302
|
end
|
|
301
303
|
{ closed: true }
|
|
302
304
|
end
|
|
303
305
|
|
|
306
|
+
# Stops all live RPC session workers during server shutdown.
|
|
307
|
+
def shutdown_sessions
|
|
308
|
+
rpc_sessions = @mutex.synchronize { @sessions.values.dup }
|
|
309
|
+
rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) if session_idle?(rpc_session) }
|
|
310
|
+
{ closed: true }
|
|
311
|
+
end
|
|
312
|
+
|
|
304
313
|
# Returns the normalized transcript for the active RPC session.
|
|
305
314
|
def transcript(session_id:)
|
|
306
315
|
rpc_session = fetch_session(session_id)
|
|
@@ -321,10 +330,7 @@ module Kward
|
|
|
321
330
|
content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
|
|
322
331
|
streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
|
|
323
332
|
if streaming_behavior == "steer"
|
|
324
|
-
|
|
325
|
-
return steered_turn if steered_turn
|
|
326
|
-
|
|
327
|
-
streaming_behavior = "followUp"
|
|
333
|
+
return steer_running_turn(rpc_session, content)
|
|
328
334
|
end
|
|
329
335
|
turn = Turn.new(
|
|
330
336
|
id: SecureRandom.uuid,
|
|
@@ -461,13 +467,7 @@ module Kward
|
|
|
461
467
|
rpc_session = fetch_session(session_id)
|
|
462
468
|
command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
|
|
463
469
|
output = []
|
|
464
|
-
context =
|
|
465
|
-
conversation: rpc_session.conversation,
|
|
466
|
-
args: arguments.to_s,
|
|
467
|
-
session: rpc_session.session,
|
|
468
|
-
workspace_root: rpc_session.workspace_root,
|
|
469
|
-
say_callback: lambda { |message| output << message.to_s }
|
|
470
|
-
)
|
|
470
|
+
context = plugin_context(rpc_session, args: arguments.to_s, say_callback: lambda { |message| output << message.to_s })
|
|
471
471
|
result = command.handler.call(arguments.to_s, context)
|
|
472
472
|
output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
|
|
473
473
|
{ command: command.name, output: output, result: result.nil? ? nil : result.to_s }
|
|
@@ -485,11 +485,6 @@ module Kward
|
|
|
485
485
|
normalized
|
|
486
486
|
end
|
|
487
487
|
|
|
488
|
-
def openrouter_catalog
|
|
489
|
-
models = @client.respond_to?(:openrouter_catalog) ? Array(@client.openrouter_catalog) : []
|
|
490
|
-
models.map { |model| normalize_model(model) }
|
|
491
|
-
end
|
|
492
|
-
|
|
493
488
|
def current_model
|
|
494
489
|
provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
|
|
495
490
|
model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
@@ -503,7 +498,7 @@ module Kward
|
|
|
503
498
|
model = rpc_session.conversation.model || current[:id]
|
|
504
499
|
reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
|
|
505
500
|
reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
|
|
506
|
-
context_window =
|
|
501
|
+
context_window = context_window_for(provider, model)
|
|
507
502
|
normalize_model(
|
|
508
503
|
provider: provider,
|
|
509
504
|
id: model,
|
|
@@ -666,6 +661,11 @@ module Kward
|
|
|
666
661
|
end
|
|
667
662
|
|
|
668
663
|
def normalize_model(model)
|
|
664
|
+
unless model.key?(:contextWindow) || model.key?("contextWindow")
|
|
665
|
+
provider = model[:provider] || model["provider"]
|
|
666
|
+
id = model[:id] || model["id"] || model[:model] || model["model"]
|
|
667
|
+
model = model.merge(contextWindow: context_window_for(provider, id))
|
|
668
|
+
end
|
|
669
669
|
ModelInfo.normalize(
|
|
670
670
|
model,
|
|
671
671
|
current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
@@ -674,6 +674,17 @@ module Kward
|
|
|
674
674
|
)
|
|
675
675
|
end
|
|
676
676
|
|
|
677
|
+
def context_window_for(provider, model)
|
|
678
|
+
provider = ModelInfo.provider_label(provider)
|
|
679
|
+
return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
|
|
680
|
+
|
|
681
|
+
if @client.respond_to?(:current_context_window) && @client.respond_to?(:current_provider) && @client.respond_to?(:current_model)
|
|
682
|
+
return @client.current_context_window if provider == @client.current_provider && model == @client.current_model
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
ModelInfo.context_window(provider, model)
|
|
686
|
+
end
|
|
687
|
+
|
|
677
688
|
def active_persona_label(rpc_session)
|
|
678
689
|
ConfigFiles.active_persona_label(
|
|
679
690
|
workspace_root: rpc_session.workspace_root,
|
|
@@ -698,10 +709,6 @@ module Kward
|
|
|
698
709
|
pending_turn_count(rpc_session.id).zero?
|
|
699
710
|
end
|
|
700
711
|
|
|
701
|
-
def active_session_count(workspace_root)
|
|
702
|
-
@mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
|
|
703
|
-
end
|
|
704
|
-
|
|
705
712
|
def tool_calls(message)
|
|
706
713
|
MessageAccess.tool_calls(message)
|
|
707
714
|
end
|
|
@@ -710,10 +717,6 @@ module Kward
|
|
|
710
717
|
MessageAccess.role(message)
|
|
711
718
|
end
|
|
712
719
|
|
|
713
|
-
def message_content(message)
|
|
714
|
-
MessageAccess.content(message)
|
|
715
|
-
end
|
|
716
|
-
|
|
717
720
|
def session_tree_helper(rpc_session)
|
|
718
721
|
SessionTree.new(rpc_session)
|
|
719
722
|
end
|
|
@@ -902,9 +905,7 @@ module Kward
|
|
|
902
905
|
end
|
|
903
906
|
|
|
904
907
|
def close_rpc_session(rpc_session, delete_unused: true)
|
|
905
|
-
|
|
906
|
-
stop_worker(rpc_session)
|
|
907
|
-
stop_footer_worker(rpc_session)
|
|
908
|
+
remove_live_session(rpc_session)
|
|
908
909
|
rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
|
|
909
910
|
end
|
|
910
911
|
|
|
@@ -917,12 +918,16 @@ module Kward
|
|
|
917
918
|
next unless rpc_session.session.respond_to?(:delete_if_unused)
|
|
918
919
|
next unless rpc_session.session.delete_if_unused
|
|
919
920
|
|
|
920
|
-
|
|
921
|
-
stop_worker(rpc_session)
|
|
922
|
-
stop_footer_worker(rpc_session)
|
|
921
|
+
remove_live_session(rpc_session)
|
|
923
922
|
end
|
|
924
923
|
end
|
|
925
924
|
|
|
925
|
+
def remove_live_session(rpc_session)
|
|
926
|
+
@mutex.synchronize { @sessions.delete(rpc_session.id) }
|
|
927
|
+
stop_worker(rpc_session)
|
|
928
|
+
stop_footer_worker(rpc_session)
|
|
929
|
+
end
|
|
930
|
+
|
|
926
931
|
def stop_worker(rpc_session)
|
|
927
932
|
worker = rpc_session.worker
|
|
928
933
|
return unless worker&.alive?
|
|
@@ -1065,21 +1070,23 @@ module Kward
|
|
|
1065
1070
|
|
|
1066
1071
|
turn.steering.submit(input)
|
|
1067
1072
|
turn_payload(turn)
|
|
1068
|
-
rescue StandardError
|
|
1069
|
-
nil
|
|
1070
1073
|
end
|
|
1071
1074
|
|
|
1072
|
-
def
|
|
1073
|
-
|
|
1074
|
-
command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
|
|
1075
|
-
output = []
|
|
1076
|
-
context = PluginRegistry::Context.new(
|
|
1075
|
+
def plugin_context(rpc_session, args: nil, say_callback:)
|
|
1076
|
+
PluginRegistry::Context.new(
|
|
1077
1077
|
conversation: rpc_session.conversation,
|
|
1078
|
-
args:
|
|
1078
|
+
args: args,
|
|
1079
1079
|
session: rpc_session.session,
|
|
1080
1080
|
workspace_root: rpc_session.workspace_root,
|
|
1081
|
-
say_callback:
|
|
1081
|
+
say_callback: say_callback
|
|
1082
1082
|
)
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
def run_plugin_turn(rpc_session, turn)
|
|
1086
|
+
turn.cancellation&.raise_if_cancelled!
|
|
1087
|
+
command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
|
|
1088
|
+
output = []
|
|
1089
|
+
context = plugin_context(rpc_session, args: turn.plugin_arguments.to_s, say_callback: lambda { |message| output << message.to_s })
|
|
1083
1090
|
result = command.handler.call(turn.plugin_arguments.to_s, context)
|
|
1084
1091
|
answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
|
|
1085
1092
|
unless answer.empty?
|
|
@@ -1092,12 +1099,7 @@ module Kward
|
|
|
1092
1099
|
def notify_plugin_transcript_event(rpc_session, event)
|
|
1093
1100
|
return if plugin_registry.transcript_event_handlers.empty?
|
|
1094
1101
|
|
|
1095
|
-
context =
|
|
1096
|
-
conversation: rpc_session.conversation,
|
|
1097
|
-
session: rpc_session.session,
|
|
1098
|
-
workspace_root: rpc_session.workspace_root,
|
|
1099
|
-
say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
|
|
1100
|
-
)
|
|
1102
|
+
context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
|
|
1101
1103
|
plugin_registry.notify_transcript_event(event, context)
|
|
1102
1104
|
end
|
|
1103
1105
|
|
|
@@ -1113,6 +1115,8 @@ module Kward
|
|
|
1113
1115
|
emit_turn_event(turn, "modelRetry", retry_event_payload(event))
|
|
1114
1116
|
when Events::Steering
|
|
1115
1117
|
emit_turn_event(turn, "turnSteered", { input: event.input, createdAt: event.created_at })
|
|
1118
|
+
when Events::SteeringApplied
|
|
1119
|
+
emit_turn_event(turn, "steeringApplied", { count: event.count })
|
|
1116
1120
|
when Events::ToolCall
|
|
1117
1121
|
emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
|
|
1118
1122
|
when Events::ToolResult
|
|
@@ -1149,12 +1153,7 @@ module Kward
|
|
|
1149
1153
|
return clear_footer_update(rpc_session) unless renderer
|
|
1150
1154
|
|
|
1151
1155
|
text = begin
|
|
1152
|
-
context =
|
|
1153
|
-
conversation: rpc_session.conversation,
|
|
1154
|
-
session: rpc_session.session,
|
|
1155
|
-
workspace_root: rpc_session.workspace_root,
|
|
1156
|
-
say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
|
|
1157
|
-
)
|
|
1156
|
+
context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
|
|
1158
1157
|
renderer.call(context).to_s.gsub(/\s+/, " ").strip
|
|
1159
1158
|
rescue StandardError => e
|
|
1160
1159
|
warn "Warning: Kward plugin footer error: #{e.message}"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require_relative "../message_access"
|
|
2
2
|
require_relative "../message_text"
|
|
3
|
+
require_relative "../session_tree_nodes"
|
|
3
4
|
require_relative "../session_tree_tool_display"
|
|
4
|
-
require_relative "../tools/tool_call"
|
|
5
5
|
|
|
6
6
|
# Namespace for the Kward CLI agent runtime.
|
|
7
7
|
module Kward
|
|
@@ -28,13 +28,14 @@ module Kward
|
|
|
28
28
|
#
|
|
29
29
|
# @return [Array<Hash>] rows for the `session/tree` RPC method
|
|
30
30
|
def rows
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf)
|
|
32
|
+
active_path = tree_nodes.active_path
|
|
33
|
+
tool_calls_by_id = tree_nodes.tool_calls
|
|
34
|
+
visible_roots = tree_nodes.visible_roots
|
|
34
35
|
multiple_roots = visible_roots.length > 1
|
|
35
36
|
result = []
|
|
36
37
|
|
|
37
|
-
stack = visible_roots.sort_by { |root|
|
|
38
|
+
stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
|
|
38
39
|
[root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
|
|
39
40
|
end.reverse
|
|
40
41
|
|
|
@@ -60,7 +61,7 @@ module Kward
|
|
|
60
61
|
prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
|
|
61
62
|
}.compact
|
|
62
63
|
|
|
63
|
-
children = node[:children].sort_by { |child|
|
|
64
|
+
children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
|
|
64
65
|
multiple_children = children.length > 1
|
|
65
66
|
child_indent = if multiple_children
|
|
66
67
|
indent + 1
|
|
@@ -80,112 +81,6 @@ module Kward
|
|
|
80
81
|
|
|
81
82
|
private
|
|
82
83
|
|
|
83
|
-
def tree_active_path(roots, leaf_id)
|
|
84
|
-
by_id = tree_entries_by_id(roots)
|
|
85
|
-
ids = []
|
|
86
|
-
current = by_id[leaf_id.to_s]
|
|
87
|
-
seen = {}
|
|
88
|
-
while current && !seen[current["id"].to_s]
|
|
89
|
-
seen[current["id"].to_s] = true
|
|
90
|
-
ids << current["id"].to_s
|
|
91
|
-
current = by_id[current["parentId"].to_s]
|
|
92
|
-
end
|
|
93
|
-
ids
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def tree_entries_by_id(roots)
|
|
97
|
-
roots.each_with_object({}) do |root, map|
|
|
98
|
-
stack = [root]
|
|
99
|
-
seen = {}
|
|
100
|
-
until stack.empty?
|
|
101
|
-
node = stack.pop
|
|
102
|
-
next if seen[node.object_id]
|
|
103
|
-
|
|
104
|
-
seen[node.object_id] = true
|
|
105
|
-
entry = node["entry"] || {}
|
|
106
|
-
map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
|
|
107
|
-
stack.concat(Array(node["children"]))
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def visible_tree_nodes(node)
|
|
113
|
-
results = {}
|
|
114
|
-
stack = [[node, false, {}]]
|
|
115
|
-
|
|
116
|
-
until stack.empty?
|
|
117
|
-
current, visited, seen = stack.pop
|
|
118
|
-
node_key = current.object_id
|
|
119
|
-
next if seen[node_key]
|
|
120
|
-
|
|
121
|
-
if visited
|
|
122
|
-
children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
|
|
123
|
-
results[node_key] = if hidden_tree_entry?(current["entry"] || {})
|
|
124
|
-
children
|
|
125
|
-
else
|
|
126
|
-
[{ source: current, children: children }]
|
|
127
|
-
end
|
|
128
|
-
else
|
|
129
|
-
branch_seen = seen.merge(node_key => true)
|
|
130
|
-
stack << [current, true, seen]
|
|
131
|
-
Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
results[node.object_id] || []
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def hidden_tree_entry?(entry)
|
|
139
|
-
return false if @current_leaf && entry["id"].to_s == @current_leaf.to_s
|
|
140
|
-
return false unless entry["type"] == "message"
|
|
141
|
-
|
|
142
|
-
message = entry["message"]
|
|
143
|
-
return false unless message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
|
|
144
|
-
|
|
145
|
-
content = MessageAccess.content(message)
|
|
146
|
-
content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
|
|
147
|
-
(content_tool_calls && !tree_text_content?(content)) || (!MessageAccess.tool_calls(message).empty? && MessageText.full_text(message).empty?)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def tree_text_content?(content)
|
|
151
|
-
Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def tree_contains_active_path?(node, active_path)
|
|
155
|
-
stack = [node]
|
|
156
|
-
seen = {}
|
|
157
|
-
until stack.empty?
|
|
158
|
-
current = stack.pop
|
|
159
|
-
next if seen[current.object_id]
|
|
160
|
-
|
|
161
|
-
seen[current.object_id] = true
|
|
162
|
-
entry_id = (current[:source]["entry"] || {})["id"].to_s
|
|
163
|
-
return true if active_path.include?(entry_id)
|
|
164
|
-
|
|
165
|
-
stack.concat(current[:children])
|
|
166
|
-
end
|
|
167
|
-
false
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def tree_tool_calls(roots)
|
|
171
|
-
roots.each_with_object({}) do |root, tool_calls_by_id|
|
|
172
|
-
stack = [root]
|
|
173
|
-
seen = {}
|
|
174
|
-
until stack.empty?
|
|
175
|
-
node = stack.pop
|
|
176
|
-
next if seen[node.object_id]
|
|
177
|
-
|
|
178
|
-
seen[node.object_id] = true
|
|
179
|
-
entry = node["entry"] || {}
|
|
180
|
-
message = entry["message"]
|
|
181
|
-
if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
|
|
182
|
-
MessageAccess.tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
|
|
183
|
-
end
|
|
184
|
-
stack.concat(Array(node["children"]))
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
84
|
def tree_entry_display(entry, tool_calls_by_id = {})
|
|
190
85
|
case entry["type"]
|
|
191
86
|
when "message"
|
|
@@ -237,12 +132,11 @@ module Kward
|
|
|
237
132
|
end
|
|
238
133
|
|
|
239
134
|
def display_message_text(message)
|
|
240
|
-
|
|
135
|
+
SessionTreeNodes.truncate_text(MessageText.full_text(message))
|
|
241
136
|
end
|
|
242
137
|
|
|
243
138
|
def truncate_tree_text(text)
|
|
244
|
-
|
|
245
|
-
normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
|
|
139
|
+
SessionTreeNodes.truncate_text(text)
|
|
246
140
|
end
|
|
247
141
|
end
|
|
248
142
|
end
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -178,6 +178,9 @@ module Kward
|
|
|
178
178
|
# @return [String] workspace directory this store lists and creates sessions for
|
|
179
179
|
attr_reader :cwd
|
|
180
180
|
|
|
181
|
+
# @return [String] configuration directory containing session and tab files
|
|
182
|
+
attr_reader :config_dir
|
|
183
|
+
|
|
181
184
|
# Creates a new empty session file for the store's workspace directory.
|
|
182
185
|
#
|
|
183
186
|
# Parent fields record clone/fork ancestry; they do not imply live coupling
|
|
@@ -285,6 +288,7 @@ module Kward
|
|
|
285
288
|
session_memories: memory_state["sessionMemories"],
|
|
286
289
|
last_memory_retrieval: memory_state["lastRetrieval"]
|
|
287
290
|
)
|
|
291
|
+
restore_tool_output_artifacts(records, conversation)
|
|
288
292
|
conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
|
|
289
293
|
session = Session.new(
|
|
290
294
|
store: self,
|
|
@@ -496,9 +500,29 @@ module Kward
|
|
|
496
500
|
|
|
497
501
|
def session_header(records, path)
|
|
498
502
|
header = records.find { |record| record["type"] == "session" }
|
|
499
|
-
|
|
503
|
+
return header if header && header["id"].to_s != ""
|
|
504
|
+
|
|
505
|
+
recovered = recovered_session_header(records, path)
|
|
506
|
+
return recovered if recovered
|
|
500
507
|
|
|
501
|
-
|
|
508
|
+
raise "Invalid Kward session file: #{path}"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def recovered_session_header(records, path)
|
|
512
|
+
return nil unless records.any? { |record| ["message", "session_info", "system_prompt", "memory_state"].include?(record["type"]) }
|
|
513
|
+
|
|
514
|
+
basename = File.basename(path)
|
|
515
|
+
match = basename.match(/\A(?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z)_(?<id>[0-9a-fA-F-]{36})\.jsonl\z/)
|
|
516
|
+
return nil unless match
|
|
517
|
+
|
|
518
|
+
timestamp = match[:timestamp].tr("-", ":").sub(/\A(\d{4}):(\d{2}):(\d{2})T/, "\\1-\\2-\\3T")
|
|
519
|
+
{
|
|
520
|
+
"type" => "session",
|
|
521
|
+
"version" => VERSION,
|
|
522
|
+
"id" => match[:id],
|
|
523
|
+
"timestamp" => timestamp,
|
|
524
|
+
"cwd" => @cwd
|
|
525
|
+
}
|
|
502
526
|
end
|
|
503
527
|
|
|
504
528
|
def session_named?(session)
|
|
@@ -541,6 +565,46 @@ module Kward
|
|
|
541
565
|
records.reverse.find { |record| record["type"] == "memory_state" } || { "sessionMemories" => [], "lastRetrieval" => nil }
|
|
542
566
|
end
|
|
543
567
|
|
|
568
|
+
def restore_tool_output_artifacts(records, conversation)
|
|
569
|
+
tool_names = tool_message_names_by_id(records)
|
|
570
|
+
records.each do |record|
|
|
571
|
+
next unless record["type"] == "tool_execution_end"
|
|
572
|
+
|
|
573
|
+
content = record.dig("result", "content")
|
|
574
|
+
next if content.nil?
|
|
575
|
+
|
|
576
|
+
tool_name = tool_names[record["toolCallId"].to_s] || raw_tool_name(record["toolName"])
|
|
577
|
+
next if tool_name.to_s.empty?
|
|
578
|
+
|
|
579
|
+
conversation.restore_tool_output_artifact(
|
|
580
|
+
tool_name: tool_name,
|
|
581
|
+
content: content,
|
|
582
|
+
created_at: parse_time(record["timestamp"])
|
|
583
|
+
)
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def tool_message_names_by_id(records)
|
|
588
|
+
records.each_with_object({}) do |record, names|
|
|
589
|
+
next unless record["type"] == "message"
|
|
590
|
+
|
|
591
|
+
message = record["message"]
|
|
592
|
+
next unless message.is_a?(Hash) && message_role(message) == "tool"
|
|
593
|
+
|
|
594
|
+
tool_call_id = message_tool_call_id(message).to_s
|
|
595
|
+
names[tool_call_id] = message_name(message) unless tool_call_id.empty? || message_name(message).to_s.empty?
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def raw_tool_name(name)
|
|
600
|
+
{
|
|
601
|
+
"bash" => "run_shell_command",
|
|
602
|
+
"edit" => "edit_file",
|
|
603
|
+
"read" => "read_file",
|
|
604
|
+
"write" => "write_file"
|
|
605
|
+
}.fetch(name.to_s, name.to_s)
|
|
606
|
+
end
|
|
607
|
+
|
|
544
608
|
def session_runtime(records, header)
|
|
545
609
|
result = {
|
|
546
610
|
"provider" => header["provider"],
|
|
@@ -756,8 +820,7 @@ module Kward
|
|
|
756
820
|
|
|
757
821
|
def session_info(path)
|
|
758
822
|
records = records_from_file(path)
|
|
759
|
-
header = records
|
|
760
|
-
return nil unless header && header["id"].to_s != ""
|
|
823
|
+
header = session_header(records, path)
|
|
761
824
|
|
|
762
825
|
messages = restored_messages(records)
|
|
763
826
|
name = session_name(records)
|