kward 0.80.1 → 0.82.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/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/Rakefile +44 -4
- data/doc/composer.md +6 -9
- data/doc/configuration.md +13 -3
- data/doc/files.md +8 -2
- data/doc/permissions.md +1 -1
- data/doc/releasing.md +71 -38
- data/doc/rpc.md +2 -1
- data/doc/sandboxing.md +4 -4
- data/doc/security.md +6 -9
- data/doc/shell.md +161 -196
- data/doc/skills.md +17 -5
- data/doc/tabs.md +1 -1
- data/doc/usage.md +6 -5
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +1 -1
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/project_skills.rb +99 -0
- data/lib/kward/cli/project_skills_commands.rb +87 -0
- data/lib/kward/cli/prompt_interface.rb +18 -4
- data/lib/kward/cli/rendering.rb +41 -3
- data/lib/kward/cli/runtime_helpers.rb +222 -21
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +6 -0
- data/lib/kward/cli/tabs.rb +62 -5
- data/lib/kward/cli.rb +21 -1
- data/lib/kward/config_files.rb +9 -4
- data/lib/kward/conversation.rb +8 -5
- data/lib/kward/ekwsh.rb +37 -16
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +102 -30
- data/lib/kward/prompt_interface/composer_controller.rb +15 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/controller.rb +10 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +100 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
- data/lib/kward/prompt_interface/project_browser.rb +162 -3
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/screen.rb +40 -15
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +167 -44
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +97 -7
- data/lib/kward/skills/registry.rb +52 -2
- data/lib/kward/skills/trust_coordinator.rb +45 -0
- data/lib/kward/skills/trust_store.rb +107 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/version.rb +1 -1
- metadata +11 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Forwards PTY output immediately and optionally keeps a bounded byte copy.
|
|
4
|
+
#
|
|
5
|
+
# The runner only depends on the sink's `write` method and optional `flush`
|
|
6
|
+
# method. Capture is deliberately byte-oriented so PTY chunk boundaries have
|
|
7
|
+
# no effect on the retained output.
|
|
8
|
+
class PassthroughPtyOutputSink
|
|
9
|
+
attr_reader :captured_output
|
|
10
|
+
|
|
11
|
+
def initialize(output:, max_capture_bytes: nil)
|
|
12
|
+
@output = output
|
|
13
|
+
@max_capture_bytes = max_capture_bytes
|
|
14
|
+
@captured_output = max_capture_bytes ? +"".b : nil
|
|
15
|
+
@truncated = false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write(chunk)
|
|
19
|
+
@output.write(chunk)
|
|
20
|
+
capture(chunk)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def flush
|
|
24
|
+
@output.flush if @output.respond_to?(:flush)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def truncated?
|
|
28
|
+
@truncated
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def capture(chunk)
|
|
34
|
+
return unless @captured_output
|
|
35
|
+
|
|
36
|
+
remaining = @max_capture_bytes - @captured_output.bytesize
|
|
37
|
+
if chunk.bytesize > remaining
|
|
38
|
+
@captured_output << chunk.byteslice(0, remaining) if remaining.positive?
|
|
39
|
+
@truncated = true
|
|
40
|
+
else
|
|
41
|
+
@captured_output << chunk
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require_relative "ansi"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Reduces safe, line-oriented PTY redraws to transcript-friendly text.
|
|
6
|
+
class PtyTranscriptNormalizer
|
|
7
|
+
HORIZONTAL_REDRAW_PATTERN = /\r|\e\[[0-9;]*[CDGK`]/.freeze
|
|
8
|
+
|
|
9
|
+
def self.normalize(text)
|
|
10
|
+
text.split("\n", -1).map do |line|
|
|
11
|
+
if line.match?(HORIZONTAL_REDRAW_PATTERN)
|
|
12
|
+
Line.new(line).render
|
|
13
|
+
else
|
|
14
|
+
ANSI.sanitize_transcript(line)
|
|
15
|
+
end
|
|
16
|
+
end.join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Applies the horizontal cursor controls accepted by AdaptivePtyOutputSink
|
|
20
|
+
# without modeling a complete terminal screen.
|
|
21
|
+
class Line
|
|
22
|
+
def initialize(text)
|
|
23
|
+
@text = text
|
|
24
|
+
@cells = []
|
|
25
|
+
@cursor = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render
|
|
29
|
+
ANSI.scan_escape_tokens(@text).each do |token|
|
|
30
|
+
token[:escape] ? apply_escape(token[:text]) : write_text(token[:text])
|
|
31
|
+
end
|
|
32
|
+
@cells.join.rstrip
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def apply_escape(sequence)
|
|
38
|
+
final = sequence[-1]
|
|
39
|
+
return set_column(sequence) if final == "G" || final == "`"
|
|
40
|
+
return move_cursor(sequence, 1) if final == "C"
|
|
41
|
+
return move_cursor(sequence, -1) if final == "D"
|
|
42
|
+
return erase_line(sequence) if final == "K"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def set_column(sequence)
|
|
46
|
+
@cursor = [parameter(sequence, default: 1), 1].max - 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def move_cursor(sequence, direction)
|
|
50
|
+
distance = [parameter(sequence, default: 1), 1].max
|
|
51
|
+
@cursor = [@cursor + (distance * direction), 0].max
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def erase_line(sequence)
|
|
55
|
+
case parameter(sequence, default: 0)
|
|
56
|
+
when 1
|
|
57
|
+
fill_to_cursor
|
|
58
|
+
0.upto(@cursor) { |index| @cells[index] = " " }
|
|
59
|
+
when 2
|
|
60
|
+
@cells.clear
|
|
61
|
+
else
|
|
62
|
+
@cells.slice!(@cursor..) if @cursor < @cells.length
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parameter(sequence, default:)
|
|
67
|
+
value = sequence[2...-1].to_s.split(";", 2).first.to_s
|
|
68
|
+
value.empty? ? default : value.to_i
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_text(text)
|
|
72
|
+
text.each_char do |character|
|
|
73
|
+
case character
|
|
74
|
+
when "\r"
|
|
75
|
+
@cursor = 0
|
|
76
|
+
when "\b"
|
|
77
|
+
@cursor = [@cursor - 1, 0].max
|
|
78
|
+
when "\t"
|
|
79
|
+
@cursor = ((@cursor / 8) + 1) * 8
|
|
80
|
+
else
|
|
81
|
+
fill_to_cursor
|
|
82
|
+
@cells[@cursor] = character
|
|
83
|
+
@cursor += 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def fill_to_cursor
|
|
89
|
+
@cells.concat([" "] * (@cursor - @cells.length)) if @cursor > @cells.length
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -579,6 +579,7 @@ module Kward
|
|
|
579
579
|
stability: { protocol: "stable", compatibility: "additive-fields-unless-protocol-version-changes", experimentalCapabilities: [] },
|
|
580
580
|
commands: { supported: true, methods: COMMAND_METHODS, method: COMMAND_METHODS[0], runMethod: COMMAND_METHODS[1], sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
|
|
581
581
|
skillCapture: { supported: true, methods: SKILL_CAPTURE_METHODS, destination: "personal", source: "savedSessionActiveLeaf", reviewRequired: true, overwrite: "explicit", autoActivate: false },
|
|
582
|
+
projectSkillTrust: { supported: false, trustRequired: true, reason: "RPC has no interactive trust decision bridge; project skills remain skipped unless globally enabled." },
|
|
582
583
|
mcp: {
|
|
583
584
|
supported: true,
|
|
584
585
|
transport: "stdio",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "private_file"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Rebuildable lightweight summaries for persisted session event logs.
|
|
7
|
+
#
|
|
8
|
+
# Session JSONL files remain authoritative. Catalog entries are accepted only
|
|
9
|
+
# while their source fingerprint matches and may be deleted at any time.
|
|
10
|
+
class SessionCatalog
|
|
11
|
+
VERSION = 1
|
|
12
|
+
INDEX_DIRECTORY = ".index"
|
|
13
|
+
FILENAME = "sessions.json"
|
|
14
|
+
|
|
15
|
+
def initialize(session_dir:)
|
|
16
|
+
@path = File.join(session_dir, INDEX_DIRECTORY, FILENAME)
|
|
17
|
+
@entries = load_entries
|
|
18
|
+
@dirty = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(path)
|
|
22
|
+
entry = @entries[entry_key(path)]
|
|
23
|
+
return nil unless entry.is_a?(Hash)
|
|
24
|
+
return nil unless entry["source"] == fingerprint(path)
|
|
25
|
+
|
|
26
|
+
entry["summary"] if entry["summary"].is_a?(Hash)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write(path, summary, fingerprint: self.fingerprint(path))
|
|
32
|
+
@entries[entry_key(path)] = {
|
|
33
|
+
"source" => fingerprint,
|
|
34
|
+
"summary" => summary
|
|
35
|
+
}
|
|
36
|
+
@dirty = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def remove(path)
|
|
40
|
+
@dirty = true if @entries.delete(entry_key(path))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def retain(paths)
|
|
44
|
+
keys = paths.to_h { |path| [entry_key(path), true] }
|
|
45
|
+
previous_size = @entries.size
|
|
46
|
+
@entries.delete_if { |key, _entry| !keys[key] }
|
|
47
|
+
@dirty = true if @entries.size != previous_size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def flush
|
|
51
|
+
return unless @dirty
|
|
52
|
+
|
|
53
|
+
PrivateFile.write_json(@path, {
|
|
54
|
+
"version" => VERSION,
|
|
55
|
+
"entries" => @entries
|
|
56
|
+
})
|
|
57
|
+
@dirty = false
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fingerprint(path)
|
|
63
|
+
stat = File.stat(path)
|
|
64
|
+
{
|
|
65
|
+
"size" => stat.size,
|
|
66
|
+
"inode" => stat.ino,
|
|
67
|
+
"mtimeSeconds" => stat.mtime.to_i,
|
|
68
|
+
"mtimeNanoseconds" => stat.mtime.nsec
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def load_entries
|
|
75
|
+
data = JSON.parse(File.read(@path))
|
|
76
|
+
return {} unless data["version"] == VERSION && data["entries"].is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
data["entries"]
|
|
79
|
+
rescue StandardError
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def entry_key(path)
|
|
84
|
+
File.basename(path)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "message_access"
|
|
|
9
9
|
require_relative "message_text"
|
|
10
10
|
require_relative "private_file"
|
|
11
11
|
require_relative "rpc/tool_event_normalizer"
|
|
12
|
+
require_relative "session_catalog"
|
|
12
13
|
require_relative "tools/tool_call"
|
|
13
14
|
require_relative "workspace"
|
|
14
15
|
|
|
@@ -204,6 +205,7 @@ module Kward
|
|
|
204
205
|
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
|
|
205
206
|
@config_dir = config_dir
|
|
206
207
|
@cwd = File.expand_path(cwd)
|
|
208
|
+
@session_catalog = SessionCatalog.new(session_dir: session_dir)
|
|
207
209
|
end
|
|
208
210
|
|
|
209
211
|
# @return [String] workspace directory this store lists and creates sessions for
|
|
@@ -297,7 +299,7 @@ module Kward
|
|
|
297
299
|
# `workspace` is used both for the active root and to restore read-before-write
|
|
298
300
|
# paths from successful read tool results. If a session moved workspaces, load
|
|
299
301
|
# it through `session_location` first so the original cwd is respected.
|
|
300
|
-
def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil)
|
|
302
|
+
def load(path, workspace: Workspace.new, provider: nil, model: nil, reasoning_effort: nil, project_skill_paths: nil)
|
|
301
303
|
resolved_path = resolve_session_path(path)
|
|
302
304
|
records = records_from_file(resolved_path)
|
|
303
305
|
header = session_header(records, resolved_path)
|
|
@@ -317,7 +319,8 @@ module Kward
|
|
|
317
319
|
model: runtime["model"] || model,
|
|
318
320
|
reasoning_effort: runtime["reasoningEffort"] || reasoning_effort,
|
|
319
321
|
session_memories: memory_state["sessionMemories"],
|
|
320
|
-
last_memory_retrieval: memory_state["lastRetrieval"]
|
|
322
|
+
last_memory_retrieval: memory_state["lastRetrieval"],
|
|
323
|
+
project_skill_paths: project_skill_paths
|
|
321
324
|
)
|
|
322
325
|
restore_tool_output_artifacts(records, conversation)
|
|
323
326
|
conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
|
|
@@ -398,6 +401,8 @@ module Kward
|
|
|
398
401
|
return false unless unused_session_file?(path)
|
|
399
402
|
|
|
400
403
|
File.delete(path)
|
|
404
|
+
@session_catalog.remove(path)
|
|
405
|
+
@session_catalog.flush
|
|
401
406
|
true
|
|
402
407
|
rescue StandardError
|
|
403
408
|
false
|
|
@@ -848,21 +853,43 @@ module Kward
|
|
|
848
853
|
keep_empty_paths = Array(keep_empty_path).filter_map do |path|
|
|
849
854
|
File.expand_path(path) unless path.to_s.empty?
|
|
850
855
|
end
|
|
851
|
-
paths = Dir.glob(File.join(session_dir, "*.jsonl"))
|
|
852
|
-
|
|
856
|
+
paths = Dir.glob(File.join(session_dir, "*.jsonl"))
|
|
857
|
+
@session_catalog.retain(paths)
|
|
858
|
+
return all_recent_sessions(paths, keep_empty_paths: keep_empty_paths) if limit.nil?
|
|
859
|
+
|
|
860
|
+
candidates = paths.map do |path|
|
|
861
|
+
info = cataloged_session_info(path)
|
|
862
|
+
[path, info, info&.modified_at || session_file_activity_time(path)]
|
|
863
|
+
end
|
|
864
|
+
candidates.sort_by! { |_path, _info, modified_at| modified_at }.reverse!
|
|
853
865
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
866
|
+
sessions = []
|
|
867
|
+
candidates.each do |path, cataloged_info, _modified_at|
|
|
868
|
+
if sessions.length < limit
|
|
869
|
+
info = cataloged_info || cache_session_info(path)
|
|
857
870
|
next unless info
|
|
858
871
|
next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
|
|
859
872
|
|
|
860
873
|
sessions << info
|
|
874
|
+
elsif cataloged_info
|
|
875
|
+
delete_empty_unnamed_session_info(cataloged_info, keep_empty_paths: keep_empty_paths)
|
|
861
876
|
else
|
|
862
877
|
delete_empty_unnamed_session_path(path, keep_empty_paths: keep_empty_paths)
|
|
863
878
|
end
|
|
864
879
|
end
|
|
865
880
|
sessions
|
|
881
|
+
ensure
|
|
882
|
+
@session_catalog.flush
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def all_recent_sessions(paths, keep_empty_paths:)
|
|
886
|
+
paths.filter_map do |path|
|
|
887
|
+
info = cataloged_session_info(path) || cache_session_info(path)
|
|
888
|
+
next unless info
|
|
889
|
+
next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
|
|
890
|
+
|
|
891
|
+
info
|
|
892
|
+
end.sort_by(&:modified_at).reverse
|
|
866
893
|
end
|
|
867
894
|
|
|
868
895
|
def delete_empty_unnamed_session_info(info, keep_empty_paths: [])
|
|
@@ -870,6 +897,7 @@ module Kward
|
|
|
870
897
|
return true if keep_empty_paths.include?(File.expand_path(info.path))
|
|
871
898
|
|
|
872
899
|
File.delete(info.path)
|
|
900
|
+
@session_catalog.remove(info.path)
|
|
873
901
|
true
|
|
874
902
|
rescue StandardError
|
|
875
903
|
false
|
|
@@ -894,6 +922,7 @@ module Kward
|
|
|
894
922
|
return true if keep_empty_paths.include?(File.expand_path(path))
|
|
895
923
|
|
|
896
924
|
File.delete(path)
|
|
925
|
+
@session_catalog.remove(path)
|
|
897
926
|
true
|
|
898
927
|
rescue StandardError
|
|
899
928
|
false
|
|
@@ -943,6 +972,67 @@ module Kward
|
|
|
943
972
|
end
|
|
944
973
|
end
|
|
945
974
|
|
|
975
|
+
def cataloged_session_info(path)
|
|
976
|
+
summary = @session_catalog.fetch(path)
|
|
977
|
+
session_info_from_summary(path, summary) if summary
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
def cache_session_info(path)
|
|
981
|
+
fingerprint = @session_catalog.fingerprint(path)
|
|
982
|
+
info = session_info(path)
|
|
983
|
+
return nil unless info
|
|
984
|
+
|
|
985
|
+
current_fingerprint = @session_catalog.fingerprint(path)
|
|
986
|
+
if fingerprint == current_fingerprint
|
|
987
|
+
@session_catalog.write(path, session_info_summary(info), fingerprint: current_fingerprint)
|
|
988
|
+
end
|
|
989
|
+
info
|
|
990
|
+
rescue StandardError
|
|
991
|
+
nil
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
def session_info_summary(info)
|
|
995
|
+
{
|
|
996
|
+
"id" => info.id,
|
|
997
|
+
"cwd" => info.cwd,
|
|
998
|
+
"createdAt" => info.created_at.utc.iso8601(9),
|
|
999
|
+
"modifiedAt" => info.modified_at.utc.iso8601(9),
|
|
1000
|
+
"name" => info.name,
|
|
1001
|
+
"firstMessage" => info.first_message,
|
|
1002
|
+
"messageCount" => info.message_count,
|
|
1003
|
+
"provider" => info.provider,
|
|
1004
|
+
"model" => info.model,
|
|
1005
|
+
"reasoningEffort" => info.reasoning_effort,
|
|
1006
|
+
"parentId" => info.parent_id,
|
|
1007
|
+
"parentPath" => info.parent_path
|
|
1008
|
+
}
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
def session_info_from_summary(path, summary)
|
|
1012
|
+
created_at = parse_time(summary["createdAt"])
|
|
1013
|
+
modified_at = parse_time(summary["modifiedAt"])
|
|
1014
|
+
return nil if summary["id"].to_s.empty? || !created_at || !modified_at
|
|
1015
|
+
|
|
1016
|
+
SessionInfo.new(
|
|
1017
|
+
id: summary["id"],
|
|
1018
|
+
path: path,
|
|
1019
|
+
cwd: summary["cwd"].to_s,
|
|
1020
|
+
created_at: created_at,
|
|
1021
|
+
modified_at: modified_at,
|
|
1022
|
+
name: summary["name"],
|
|
1023
|
+
first_message: summary["firstMessage"].to_s,
|
|
1024
|
+
message_count: summary["messageCount"].to_i,
|
|
1025
|
+
provider: summary["provider"],
|
|
1026
|
+
model: summary["model"],
|
|
1027
|
+
reasoning_effort: summary["reasoningEffort"],
|
|
1028
|
+
parent_id: summary["parentId"],
|
|
1029
|
+
parent_path: summary["parentPath"],
|
|
1030
|
+
depth: 0,
|
|
1031
|
+
is_last: true,
|
|
1032
|
+
ancestor_continues: []
|
|
1033
|
+
)
|
|
1034
|
+
end
|
|
1035
|
+
|
|
946
1036
|
def session_info(path)
|
|
947
1037
|
records = records_from_file(path)
|
|
948
1038
|
header = session_header(records, path)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "pathname"
|
|
2
|
+
require_relative "trust_store"
|
|
2
3
|
|
|
3
4
|
# Namespace for the Kward CLI agent runtime.
|
|
4
5
|
module Kward
|
|
@@ -13,8 +14,9 @@ module Kward
|
|
|
13
14
|
# @api public
|
|
14
15
|
class Registry
|
|
15
16
|
SkillSource = Struct.new(:root, :label, :scope, :precedence, keyword_init: true)
|
|
17
|
+
SkillCandidate = Struct.new(:path, :root, :label, :scope, :digest, keyword_init: true)
|
|
16
18
|
|
|
17
|
-
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil)
|
|
19
|
+
def initialize(config_dir:, workspace_root:, project_skills_trusted:, skill_class:, max_file_bytes:, markdown_parser:, inside_directory:, warning_sink: nil, project_skill_paths: nil)
|
|
18
20
|
@config_dir = config_dir
|
|
19
21
|
@workspace_root = workspace_root
|
|
20
22
|
@project_skills_trusted = project_skills_trusted
|
|
@@ -23,6 +25,32 @@ module Kward
|
|
|
23
25
|
@markdown_parser = markdown_parser
|
|
24
26
|
@inside_directory = inside_directory
|
|
25
27
|
@warning_sink = warning_sink
|
|
28
|
+
@project_skill_paths = project_skill_paths&.map do |path|
|
|
29
|
+
File.realpath(path)
|
|
30
|
+
rescue SystemCallError
|
|
31
|
+
File.expand_path(path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns project skill files without activating their instructions.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<SkillCandidate>]
|
|
38
|
+
# @api public
|
|
39
|
+
def project_skill_candidates
|
|
40
|
+
skill_sources.select { |source| source.scope == :project }.flat_map do |source|
|
|
41
|
+
discover_source(source).filter_map do |path|
|
|
42
|
+
SkillCandidate.new(
|
|
43
|
+
path: path,
|
|
44
|
+
root: source.root,
|
|
45
|
+
label: source.label,
|
|
46
|
+
scope: source.scope,
|
|
47
|
+
digest: skill_digest(path)
|
|
48
|
+
)
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
emit_warning "Warning: skipping Kward skill #{path}: #{e.message}"
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
# Returns discovered, validated skills in precedence order.
|
|
@@ -99,17 +127,39 @@ module Kward
|
|
|
99
127
|
|
|
100
128
|
def scan_source(source)
|
|
101
129
|
return [] unless Dir.exist?(source.root)
|
|
102
|
-
if source.scope == :project && !@project_skills_trusted
|
|
130
|
+
if source.scope == :project && @project_skill_paths.nil? && !@project_skills_trusted
|
|
103
131
|
emit_warning "Warning: skipping #{source.label} in #{source.root}: project skills are not trusted"
|
|
104
132
|
return []
|
|
105
133
|
end
|
|
106
134
|
|
|
135
|
+
paths = discover_source(source)
|
|
136
|
+
return paths unless source.scope == :project && @project_skill_paths
|
|
137
|
+
|
|
138
|
+
paths.select { |path| @project_skill_paths.include?(canonical_path(path)) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def canonical_path(path)
|
|
142
|
+
File.realpath(path)
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
File.expand_path(path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def discover_source(source)
|
|
107
148
|
Dir.glob(File.join(source.root, "*", "SKILL.md")).sort
|
|
108
149
|
rescue StandardError => e
|
|
109
150
|
emit_warning "Warning: skipping #{source.label} in #{source.root}: #{e.message}"
|
|
110
151
|
[]
|
|
111
152
|
end
|
|
112
153
|
|
|
154
|
+
def skill_digest(path)
|
|
155
|
+
TrustStore.digest_files(skill_files(path), root: @workspace_root)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def skill_files(path)
|
|
159
|
+
folder = File.dirname(path)
|
|
160
|
+
[path] + skill_resources(folder).map { |resource| File.join(folder, resource) }
|
|
161
|
+
end
|
|
162
|
+
|
|
113
163
|
def skill_content(skill, content)
|
|
114
164
|
lines = [
|
|
115
165
|
%(<skill_content name="#{xml_escape(skill.name)}">),
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require_relative "trust_store"
|
|
2
|
+
|
|
3
|
+
# Namespace for Agent Skills discovery and trust management.
|
|
4
|
+
module Kward
|
|
5
|
+
module Skills
|
|
6
|
+
# Resolves which discovered project skills need a user decision.
|
|
7
|
+
class TrustCoordinator
|
|
8
|
+
def initialize(workspace_root:, trust_store:)
|
|
9
|
+
@workspace_root = workspace_root
|
|
10
|
+
@trust_store = trust_store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def decision(candidate)
|
|
14
|
+
@trust_store.decision(
|
|
15
|
+
workspace_root: @workspace_root,
|
|
16
|
+
skill_path: candidate.path,
|
|
17
|
+
digest: candidate.digest
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def pending(candidates)
|
|
22
|
+
candidates.reject { |candidate| decision(candidate) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def allowed_paths(candidates)
|
|
26
|
+
candidates.filter_map { |candidate| candidate.path if decision(candidate) == "allow" }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remove_workspace!
|
|
30
|
+
@trust_store.remove_workspace!(workspace_root: @workspace_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record!(candidates, decision)
|
|
34
|
+
candidates.each do |candidate|
|
|
35
|
+
@trust_store.set_decision!(
|
|
36
|
+
workspace_root: @workspace_root,
|
|
37
|
+
skill_path: candidate.path,
|
|
38
|
+
digest: candidate.digest,
|
|
39
|
+
decision: decision
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../path_guard"
|
|
5
|
+
require_relative "../private_file"
|
|
6
|
+
|
|
7
|
+
# Namespace for Agent Skills discovery and trust management.
|
|
8
|
+
module Kward
|
|
9
|
+
module Skills
|
|
10
|
+
# Persists workspace-scoped decisions for project skill snapshots.
|
|
11
|
+
class TrustStore
|
|
12
|
+
DECISIONS = %w[allow deny].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :path
|
|
15
|
+
|
|
16
|
+
def initialize(config_dir:)
|
|
17
|
+
@path = File.join(File.expand_path(config_dir), "trusted_project_skills.json")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def decision(workspace_root:, skill_path:, digest:)
|
|
21
|
+
record = skill_record(workspace_root: workspace_root, skill_path: skill_path)
|
|
22
|
+
return unless record
|
|
23
|
+
return unless record["digest"] == digest.to_s
|
|
24
|
+
|
|
25
|
+
decision = record["decision"]
|
|
26
|
+
decision if DECISIONS.include?(decision)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def set_decision!(workspace_root:, skill_path:, digest:, decision:)
|
|
30
|
+
decision = decision.to_s
|
|
31
|
+
raise ArgumentError, "invalid project skill trust decision: #{decision}" unless DECISIONS.include?(decision)
|
|
32
|
+
|
|
33
|
+
config = read_config
|
|
34
|
+
workspace = workspace_key(workspace_root)
|
|
35
|
+
skill = skill_key(workspace_root, skill_path)
|
|
36
|
+
config["workspaces"] ||= {}
|
|
37
|
+
config["workspaces"][workspace] ||= { "skills" => {} }
|
|
38
|
+
config["workspaces"][workspace]["skills"] ||= {}
|
|
39
|
+
config["workspaces"][workspace]["skills"][skill] = {
|
|
40
|
+
"digest" => digest.to_s,
|
|
41
|
+
"decision" => decision
|
|
42
|
+
}
|
|
43
|
+
PrivateFile.write_json(path, config)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove_workspace!(workspace_root:)
|
|
47
|
+
config = read_config
|
|
48
|
+
config.fetch("workspaces", {}).delete(workspace_key(workspace_root))
|
|
49
|
+
PrivateFile.write_json(path, config)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remove_skill!(workspace_root:, skill_path:)
|
|
53
|
+
config = read_config
|
|
54
|
+
workspace = config.fetch("workspaces", {})[workspace_key(workspace_root)]
|
|
55
|
+
workspace&.fetch("skills", {})&.delete(skill_key(workspace_root, skill_path))
|
|
56
|
+
PrivateFile.write_json(path, config)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.digest_files(paths, root: nil)
|
|
60
|
+
root = File.realpath(root) if root
|
|
61
|
+
digest = Digest::SHA256.new
|
|
62
|
+
paths.map { |path| File.realpath(path) }.sort.each do |path|
|
|
63
|
+
raise ArgumentError, "file is outside digest root" if root && !PathGuard.inside?(path, root)
|
|
64
|
+
|
|
65
|
+
relative_path = root ? Pathname.new(path).relative_path_from(Pathname.new(root)).to_s : path
|
|
66
|
+
digest.update(relative_path)
|
|
67
|
+
digest.update("\0")
|
|
68
|
+
digest.update(File.binread(path))
|
|
69
|
+
digest.update("\0")
|
|
70
|
+
end
|
|
71
|
+
digest.hexdigest
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def skill_record(workspace_root:, skill_path:)
|
|
77
|
+
workspace = read_config.fetch("workspaces", {})[workspace_key(workspace_root)]
|
|
78
|
+
workspace&.fetch("skills", {})&.[](skill_key(workspace_root, skill_path))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_config
|
|
82
|
+
return {} unless File.file?(path)
|
|
83
|
+
|
|
84
|
+
config = JSON.parse(File.read(path))
|
|
85
|
+
config.is_a?(Hash) ? config : {}
|
|
86
|
+
rescue JSON::ParserError, SystemCallError
|
|
87
|
+
{}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def workspace_key(workspace_root)
|
|
91
|
+
File.realpath(workspace_root)
|
|
92
|
+
rescue SystemCallError
|
|
93
|
+
File.expand_path(workspace_root)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def skill_key(workspace_root, skill_path)
|
|
97
|
+
workspace = workspace_key(workspace_root)
|
|
98
|
+
skill = File.realpath(skill_path)
|
|
99
|
+
raise ArgumentError, "project skill is outside workspace" unless PathGuard.inside?(skill, workspace)
|
|
100
|
+
|
|
101
|
+
Pathname.new(skill).relative_path_from(Pathname.new(workspace)).to_s
|
|
102
|
+
rescue SystemCallError
|
|
103
|
+
raise ArgumentError, "project skill path does not exist"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|