kward 0.78.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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -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 +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -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 +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -0,0 +1,96 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require_relative "command_runner"
4
+ require_relative "environment"
5
+
6
+ # Namespace for operating-system command sandboxing.
7
+ module Kward
8
+ module Sandbox
9
+ # Runs a command under macOS Seatbelt. This backend deliberately constrains
10
+ # writes and child networking first; host-process and plugin access remain
11
+ # outside the command worker boundary.
12
+ class MacOSSeatbeltRunner < CommandRunner
13
+ EXECUTABLE = "/usr/bin/sandbox-exec".freeze
14
+ PROTECTED_READ_DIRECTORIES = %w[.aws .gnupg .kward .ssh .config/gcloud .config/gh].freeze
15
+
16
+ def self.capabilities(platform: RUBY_PLATFORM)
17
+ available = platform.to_s.include?("darwin") && File.executable?(EXECUTABLE)
18
+ return Capabilities.new(available: true, filesystem_enforced: true, child_network_enforced: true, backend: "macos_seatbelt") if available
19
+
20
+ Capabilities.new(
21
+ available: false,
22
+ filesystem_enforced: false,
23
+ child_network_enforced: false,
24
+ backend: "macos_seatbelt",
25
+ reason: "#{EXECUTABLE} is unavailable"
26
+ )
27
+ end
28
+
29
+ def initialize(policy:, capabilities: self.class.capabilities)
30
+ raise ArgumentError, "macOS Seatbelt is unavailable: #{capabilities.reason}" unless capabilities.available?
31
+
32
+ super
33
+ end
34
+
35
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
36
+ temporary_root = Dir.mktmpdir("kward-sandbox")
37
+ environment = Environment.command_worker(temporary_root)
38
+
39
+ LocalCommandRunner.new(
40
+ timeout_seconds: timeout_seconds,
41
+ max_output_bytes: max_output_bytes
42
+ ).run(
43
+ *command_argv(command, cwd: cwd, temporary_root: temporary_root),
44
+ env: environment,
45
+ cwd: cwd,
46
+ cancellation: cancellation,
47
+ unsetenv_others: true,
48
+ &block
49
+ )
50
+ ensure
51
+ FileUtils.remove_entry(temporary_root) if temporary_root && File.exist?(temporary_root)
52
+ end
53
+
54
+ def command_argv(command, cwd:, temporary_root: nil)
55
+ temporary_root ||= Dir.tmpdir
56
+ [EXECUTABLE, "-p", profile(temporary_root), "/bin/zsh", "-lc", command.to_s]
57
+ end
58
+
59
+ private
60
+
61
+ def profile(temporary_root)
62
+ writable_roots = policy.command_writable_roots + [File.realpath(temporary_root)]
63
+ rules = [
64
+ "(version 1)",
65
+ "(deny default)",
66
+ "(allow process*)",
67
+ "(allow signal)",
68
+ "(allow file-read*)",
69
+ "(allow file-write-data (literal \"/dev/null\"))",
70
+ "(allow sysctl-read)",
71
+ "(allow mach-lookup)",
72
+ "(allow ipc-posix-shm*)",
73
+ "(allow user-preference-read)",
74
+ "(allow pseudo-tty)"
75
+ ]
76
+ rules << "(allow network*)" if policy.child_network_allowed?
77
+ rules.concat(writable_roots.map { |path| "(allow file-write* (subpath #{seatbelt_string(path)}))" })
78
+ rules.concat(protected_read_roots.map { |path| "(deny file-read* (subpath #{seatbelt_string(path)}))" })
79
+ rules << "(deny file-write* (subpath #{seatbelt_string(File.join(policy.workspace_root, ".git"))}))" if policy.protect_git_metadata?
80
+ rules.join("\n")
81
+ end
82
+
83
+ def protected_read_roots
84
+ home = ENV.fetch("HOME", Dir.home)
85
+ PROTECTED_READ_DIRECTORIES.map do |path|
86
+ root = File.join(home, path)
87
+ File.exist?(root) ? File.realpath(root) : root
88
+ end
89
+ end
90
+
91
+ def seatbelt_string(value)
92
+ %Q{"#{value.to_s.gsub(/([\\"])/, '\\\\1')}"}
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Preserves existing LocalCommandRunner behavior when sandboxing is off.
7
+ class PassthroughRunner < CommandRunner
8
+ def command_argv(command, cwd:)
9
+ [command.to_s]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,74 @@
1
+ require "pathname"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Immutable, user-configured restrictions for a command worker.
7
+ class Policy
8
+ MODES = %w[off read_only workspace_write].freeze
9
+ NETWORK_MODES = %w[deny allow].freeze
10
+
11
+ attr_reader :mode, :network, :workspace_root, :writable_roots
12
+
13
+ def initialize(mode: "off", network: "deny", workspace_root:, writable_roots: [], protect_git_metadata: true)
14
+ @mode = normalize_mode(mode)
15
+ @network = normalize_network(network)
16
+ @workspace_root = canonical_path(workspace_root)
17
+ @writable_roots = normalize_writable_roots(writable_roots)
18
+ @protect_git_metadata = protect_git_metadata != false
19
+ end
20
+
21
+ def enabled?
22
+ mode != "off"
23
+ end
24
+
25
+ def read_only?
26
+ mode == "read_only"
27
+ end
28
+
29
+ def workspace_write?
30
+ mode == "workspace_write"
31
+ end
32
+
33
+ def protect_git_metadata?
34
+ @protect_git_metadata
35
+ end
36
+
37
+ def child_network_allowed?
38
+ network == "allow"
39
+ end
40
+
41
+ def command_writable_roots
42
+ return [] unless workspace_write?
43
+
44
+ ([workspace_root] + writable_roots).uniq
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_mode(value)
50
+ mode = value.to_s
51
+ return mode if MODES.include?(mode)
52
+
53
+ raise ArgumentError, "sandbox mode must be one of: #{MODES.join(", ")}"
54
+ end
55
+
56
+ def normalize_network(value)
57
+ network = value.to_s
58
+ return network if NETWORK_MODES.include?(network)
59
+
60
+ raise ArgumentError, "sandbox network must be one of: #{NETWORK_MODES.join(", ")}"
61
+ end
62
+
63
+ def normalize_writable_roots(roots)
64
+ Array(roots).map { |root| canonical_path(root) }.uniq.reject { |root| root == workspace_root }
65
+ end
66
+
67
+ def canonical_path(path)
68
+ Pathname.new(path.to_s).realpath.to_s
69
+ rescue Errno::ENOENT
70
+ raise ArgumentError, "sandbox path does not exist: #{path}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,55 @@
1
+ require_relative "capabilities"
2
+ require_relative "passthrough_runner"
3
+ require_relative "unavailable_runner"
4
+ require_relative "macos_seatbelt_runner"
5
+ require_relative "linux_bubblewrap_runner"
6
+
7
+ # Namespace for operating-system command sandboxing.
8
+ module Kward
9
+ module Sandbox
10
+ # Selects the command runner that can enforce a policy on the current host.
11
+ module RunnerFactory
12
+ module_function
13
+
14
+ def build(policy, platform: RUBY_PLATFORM)
15
+ return PassthroughRunner.new(policy:, capabilities: off_capabilities) unless policy.enabled?
16
+
17
+ runner_class = runner_class_for(platform)
18
+ return UnavailableRunner.new(policy:, capabilities: unsupported_capabilities(platform)) unless runner_class
19
+
20
+ capabilities = runner_class.capabilities(platform:)
21
+ return UnavailableRunner.new(policy:, capabilities:) unless capabilities.available?
22
+
23
+ runner_class.new(policy:, capabilities:)
24
+ end
25
+
26
+ def off_capabilities
27
+ Capabilities.new(
28
+ available: true,
29
+ filesystem_enforced: false,
30
+ child_network_enforced: false,
31
+ backend: "off"
32
+ )
33
+ end
34
+
35
+ def unsupported_capabilities(platform)
36
+ Capabilities.new(
37
+ available: false,
38
+ filesystem_enforced: false,
39
+ child_network_enforced: false,
40
+ backend: "unsupported",
41
+ reason: "Sandboxing is unsupported on #{platform}"
42
+ )
43
+ end
44
+
45
+ def runner_class_for(platform)
46
+ value = platform.to_s
47
+ return MacOSSeatbeltRunner if value.include?("darwin")
48
+ return LinuxBubblewrapRunner if value.include?("linux")
49
+
50
+ nil
51
+ end
52
+ private_class_method :runner_class_for
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ require_relative "command_runner"
2
+
3
+ # Namespace for operating-system command sandboxing.
4
+ module Kward
5
+ module Sandbox
6
+ # Raised when a requested sandbox cannot be enforced on this host.
7
+ class UnavailableError < StandardError
8
+ end
9
+
10
+ # Fails closed rather than running a requested sandbox policy unrestricted.
11
+ class UnavailableRunner < CommandRunner
12
+ def command_argv(command, cwd:)
13
+ []
14
+ end
15
+
16
+ def run(command, cwd:, timeout_seconds:, max_output_bytes:, cancellation: nil, &block)
17
+ raise UnavailableError, "Sandbox #{policy.mode} is unavailable: #{capabilities.reason}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "sandbox/policy"
2
+ require_relative "sandbox/capabilities"
3
+ require_relative "sandbox/environment"
4
+ require_relative "sandbox/command_runner"
5
+ require_relative "sandbox/passthrough_runner"
6
+ require_relative "sandbox/unavailable_runner"
7
+ require_relative "sandbox/macos_seatbelt_runner"
8
+ require_relative "sandbox/linux_bubblewrap_runner"
9
+ require_relative "sandbox/runner_factory"
@@ -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]
@@ -315,6 +348,16 @@ module Kward
315
348
  recent_sessions(limit: limit, keep_empty_path: keep_empty_path)
