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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +13 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +82 -1
- data/Gemfile.lock +2 -2
- data/README.md +33 -38
- data/doc/agent-tools.md +10 -11
- 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 +136 -27
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +9 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +3 -3
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +130 -0
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +15 -5
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +11 -3
- data/doc/rpc.md +65 -31
- data/doc/sandboxing.md +142 -0
- data/doc/security.md +31 -10
- data/doc/session-management.md +2 -4
- data/doc/skills.md +10 -0
- 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 +4 -4
- 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/kward.gemspec +1 -1
- 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/doctor.rb +21 -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 +162 -36
- data/lib/kward/cli/slash_commands.rb +109 -1
- 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 +22 -1
- data/lib/kward/config_files.rb +106 -4
- data/lib/kward/conversation.rb +27 -8
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +364 -23
- data/lib/kward/model/model_info.rb +60 -1
- data/lib/kward/model/payloads.rb +64 -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 +141 -39
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +81 -8
- 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 +43 -10
- 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 +71 -12
- data/lib/kward/prompts/commands.rb +3 -1
- data/lib/kward/prompts.rb +22 -10
- 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 +56 -194
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +102 -14
- data/lib/kward/rpc/session_manager.rb +151 -38
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +110 -24
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +26 -6
- 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.rb +18 -3
- data/lib/kward/workspace_factory.rb +34 -0
- data/templates/default/fulldoc/html/js/kward.js +3 -0
- data/templates/default/kward_navigation.rb +8 -1
- data/templates/default/layout/html/setup.rb +5 -0
- 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"
|
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]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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|
|
|
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
|
|
802
|
-
|
|
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:
|
|
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)) },
|