kward 0.79.0 → 0.80.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 +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
|
@@ -66,8 +66,8 @@ module Kward
|
|
|
66
66
|
WORKER_STOP_TIMEOUT = 2.0
|
|
67
67
|
WORKER_STOP = Object.new.freeze
|
|
68
68
|
|
|
69
|
-
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
70
|
-
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, :options, :tool_registry, :mutex, keyword_init: true)
|
|
69
|
+
RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :execution_profile, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
|
|
70
|
+
Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, :options, :tool_registry, :execution_profile, :mutex, keyword_init: true)
|
|
71
71
|
|
|
72
72
|
# Creates an object for RPC session lifecycle and turn coordination.
|
|
73
73
|
def initialize(
|
|
@@ -77,7 +77,8 @@ module Kward
|
|
|
77
77
|
config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
|
|
78
78
|
context_usage: ContextUsage.new,
|
|
79
79
|
session_trash: SessionTrash.new,
|
|
80
|
-
worker_stop_timeout: WORKER_STOP_TIMEOUT
|
|
80
|
+
worker_stop_timeout: WORKER_STOP_TIMEOUT,
|
|
81
|
+
event_listener: nil
|
|
81
82
|
)
|
|
82
83
|
@server = server
|
|
83
84
|
@client = client
|
|
@@ -87,6 +88,7 @@ module Kward
|
|
|
87
88
|
@session_metrics = SessionMetrics.new(context_usage: context_usage)
|
|
88
89
|
@session_trash = session_trash
|
|
89
90
|
@worker_stop_timeout = worker_stop_timeout
|
|
91
|
+
@event_listeners = event_listener ? [event_listener] : []
|
|
90
92
|
@sessions = {}
|
|
91
93
|
@turns = {}
|
|
92
94
|
@mutex = Mutex.new
|
|
@@ -97,26 +99,28 @@ module Kward
|
|
|
97
99
|
# Returns the normalized session payload expected by RPC clients. The RPC
|
|
98
100
|
# session id is separate from the persisted session id so one persisted file
|
|
99
101
|
# can be closed and reopened by different client connections.
|
|
100
|
-
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
|
|
102
|
+
def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false, execution_profile: nil)
|
|
103
|
+
execution_profile = validate_execution_profile(execution_profile)
|
|
101
104
|
workspace_root = validate_workspace_root(workspace_root)
|
|
102
105
|
store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
|
|
103
106
|
if resume_last && session_auto_resume_enabled? && name.to_s.strip.empty?
|
|
104
107
|
path = store.remembered_last_session_path
|
|
105
|
-
return resume_session(path: path, workspace_root: workspace_root, include_transcript: true) if path
|
|
108
|
+
return resume_session(path: path, workspace_root: workspace_root, include_transcript: true, execution_profile: execution_profile) if path
|
|
106
109
|
end
|
|
107
110
|
|
|
108
|
-
conversation = new_conversation(workspace_root: workspace_root)
|
|
111
|
+
conversation = new_conversation(workspace_root: workspace_root, execution_profile: execution_profile)
|
|
109
112
|
session = store.create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort)
|
|
110
113
|
session.rename(name) unless name.to_s.strip.empty?
|
|
111
114
|
session.attach(conversation)
|
|
112
|
-
rpc_session = build_rpc_session(store, session, conversation, workspace_root)
|
|
115
|
+
rpc_session = build_rpc_session(store, session, conversation, workspace_root, execution_profile: execution_profile)
|
|
113
116
|
remember_session(rpc_session)
|
|
114
117
|
cleanup_other_unused_sessions(rpc_session)
|
|
115
118
|
emit_footer_update(rpc_session)
|
|
116
119
|
session_payload(rpc_session)
|
|
117
120
|
end
|
|
118
121
|
|
|
119
|
-
def resume_session(path:, workspace_root: nil, include_transcript: false)
|
|
122
|
+
def resume_session(path:, workspace_root: nil, include_transcript: false, execution_profile: nil)
|
|
123
|
+
execution_profile = validate_execution_profile(execution_profile)
|
|
120
124
|
root = validate_workspace_root(workspace_root || Dir.pwd)
|
|
121
125
|
store = SessionStore.new(config_dir: @config_dir, cwd: root)
|
|
122
126
|
location = store.session_location(path)
|
|
@@ -129,7 +133,8 @@ module Kward
|
|
|
129
133
|
model: current_model_id,
|
|
130
134
|
reasoning_effort: current_reasoning_effort
|
|
131
135
|
)
|
|
132
|
-
|
|
136
|
+
conversation.update_execution_profile_context!(execution_profile.prompt_context) if execution_profile
|
|
137
|
+
rpc_session = build_rpc_session(store, session, conversation, root, execution_profile: execution_profile)
|
|
133
138
|
remember_session(rpc_session)
|
|
134
139
|
cleanup_other_unused_sessions(rpc_session)
|
|
135
140
|
emit_footer_update(rpc_session)
|
|
@@ -197,6 +202,17 @@ module Kward
|
|
|
197
202
|
{ tools: registry.schemas }
|
|
198
203
|
end
|
|
199
204
|
|
|
205
|
+
# Subscribes to frontend-neutral runtime notifications.
|
|
206
|
+
#
|
|
207
|
+
# Subscribers are isolated from session execution: an exception is logged
|
|
208
|
+
# and does not interrupt the RPC notification or active turn.
|
|
209
|
+
def subscribe_events(&listener)
|
|
210
|
+
raise ArgumentError, "event subscription requires a block" unless listener
|
|
211
|
+
|
|
212
|
+
@mutex.synchronize { @event_listeners << listener }
|
|
213
|
+
listener
|
|
214
|
+
end
|
|
215
|
+
|
|
200
216
|
# Returns the plugin registry shared by RPC sessions and plugin chats.
|
|
201
217
|
def plugin_registry
|
|
202
218
|
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
|
|
@@ -213,7 +229,8 @@ module Kward
|
|
|
213
229
|
def clone_session(session_id:)
|
|
214
230
|
source = fetch_session(session_id)
|
|
215
231
|
session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
|
|
216
|
-
|
|
232
|
+
conversation.update_execution_profile_context!(source.execution_profile.prompt_context) if source.execution_profile
|
|
233
|
+
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root, execution_profile: source.execution_profile)
|
|
217
234
|
remember_session(rpc_session)
|
|
218
235
|
cleanup_other_unused_sessions(rpc_session)
|
|
219
236
|
emit_footer_update(rpc_session)
|
|
@@ -272,7 +289,8 @@ module Kward
|
|
|
272
289
|
parent_session: source.session
|
|
273
290
|
)
|
|
274
291
|
|
|
275
|
-
|
|
292
|
+
conversation.update_execution_profile_context!(source.execution_profile.prompt_context) if source.execution_profile
|
|
293
|
+
rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root, execution_profile: source.execution_profile)
|
|
276
294
|
remember_session(rpc_session)
|
|
277
295
|
cleanup_other_unused_sessions(rpc_session)
|
|
278
296
|
{
|
|
@@ -386,12 +404,20 @@ module Kward
|
|
|
386
404
|
# queue a follow-up, or steer the running turn when the active provider
|
|
387
405
|
# supports native steering. The returned turn id is used for status,
|
|
388
406
|
# cancellation, and event replay.
|
|
389
|
-
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil)
|
|
407
|
+
def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil, execution_profile: nil)
|
|
390
408
|
rpc_session = fetch_session(session_id)
|
|
391
|
-
|
|
409
|
+
execution_profile = validate_execution_profile(execution_profile)
|
|
410
|
+
normalized_options = enforce_execution_profile_options(normalize_turn_options(options), execution_profile)
|
|
392
411
|
normalized_context = TurnContext.normalize(context)
|
|
393
412
|
normalized_attachments = normalize_attachments(attachments)
|
|
394
|
-
|
|
413
|
+
if execution_profile && !execution_profile.attachments && !normalized_attachments.empty?
|
|
414
|
+
raise ArgumentError, "transport execution profile does not allow attachments"
|
|
415
|
+
end
|
|
416
|
+
plugin_command, plugin_arguments = if execution_profile && !execution_profile.plugin_commands
|
|
417
|
+
[nil, ""]
|
|
418
|
+
else
|
|
419
|
+
plugin_command_turn(input, normalized_attachments)
|
|
420
|
+
end
|
|
395
421
|
display_input = input.to_s if input.is_a?(String)
|
|
396
422
|
content = plugin_command ? input.to_s : user_turn_content(turn_input_with_context(expand_prompt_input(input), normalized_context), normalized_attachments)
|
|
397
423
|
streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
|
|
@@ -414,6 +440,7 @@ module Kward
|
|
|
414
440
|
plugin_arguments: plugin_arguments,
|
|
415
441
|
options: normalized_options,
|
|
416
442
|
tool_registry: scoped_tool_registry(rpc_session, normalized_options),
|
|
443
|
+
execution_profile: execution_profile,
|
|
417
444
|
mutex: Mutex.new
|
|
418
445
|
)
|
|
419
446
|
@mutex.synchronize { @turns[turn.id] = turn }
|
|
@@ -429,7 +456,7 @@ module Kward
|
|
|
429
456
|
turn.cancel_requested = true
|
|
430
457
|
[turn.status == "queued", append_turn_event_locked(turn, "turnCancelRequested", {})]
|
|
431
458
|
end
|
|
432
|
-
|
|
459
|
+
notify("turn/event", event)
|
|
433
460
|
turn.cancellation&.cancel!
|
|
434
461
|
finish_turn(turn, "canceled") if queued
|
|
435
462
|
turn_payload(turn)
|
|
@@ -518,6 +545,11 @@ module Kward
|
|
|
518
545
|
normalized
|
|
519
546
|
end
|
|
520
547
|
|
|
548
|
+
def refresh_models(provider: nil)
|
|
549
|
+
@client.refresh_available_models(provider: provider) if @client.respond_to?(:refresh_available_models)
|
|
550
|
+
available_models
|
|
551
|
+
end
|
|
552
|
+
|
|
521
553
|
def current_model
|
|
522
554
|
provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
|
|
523
555
|
model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
@@ -623,7 +655,9 @@ module Kward
|
|
|
623
655
|
end
|
|
624
656
|
|
|
625
657
|
def session_modified_at(session)
|
|
626
|
-
File.exist?(session.path)
|
|
658
|
+
return nil unless File.exist?(session.path)
|
|
659
|
+
|
|
660
|
+
session.respond_to?(:modified_at) ? session.modified_at : nil
|
|
627
661
|
end
|
|
628
662
|
|
|
629
663
|
def validate_workspace_root(root)
|
|
@@ -635,9 +669,22 @@ module Kward
|
|
|
635
669
|
|
|
636
670
|
private
|
|
637
671
|
|
|
638
|
-
def
|
|
672
|
+
def notify(method, payload)
|
|
673
|
+
@server.notify(method, payload)
|
|
674
|
+
listeners = @mutex.synchronize { @event_listeners.dup }
|
|
675
|
+
listeners.each do |listener|
|
|
676
|
+
begin
|
|
677
|
+
listener.call(method, payload)
|
|
678
|
+
rescue StandardError => e
|
|
679
|
+
@server.log_error(e) if @server.respond_to?(:log_error)
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def new_conversation(workspace_root: Dir.pwd, execution_profile: nil)
|
|
639
685
|
Conversation.new(
|
|
640
686
|
workspace_root: workspace_root,
|
|
687
|
+
execution_profile_context: execution_profile&.prompt_context,
|
|
641
688
|
provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
642
689
|
model: (@client.current_model if @client.respond_to?(:current_model)),
|
|
643
690
|
reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
|
|
@@ -780,6 +827,7 @@ module Kward
|
|
|
780
827
|
reasoning_effort: current_reasoning_effort
|
|
781
828
|
)
|
|
782
829
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
830
|
+
conversation.update_execution_profile_context!(rpc_session.execution_profile.prompt_context) if rpc_session.execution_profile
|
|
783
831
|
rpc_session.session = session
|
|
784
832
|
rpc_session.conversation = conversation
|
|
785
833
|
rebuild_session_tools(rpc_session)
|
|
@@ -856,6 +904,30 @@ module Kward
|
|
|
856
904
|
PromptCommands.expand(input) || input
|
|
857
905
|
end
|
|
858
906
|
|
|
907
|
+
def validate_execution_profile(profile)
|
|
908
|
+
return nil if profile.nil?
|
|
909
|
+
return profile if profile.is_a?(Kward::Transport::ExecutionProfile)
|
|
910
|
+
|
|
911
|
+
raise ArgumentError, "execution_profile must be a Transport::ExecutionProfile"
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def enforce_execution_profile_options(options, profile)
|
|
915
|
+
return options unless profile
|
|
916
|
+
|
|
917
|
+
options = options.dup
|
|
918
|
+
case profile.tool_mode
|
|
919
|
+
when :none
|
|
920
|
+
options.delete(:disabled_tools)
|
|
921
|
+
options[:allowed_tools] = []
|
|
922
|
+
when :allowlist
|
|
923
|
+
options.delete(:disabled_tools)
|
|
924
|
+
options[:allowed_tools] = profile.allowed_tools
|
|
925
|
+
end
|
|
926
|
+
options[:approval_mode] = "none" if profile.approval_mode == :deny
|
|
927
|
+
options[:approval_mode] = "ask" if profile.approval_mode == :ask
|
|
928
|
+
options
|
|
929
|
+
end
|
|
930
|
+
|
|
859
931
|
def plugin_command_turn(input, attachments)
|
|
860
932
|
return [nil, ""] unless input.is_a?(String)
|
|
861
933
|
return [nil, ""] unless attachments.empty?
|
|
@@ -966,10 +1038,10 @@ module Kward
|
|
|
966
1038
|
# This is the RPC counterpart to the CLI interactive setup: attach plugin
|
|
967
1039
|
# context, create a prompt bridge for UI questions/footer output, advertise
|
|
968
1040
|
# tools with the workspace guardrail policy, and build the shared `Agent`.
|
|
969
|
-
def build_rpc_session(store, session, conversation, workspace_root)
|
|
1041
|
+
def build_rpc_session(store, session, conversation, workspace_root, execution_profile: nil)
|
|
970
1042
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
971
1043
|
id = SecureRandom.uuid
|
|
972
|
-
prompt = PromptBridge.new(
|
|
1044
|
+
prompt = PromptBridge.new(notify: method(:notify), session_id: id)
|
|
973
1045
|
hook_context = lifecycle_hook_context(conversation: conversation, session: session, workspace_root: workspace_root)
|
|
974
1046
|
hook_manager = lifecycle_hook_manager(workspace_root)
|
|
975
1047
|
tool_registry = build_tool_registry(workspace_root, prompt, hook_manager: hook_manager, hook_context: hook_context)
|
|
@@ -988,6 +1060,7 @@ module Kward
|
|
|
988
1060
|
conversation: conversation,
|
|
989
1061
|
agent: agent,
|
|
990
1062
|
tool_registry: tool_registry,
|
|
1063
|
+
execution_profile: execution_profile,
|
|
991
1064
|
prompt: prompt,
|
|
992
1065
|
plugin_output: [],
|
|
993
1066
|
queue: Queue.new,
|
|
@@ -1020,7 +1093,7 @@ module Kward
|
|
|
1020
1093
|
end
|
|
1021
1094
|
|
|
1022
1095
|
def emit_lifecycle_hook_event(event, result)
|
|
1023
|
-
|
|
1096
|
+
notify("hook/event", {
|
|
1024
1097
|
event: {
|
|
1025
1098
|
id: event.id,
|
|
1026
1099
|
name: event.name,
|
|
@@ -1048,7 +1121,7 @@ module Kward
|
|
|
1048
1121
|
args: "",
|
|
1049
1122
|
session: session,
|
|
1050
1123
|
workspace_root: workspace_root,
|
|
1051
|
-
say_callback: lambda { |message|
|
|
1124
|
+
say_callback: lambda { |message| notify("hook/message", { message: message.to_s }) }
|
|
1052
1125
|
)
|
|
1053
1126
|
end
|
|
1054
1127
|
|
|
@@ -1189,6 +1262,10 @@ module Kward
|
|
|
1189
1262
|
# This method is intentionally the only place that calls `Agent#ask` for RPC
|
|
1190
1263
|
# turns. Keep event translation near this boundary so CLI rendering and RPC
|
|
1191
1264
|
# protocol details do not leak into `Agent`.
|
|
1265
|
+
def memory_disabled?(turn)
|
|
1266
|
+
turn.execution_profile&.memory == :none
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1192
1269
|
def run_turn(rpc_session, turn)
|
|
1193
1270
|
previous_turn_id = Thread.current[:kward_rpc_turn_id]
|
|
1194
1271
|
Thread.current[:kward_rpc_turn_id] = turn.id
|
|
@@ -1203,7 +1280,7 @@ module Kward
|
|
|
1203
1280
|
end
|
|
1204
1281
|
return unless started
|
|
1205
1282
|
|
|
1206
|
-
|
|
1283
|
+
notify("turn/event", event)
|
|
1207
1284
|
|
|
1208
1285
|
if turn.cancel_requested
|
|
1209
1286
|
finish_turn(turn, "canceled")
|
|
@@ -1214,15 +1291,17 @@ module Kward
|
|
|
1214
1291
|
run_plugin_turn(rpc_session, turn)
|
|
1215
1292
|
else
|
|
1216
1293
|
auto_name_session(rpc_session, turn.display_input || turn.input)
|
|
1217
|
-
prepare_memory_context(rpc_session.conversation, turn.input)
|
|
1294
|
+
prepare_memory_context(rpc_session.conversation, turn.input) unless memory_disabled?(turn)
|
|
1218
1295
|
rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering, options: turn.options || {}, tool_registry: turn.tool_registry) do |event|
|
|
1219
1296
|
next if turn.cancel_requested
|
|
1220
1297
|
|
|
1221
1298
|
notify_plugin_transcript_event(rpc_session, event)
|
|
1222
1299
|
handle_agent_event(turn, event)
|
|
1223
1300
|
end
|
|
1224
|
-
|
|
1225
|
-
|
|
1301
|
+
unless memory_disabled?(turn)
|
|
1302
|
+
persist_memory_state(rpc_session)
|
|
1303
|
+
auto_summarize_memory(rpc_session) unless turn.cancel_requested
|
|
1304
|
+
end
|
|
1226
1305
|
finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
|
|
1227
1306
|
end
|
|
1228
1307
|
rescue Cancellation::CancelledError
|
|
@@ -1372,7 +1451,7 @@ module Kward
|
|
|
1372
1451
|
end
|
|
1373
1452
|
return unless event
|
|
1374
1453
|
|
|
1375
|
-
|
|
1454
|
+
notify("turn/event", event)
|
|
1376
1455
|
rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
|
|
1377
1456
|
emit_footer_update(rpc_session) if rpc_session
|
|
1378
1457
|
end
|
|
@@ -1391,19 +1470,19 @@ module Kward
|
|
|
1391
1470
|
return if rpc_session.last_footer_text == text
|
|
1392
1471
|
|
|
1393
1472
|
rpc_session.last_footer_text = text
|
|
1394
|
-
|
|
1473
|
+
notify("ui/footer", { sessionId: rpc_session.id, text: text })
|
|
1395
1474
|
end
|
|
1396
1475
|
|
|
1397
1476
|
def clear_footer_update(rpc_session)
|
|
1398
1477
|
return if rpc_session.last_footer_text.to_s.empty?
|
|
1399
1478
|
|
|
1400
1479
|
rpc_session.last_footer_text = ""
|
|
1401
|
-
|
|
1480
|
+
notify("ui/footer", { sessionId: rpc_session.id, text: "" })
|
|
1402
1481
|
end
|
|
1403
1482
|
|
|
1404
1483
|
def emit_turn_event(turn, type, payload)
|
|
1405
1484
|
event = turn.mutex.synchronize { append_turn_event_locked(turn, type, payload) }
|
|
1406
|
-
|
|
1485
|
+
notify("turn/event", event)
|
|
1407
1486
|
event
|
|
1408
1487
|
end
|
|
1409
1488
|
|
|
@@ -1423,7 +1502,7 @@ module Kward
|
|
|
1423
1502
|
end
|
|
1424
1503
|
|
|
1425
1504
|
def emit_session_event(rpc_session, type, payload)
|
|
1426
|
-
|
|
1505
|
+
notify("session/event", {
|
|
1427
1506
|
timestamp: now,
|
|
1428
1507
|
sessionId: rpc_session.id,
|
|
1429
1508
|
type: type,
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -29,6 +29,9 @@ module Kward
|
|
|
29
29
|
class SessionStore
|
|
30
30
|
VERSION = 2
|
|
31
31
|
LAST_SESSION_FILENAME = "last_session.json"
|
|
32
|
+
NON_ACTIVITY_RECORD_TYPES = %w[session session_info system_prompt].freeze
|
|
33
|
+
RECORD_TYPE_PATTERN = /\A\{\s*"type"\s*:\s*"([^"]+)"/
|
|
34
|
+
RECORD_TIMESTAMP_PATTERN = /"timestamp"\s*:\s*"([^"]+)"/
|
|
32
35
|
|
|
33
36
|
SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :provider, :model, :reasoning_effort, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
|
|
34
37
|
|
|
@@ -47,6 +50,8 @@ module Kward
|
|
|
47
50
|
attr_reader :cwd
|
|
48
51
|
# @return [Time] creation timestamp used for sorting and filenames
|
|
49
52
|
attr_reader :created_at
|
|
53
|
+
# @return [Time] timestamp of the latest persisted conversation activity
|
|
54
|
+
attr_reader :modified_at
|
|
50
55
|
# @return [String, nil] source session id when this session was cloned or forked
|
|
51
56
|
attr_reader :parent_id
|
|
52
57
|
# @return [String, nil] source session path when this session was cloned or forked
|
|
@@ -57,16 +62,18 @@ module Kward
|
|
|
57
62
|
attr_accessor :leaf_id
|
|
58
63
|
|
|
59
64
|
# Creates an object for JSONL session persistence.
|
|
60
|
-
def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil, leaf_id: nil)
|
|
65
|
+
def initialize(store:, id:, path:, cwd:, created_at:, name: nil, parent_id: nil, parent_path: nil, leaf_id: nil, modified_at: nil, system_prompt_hash: nil)
|
|
61
66
|
@store = store
|
|
62
67
|
@id = id
|
|
63
68
|
@path = path
|
|
64
69
|
@cwd = cwd
|
|
65
70
|
@created_at = created_at
|
|
71
|
+
@modified_at = modified_at || created_at
|
|
66
72
|
@name = name
|
|
67
73
|
@parent_id = parent_id
|
|
68
74
|
@parent_path = parent_path
|
|
69
75
|
@leaf_id = leaf_id
|
|
76
|
+
@system_prompt_hash = system_prompt_hash
|
|
70
77
|
end
|
|
71
78
|
|
|
72
79
|
# Installs persistence callbacks on `conversation`.
|
|
@@ -89,6 +96,7 @@ module Kward
|
|
|
89
96
|
record = @store.build_tree_record(@path, "message", @leaf_id, message: message)
|
|
90
97
|
@leaf_id = record[:id]
|
|
91
98
|
@store.append_record(@path, record)
|
|
99
|
+
mark_modified(record)
|
|
92
100
|
end
|
|
93
101
|
|
|
94
102
|
# Persists a compaction summary entry and makes it the active leaf.
|
|
@@ -96,26 +104,36 @@ module Kward
|
|
|
96
104
|
record = @store.build_tree_record(@path, "compaction", @leaf_id, message: message)
|
|
97
105
|
@leaf_id = record[:id]
|
|
98
106
|
@store.append_record(@path, record)
|
|
107
|
+
mark_modified(record)
|
|
99
108
|
end
|
|
100
109
|
|
|
101
110
|
# Persists normalized tool execution metadata alongside transcript messages.
|
|
102
111
|
def append_tool_execution(tool_call, content)
|
|
103
|
-
|
|
112
|
+
record = RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record
|
|
113
|
+
@store.append_record(@path, record)
|
|
114
|
+
mark_modified(record)
|
|
104
115
|
end
|
|
105
116
|
|
|
106
117
|
# Persists the current system prompt as audit metadata when it changes.
|
|
107
118
|
def append_system_prompt_snapshot(system_message, reason: "changed")
|
|
108
|
-
@store.append_system_prompt_snapshot(
|
|
119
|
+
@system_prompt_hash = @store.append_system_prompt_snapshot(
|
|
120
|
+
@path,
|
|
121
|
+
system_message,
|
|
122
|
+
reason: reason,
|
|
123
|
+
latest_hash: @system_prompt_hash
|
|
124
|
+
)
|
|
109
125
|
end
|
|
110
126
|
|
|
111
127
|
# Persists the session memory snapshot used when the session is restored.
|
|
112
128
|
def update_memory_state(session_memories:, last_retrieval: nil)
|
|
113
|
-
|
|
129
|
+
record = {
|
|
114
130
|
type: "memory_state",
|
|
115
131
|
timestamp: Time.now.utc.iso8601(3),
|
|
116
132
|
sessionMemories: Array(session_memories),
|
|
117
133
|
lastRetrieval: last_retrieval
|
|
118
|
-
}
|
|
134
|
+
}
|
|
135
|
+
@store.append_record(@path, record)
|
|
136
|
+
mark_modified(record)
|
|
119
137
|
end
|
|
120
138
|
|
|
121
139
|
# Persists a user-visible session name without rewriting earlier records.
|
|
@@ -131,7 +149,8 @@ module Kward
|
|
|
131
149
|
# Moves the active leaf to an existing entry so future messages fork there.
|
|
132
150
|
def branch(entry_id)
|
|
133
151
|
@leaf_id = entry_id.to_s.empty? ? nil : entry_id.to_s
|
|
134
|
-
@store.append_leaf_change(@path, @leaf_id)
|
|
152
|
+
record = @store.append_leaf_change(@path, @leaf_id)
|
|
153
|
+
mark_modified(record)
|
|
135
154
|
end
|
|
136
155
|
|
|
137
156
|
# Clears the active leaf so the next append starts a fresh root branch.
|
|
@@ -141,7 +160,8 @@ module Kward
|
|
|
141
160
|
|
|
142
161
|
# Persists a display label override for one tree entry.
|
|
143
162
|
def append_label_change(entry_id, label)
|
|
144
|
-
@store.append_label_change(@path, entry_id, label)
|
|
163
|
+
record = @store.append_label_change(@path, entry_id, label)
|
|
164
|
+
mark_modified(record)
|
|
145
165
|
end
|
|
146
166
|
|
|
147
167
|
# Adds a branch-summary node under `parent_id` and selects it as the leaf.
|
|
@@ -149,6 +169,7 @@ module Kward
|
|
|
149
169
|
record = @store.build_tree_record(@path, "branch_summary", parent_id, fromId: from_id, summary: summary, details: details || {})
|
|
150
170
|
@leaf_id = record[:id]
|
|
151
171
|
@store.append_record(@path, record)
|
|
172
|
+
mark_modified(record)
|
|
152
173
|
record[:id]
|
|
153
174
|
end
|
|
154
175
|
|
|
@@ -168,6 +189,15 @@ module Kward
|
|
|
168
189
|
def delete_if_unused
|
|
169
190
|
@store.delete_unused_session(self)
|
|
170
191
|
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def mark_modified(record)
|
|
196
|
+
timestamp = record[:timestamp] || record["timestamp"]
|
|
197
|
+
@modified_at = Time.iso8601(timestamp.to_s) || Time.now.utc
|
|
198
|
+
rescue ArgumentError
|
|
199
|
+
@modified_at = Time.now.utc
|
|
200
|
+
end
|
|
171
201
|
end
|
|
172
202
|
|
|
173
203
|
# Creates an object for JSONL session persistence.
|
|
@@ -211,7 +241,7 @@ module Kward
|
|
|
211
241
|
end
|
|
212
242
|
File.chmod(0o600, path)
|
|
213
243
|
|
|
214
|
-
Session.new(store: self, id: id, path: path, cwd: @cwd, created_at: created_at, parent_id: parent_id, parent_path: parent_path, leaf_id: nil)
|
|
244
|
+
Session.new(store: self, id: id, path: path, cwd: @cwd, created_at: created_at, parent_id: parent_id, parent_path: parent_path, leaf_id: nil, modified_at: created_at)
|
|
215
245
|
end
|
|
216
246
|
|
|
217
247
|
def create_from_conversation(conversation, parent_session: nil)
|
|
@@ -291,16 +321,19 @@ module Kward
|
|
|
291
321
|
)
|
|
292
322
|
restore_tool_output_artifacts(records, conversation)
|
|
293
323
|
conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
|
|
324
|
+
created_at = parse_time(header["timestamp"]) || File.mtime(resolved_path)
|
|
294
325
|
session = Session.new(
|
|
295
326
|
store: self,
|
|
296
327
|
id: header["id"],
|
|
297
328
|
path: resolved_path,
|
|
298
329
|
cwd: header["cwd"].to_s,
|
|
299
|
-
created_at:
|
|
330
|
+
created_at: created_at,
|
|
300
331
|
name: name,
|
|
301
332
|
parent_id: header["parentId"],
|
|
302
333
|
parent_path: header["parentPath"],
|
|
303
|
-
leaf_id: leaf_id
|
|
334
|
+
leaf_id: leaf_id,
|
|
335
|
+
modified_at: session_modified_at(records, fallback: created_at),
|
|
336
|
+
system_prompt_hash: latest_system_prompt_hash(records)
|
|
304
337
|
)
|
|
305
338
|
session.attach(conversation)
|
|
306
339
|
[session, conversation]
|
|
@@ -391,21 +424,25 @@ module Kward
|
|
|
391
424
|
end
|
|
392
425
|
|
|
393
426
|
def append_leaf_change(path, leaf_id)
|
|
394
|
-
|
|
427
|
+
record = {
|
|
395
428
|
type: "leaf",
|
|
396
429
|
timestamp: Time.now.utc.iso8601(3),
|
|
397
430
|
targetId: leaf_id
|
|
398
|
-
}
|
|
431
|
+
}
|
|
432
|
+
append_record(path, record)
|
|
433
|
+
record
|
|
399
434
|
end
|
|
400
435
|
|
|
401
436
|
def append_label_change(path, entry_id, label)
|
|
402
|
-
|
|
437
|
+
record = {
|
|
403
438
|
type: "label",
|
|
404
439
|
id: next_entry_id(path),
|
|
405
440
|
timestamp: Time.now.utc.iso8601(3),
|
|
406
441
|
targetId: entry_id.to_s,
|
|
407
442
|
label: label.to_s.strip.empty? ? nil : label.to_s.strip
|
|
408
|
-
}
|
|
443
|
+
}
|
|
444
|
+
append_record(path, record)
|
|
445
|
+
record
|
|
409
446
|
end
|
|
410
447
|
|
|
411
448
|
# @return [Array<Hash>] nested session tree roots for the given session file
|
|
@@ -463,18 +500,22 @@ module Kward
|
|
|
463
500
|
end
|
|
464
501
|
end
|
|
465
502
|
|
|
466
|
-
def append_system_prompt_snapshot(path, system_message, reason: "changed")
|
|
503
|
+
def append_system_prompt_snapshot(path, system_message, reason: "changed", latest_hash: nil)
|
|
467
504
|
content = MessageAccess.content(system_message).to_s
|
|
468
|
-
return if content.empty?
|
|
469
|
-
|
|
505
|
+
return latest_hash if content.empty?
|
|
506
|
+
|
|
507
|
+
hash = system_prompt_hash(content)
|
|
508
|
+
latest_hash ||= latest_system_prompt_hash(records_from_file(path))
|
|
509
|
+
return latest_hash if latest_hash == hash
|
|
470
510
|
|
|
471
511
|
append_record(path, {
|
|
472
512
|
type: "system_prompt",
|
|
473
513
|
timestamp: Time.now.utc.iso8601(3),
|
|
474
514
|
reason: reason.to_s,
|
|
475
|
-
hash:
|
|
515
|
+
hash: hash,
|
|
476
516
|
content: content
|
|
477
517
|
})
|
|
518
|
+
hash
|
|
478
519
|
end
|
|
479
520
|
|
|
480
521
|
def self.safe_cwd(cwd)
|
|
@@ -483,6 +524,16 @@ module Kward
|
|
|
483
524
|
|
|
484
525
|
private
|
|
485
526
|
|
|
527
|
+
def session_modified_at(records, fallback:)
|
|
528
|
+
records.reverse_each do |record|
|
|
529
|
+
next if NON_ACTIVITY_RECORD_TYPES.include?(record["type"].to_s)
|
|
530
|
+
|
|
531
|
+
timestamp = parse_time(record["timestamp"])
|
|
532
|
+
return timestamp if timestamp
|
|
533
|
+
end
|
|
534
|
+
fallback
|
|
535
|
+
end
|
|
536
|
+
|
|
486
537
|
def latest_system_prompt_hash(records)
|
|
487
538
|
records.reverse_each do |record|
|
|
488
539
|
next unless record["type"] == "system_prompt"
|
|
@@ -751,7 +802,7 @@ module Kward
|
|
|
751
802
|
end
|
|
752
803
|
|
|
753
804
|
def next_entry_id(_path)
|
|
754
|
-
SecureRandom.hex(
|
|
805
|
+
SecureRandom.hex(16)
|
|
755
806
|
end
|
|
756
807
|
|
|
757
808
|
def message_entry_id(message)
|
|
@@ -797,7 +848,7 @@ module Kward
|
|
|
797
848
|
keep_empty_paths = Array(keep_empty_path).filter_map do |path|
|
|
798
849
|
File.expand_path(path) unless path.to_s.empty?
|
|
799
850
|
end
|
|
800
|
-
paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path|
|
|
851
|
+
paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path| session_file_activity_time(path) }.reverse
|
|
801
852
|
sessions = []
|
|
802
853
|
|
|
803
854
|
paths.each do |path|
|
|
@@ -824,8 +875,16 @@ module Kward
|
|
|
824
875
|
false
|
|
825
876
|
end
|
|
826
877
|
|
|
827
|
-
def
|
|
828
|
-
|
|
878
|
+
def session_file_activity_time(path)
|
|
879
|
+
latest_timestamp = nil
|
|
880
|
+
File.foreach(path) do |line|
|
|
881
|
+
type = line[RECORD_TYPE_PATTERN, 1]
|
|
882
|
+
next if NON_ACTIVITY_RECORD_TYPES.include?(type.to_s)
|
|
883
|
+
|
|
884
|
+
timestamp = line[RECORD_TIMESTAMP_PATTERN, 1]
|
|
885
|
+
latest_timestamp = timestamp if timestamp
|
|
886
|
+
end
|
|
887
|
+
parse_time(latest_timestamp) || File.mtime(path)
|
|
829
888
|
rescue StandardError
|
|
830
889
|
Time.at(0)
|
|
831
890
|
end
|
|
@@ -893,13 +952,14 @@ module Kward
|
|
|
893
952
|
runtime = session_runtime(records, header)
|
|
894
953
|
first_message = messages.find { |message| ["user", "compactionSummary"].include?(message_role(message)) }
|
|
895
954
|
stats = File.stat(path)
|
|
955
|
+
created_at = parse_time(header["timestamp"]) || stats.mtime
|
|
896
956
|
|
|
897
957
|
SessionInfo.new(
|
|
898
958
|
id: header["id"],
|
|
899
959
|
path: path,
|
|
900
960
|
cwd: header["cwd"].to_s,
|
|
901
|
-
created_at:
|
|
902
|
-
modified_at: stats.mtime,
|
|
961
|
+
created_at: created_at,
|
|
962
|
+
modified_at: session_modified_at(records, fallback: stats.mtime),
|
|
903
963
|
name: name,
|
|
904
964
|
first_message: first_message ? message_text(first_message) : "",
|
|
905
965
|
message_count: messages.count { |message| ["user", "assistant", "tool", "toolResult", "compactionSummary"].include?(message_role(message)) },
|
data/lib/kward/tab_driver.rb
CHANGED
|
@@ -3,11 +3,12 @@ module Kward
|
|
|
3
3
|
# Adapts a session-backed agent to the tab runtime interface. Plugin tab
|
|
4
4
|
# drivers implement the same small surface without becoming Kward sessions.
|
|
5
5
|
class SessionTabDriver
|
|
6
|
-
attr_reader :session, :agent
|
|
6
|
+
attr_reader :session, :agent, :worktree
|
|
7
7
|
|
|
8
|
-
def initialize(session:, agent:)
|
|
8
|
+
def initialize(session:, agent:, worktree: nil)
|
|
9
9
|
@session = session
|
|
10
10
|
@agent = agent
|
|
11
|
+
@worktree = worktree
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def messages
|
|
@@ -26,7 +27,9 @@ module Kward
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def descriptor
|
|
29
|
-
{ "kind" => "session", "session_path" => session.path }
|
|
30
|
+
descriptor = { "kind" => "session", "session_path" => session.path }
|
|
31
|
+
descriptor["worktree"] = worktree.descriptor if worktree
|
|
32
|
+
descriptor
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
def session?
|