316
349
  end
317
350
 
351
+ # Lists all persisted sessions under this config directory for an explicit
352
+ # user-selected operation such as skill capture. Unlike #recent, this is not
353
+ # scoped to the store's current workspace.
354
+ def capture_candidates
355
+ pattern = File.join(@config_dir, "sessions", "**", "*.jsonl")
356
+ Dir.glob(pattern).filter_map { |path| session_info(path) }.sort_by(&:modified_at).reverse
357
+ rescue StandardError
358
+ []
359
+ end
360
+
318
361
  # Persists the last active session pointer for workspace auto-resume.
319
362
  def remember_last_session(session)
320
363
  return unless session&.path
@@ -381,21 +424,25 @@ module Kward
381
424
  end
382
425
 
383
426
  def append_leaf_change(path, leaf_id)
384
- append_record(path, {
427
+ record = {
385
428
  type: "leaf",
386
429
  timestamp: Time.now.utc.iso8601(3),
387
430
  targetId: leaf_id
388
- })
431
+ }
432
+ append_record(path, record)
433
+ record
389
434
  end
390
435
 
391
436
  def append_label_change(path, entry_id, label)
392
- append_record(path, {
437
+ record = {
393
438
  type: "label",
394
439
  id: next_entry_id(path),
395
440
  timestamp: Time.now.utc.iso8601(3),
396
441
  targetId: entry_id.to_s,
397
442
  label: label.to_s.strip.empty? ? nil : label.to_s.strip
398
- })
443
+ }
444
+ append_record(path, record)
445
+ record
399
446
  end
