kward 0.79.0 → 0.80.1

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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. 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
- rpc_session = build_rpc_session(store, session, conversation, root)
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
- rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
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
- rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
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
- normalized_options = normalize_turn_options(options)
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
- plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
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
- @server.notify("turn/event", event)
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) ? File.mtime(session.path) : nil
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 new_conversation(workspace_root: Dir.pwd)
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(server: @server, session_id: id)
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
- @server.notify("hook/event", {
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| @server.notify("hook/message", { message: message.to_s }) }
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
- @server.notify("turn/event", event)
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
- persist_memory_state(rpc_session)
1225
- auto_summarize_memory(rpc_session) unless turn.cancel_requested
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
- @server.notify("turn/event", event)
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
- @server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
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
- @server.notify("ui/footer", { sessionId: rpc_session.id, text: "" })
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
- @server.notify("turn/event", event)
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
- @server.notify("session/event", {
1505
+ notify("session/event", {
1427
1506
  timestamp: now,
1428
1507
  sessionId: rpc_session.id,
1429
1508
  type: type,
@@ -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
- @store.append_record(@path, RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record)
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(@path, system_message, reason: reason)
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
- @store.append_record(@path, {
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: parse_time(header["timestamp"]) || File.mtime(resolved_path),
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
- append_record(path, {
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
- append_record(path, {
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
- return if latest_system_prompt_hash(records_from_file(path)) == system_prompt_hash(content)
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: system_prompt_hash(content),
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(4)
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| session_file_mtime(path) }.reverse
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 session_file_mtime(path)
828
- File.mtime(path)
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: parse_time(header["timestamp"]) || stats.mtime,
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)) },