400
447
 
401
448
  # @return [Array<Hash>] nested session tree roots for the given session file
@@ -430,6 +477,22 @@ module Kward
430
477
  current_leaf_id(records_from_file(resolve_session_path(path)))
431
478
  end
432
479
 
480
+ # Returns the complete persisted active branch for an explicit export-like
481
+ # consumer. Unlike #load, this does not attach a conversation or mutate the
482
+ # session file.
483
+ #
484
+ # @return [Hash] session metadata, saved system-prompt snapshots, and active branch records
485
+ def capture_branch(path)
486
+ resolved_path = resolve_session_path(path)
487
+ records = records_from_file(resolved_path)
488
+ {
489
+ path: resolved_path,
490
+ header: session_header(records, resolved_path),
491
+ system_prompts: records.select { |record| record["type"] == "system_prompt" },
492
+ entries: branch_records(records)
493
+ }
494
+ end
495
+
433
496
  def append_record(path, record)
434
497
  File.open(path, "a", 0o600) do |file|
435
498
  file.write(JSON.generate(record))
@@ -437,18 +500,22 @@ module Kward
437
500
  end
438
501
  end
439
502
 
440
- def append_system_prompt_snapshot(path, system_message, reason: "changed")
503
+ def append_system_prompt_snapshot(path, system_message, reason: "changed", latest_hash: nil)
441
504
  content = MessageAccess.content(system_message).to_s
442
- return if content.empty?
443
- 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
444
510
 
445
511
  append_record(path, {
446
512
  type: "system_prompt",
447
513
  timestamp: Time.now.utc.iso8601(3),
448
514
  reason: reason.to_s,
449
- hash: system_prompt_hash(content),
515
+ hash: hash,
450
516
  content: content
451
517
  })
518
+ hash
452
519
  end
453
520
 
454
521
  def self.safe_cwd(cwd)
@@ -457,6 +524,16 @@ module Kward
457
524
 
458
525
  private
459
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
+
460
537
  def latest_system_prompt_hash(records)
461
538
  records.reverse_each do |record|
462
539
  next unless record["type"] == "system_prompt"
@@ -725,7 +802,7 @@ module Kward
725
802
  end
726
803
 
727
804
  def next_entry_id(_path)
728
- SecureRandom.hex(4)
805
+ SecureRandom.hex(16)
729
806
  end
730
807
 
731
808
  def message_entry_id(message)
@@ -771,7 +848,7 @@ module Kward
771
848
  keep_empty_paths = Array(keep_empty_path).filter_map do |path|
772
849
  File.expand_path(path) unless path.to_s.empty?
773
850
  end
774
- 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
775
852
  sessions = []
776
853
 
777
854
  paths.each do |path|
@@ -798,8 +875,16 @@ module Kward
798
875
  false
799
876
  end
800
877
 
801
- def session_file_mtime(path)
802
- 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)
803
888
  rescue StandardError
804
889
  Time.at(0)
805
890
  end
@@ -867,13 +952,14 @@ module Kward
867
952
  runtime = session_runtime(records, header)
868
953
  first_message = messages.find { |message| ["user", "compactionSummary"].include?(message_role(message)) }
869
954
  stats = File.stat(path)
955
+ created_at = parse_time(header["timestamp"]) || stats.mtime
870
956
 
871
957
  SessionInfo.new(
872
958
  id: header["id"],
873
959
  path: path,
874
960
  cwd: header["cwd"].to_s,
875
- created_at: parse_time(header["timestamp"]) || stats.mtime,
876
- modified_at: stats.mtime,
961
+ created_at: created_at,
962
+ modified_at: session_modified_at(records, fallback: stats.mtime),
877
963
  name: name,
878
964
  first_message: first_message ? message_text(first_message) : "",
879
965
  message_count: messages.count { |message| ["user", "assistant", "tool", "toolResult", "compactionSummary"].include?(message_role(message)) },