yorishiro 0.1.0 → 0.2.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/.yorishirorc.sample +51 -0
- data/CHANGELOG.md +41 -0
- data/README.md +134 -8
- data/docs/en/README.md +97 -2
- data/docs/ja/README.md +97 -2
- data/docs/ja/how_to_create_skills.md +0 -0
- data/docs/ja/ollama_setup.md +148 -0
- data/lib/yorishiro/cli.rb +474 -93
- data/lib/yorishiro/compactor.rb +53 -0
- data/lib/yorishiro/configuration.rb +69 -1
- data/lib/yorishiro/conversation.rb +117 -0
- data/lib/yorishiro/diff.rb +92 -0
- data/lib/yorishiro/hooks.rb +64 -0
- data/lib/yorishiro/input_history.rb +49 -0
- data/lib/yorishiro/mcp/server_manager.rb +4 -4
- data/lib/yorishiro/provider/anthropic.rb +37 -8
- data/lib/yorishiro/provider/base.rb +20 -4
- data/lib/yorishiro/provider/ollama.rb +70 -45
- data/lib/yorishiro/provider/open_ai.rb +21 -5
- data/lib/yorishiro/session_resume.rb +73 -0
- data/lib/yorishiro/session_store.rb +168 -0
- data/lib/yorishiro/skill.rb +11 -0
- data/lib/yorishiro/skill_loader.rb +53 -0
- data/lib/yorishiro/sub_agent.rb +143 -0
- data/lib/yorishiro/tool.rb +7 -0
- data/lib/yorishiro/tool_result_cap.rb +31 -0
- data/lib/yorishiro/tools/edit_file.rb +87 -0
- data/lib/yorishiro/tools/execute_command.rb +8 -0
- data/lib/yorishiro/tools/exit_plan_mode.rb +41 -0
- data/lib/yorishiro/tools/grep.rb +118 -0
- data/lib/yorishiro/tools/list_files.rb +10 -8
- data/lib/yorishiro/tools/read_file.rb +27 -9
- data/lib/yorishiro/tools/task.rb +74 -0
- data/lib/yorishiro/tools/write_file.rb +9 -0
- data/lib/yorishiro/version.rb +1 -1
- data/lib/yorishiro.rb +13 -1
- metadata +33 -5
- data/lib/yorishiro/mcp/stdio_transport.rb +0 -85
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
# Caps a tool result before it enters a conversation so a single huge
|
|
5
|
+
# output (a whole file, a big command dump) cannot exhaust a small
|
|
6
|
+
# context window. Scaled to the provider budget when one is known.
|
|
7
|
+
# Shared by the CLI agent loop and subagents.
|
|
8
|
+
module ToolResultCap
|
|
9
|
+
# Tool-result size caps used when the provider reports no context
|
|
10
|
+
# budget (cloud models) and as the floor when it does.
|
|
11
|
+
DEFAULT_TOOL_RESULT_CHARS = 30_000
|
|
12
|
+
MIN_TOOL_RESULT_CHARS = 2_000
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def cap(output, budget:)
|
|
17
|
+
limit = max_chars(budget)
|
|
18
|
+
return output if output.to_s.length <= limit
|
|
19
|
+
|
|
20
|
+
"#{output[0...limit]}\n... (tool output truncated: showing #{limit} of #{output.length} characters. " \
|
|
21
|
+
"Narrow the request — offset/limit, a glob, or a more specific pattern — to see the rest.)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# One tool result may take at most a quarter of the context budget.
|
|
25
|
+
def max_chars(budget)
|
|
26
|
+
return DEFAULT_TOOL_RESULT_CHARS unless budget
|
|
27
|
+
|
|
28
|
+
[budget * Conversation::CHARS_PER_TOKEN / 4, MIN_TOOL_RESULT_CHARS].max
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
module Tools
|
|
5
|
+
class EditFile < Tool
|
|
6
|
+
def name
|
|
7
|
+
"edit_file"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def description
|
|
11
|
+
"Replace an exact string in a file. old_string must match the file content exactly " \
|
|
12
|
+
"(including whitespace and indentation) and must be unique unless replace_all is true."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parameters
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
path: { type: "string", description: "The file path to edit" },
|
|
20
|
+
old_string: { type: "string", description: "The exact text to replace" },
|
|
21
|
+
new_string: { type: "string", description: "The text to replace it with" },
|
|
22
|
+
replace_all: { type: "boolean", description: "Replace all occurrences (default: false)" }
|
|
23
|
+
},
|
|
24
|
+
required: %w[path old_string new_string]
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def execute(**params)
|
|
29
|
+
path = params[:path] || params["path"]
|
|
30
|
+
old_string = params[:old_string] || params["old_string"]
|
|
31
|
+
new_string = params[:new_string] || params["new_string"]
|
|
32
|
+
replace_all = params[:replace_all] || params["replace_all"] || false
|
|
33
|
+
|
|
34
|
+
raise "File not found: #{path}" unless File.exist?(path)
|
|
35
|
+
raise "Not a file: #{path}" unless File.file?(path)
|
|
36
|
+
|
|
37
|
+
content = File.read(path)
|
|
38
|
+
new_content, count = apply_edit(content, old_string, new_string, replace_all, path)
|
|
39
|
+
|
|
40
|
+
File.write(path, new_content)
|
|
41
|
+
"Successfully replaced #{count} occurrence(s) in #{path}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def permission_check(_arguments)
|
|
45
|
+
:ask
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def preview(arguments)
|
|
49
|
+
path = arguments[:path] || arguments["path"]
|
|
50
|
+
old_string = arguments[:old_string] || arguments["old_string"]
|
|
51
|
+
new_string = arguments[:new_string] || arguments["new_string"]
|
|
52
|
+
replace_all = arguments[:replace_all] || arguments["replace_all"] || false
|
|
53
|
+
return nil unless path && old_string && new_string
|
|
54
|
+
return nil unless File.file?(path)
|
|
55
|
+
|
|
56
|
+
content = File.read(path)
|
|
57
|
+
new_content, = apply_edit(content, old_string, new_string, replace_all, path)
|
|
58
|
+
Diff.unified(content, new_content, path: path)
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def apply_edit(content, old_string, new_string, replace_all, path)
|
|
66
|
+
raise "old_string and new_string are identical. Provide a different new_string." if old_string == new_string
|
|
67
|
+
|
|
68
|
+
count = content.scan(old_string).length
|
|
69
|
+
if count.zero?
|
|
70
|
+
raise "old_string not found in #{path}. Read the file first and copy the exact text, " \
|
|
71
|
+
"including whitespace and indentation."
|
|
72
|
+
end
|
|
73
|
+
if count > 1 && !replace_all
|
|
74
|
+
raise "old_string appears #{count} times in #{path}. Add surrounding lines to make it unique, " \
|
|
75
|
+
"or set replace_all: true."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Block form so backreference sequences (\1, \\) in new_string are
|
|
79
|
+
# written literally instead of being interpreted.
|
|
80
|
+
new_content = replace_all ? content.gsub(old_string) { new_string } : content.sub(old_string) { new_string }
|
|
81
|
+
[new_content, replace_all ? count : 1]
|
|
82
|
+
rescue ArgumentError => e
|
|
83
|
+
raise "Cannot edit #{path}: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -5,6 +5,13 @@ require "open3"
|
|
|
5
5
|
module Yorishiro
|
|
6
6
|
module Tools
|
|
7
7
|
class ExecuteCommand < Tool
|
|
8
|
+
# Commands run via `sh -c`, so these characters can chain or inject
|
|
9
|
+
# additional commands (`;`, `&`, `|`, newlines), substitute commands
|
|
10
|
+
# (`$`, backtick, `(`, `)`), or redirect files (`<`, `>`). Commands
|
|
11
|
+
# containing any of them never auto-match allow_commands and always
|
|
12
|
+
# fall back to the interactive permission prompt.
|
|
13
|
+
SHELL_METACHARACTERS = /[;&|`$<>()\n\r]/
|
|
14
|
+
|
|
8
15
|
def initialize
|
|
9
16
|
super
|
|
10
17
|
@allow_commands = []
|
|
@@ -62,6 +69,7 @@ module Yorishiro
|
|
|
62
69
|
|
|
63
70
|
def command_allowed?(command)
|
|
64
71
|
return true if @session_allowed.include?(command)
|
|
72
|
+
return false if command.match?(SHELL_METACHARACTERS)
|
|
65
73
|
|
|
66
74
|
@allow_commands.any? { |pattern| File.fnmatch(pattern, command) }
|
|
67
75
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
module Tools
|
|
5
|
+
# A plan-mode-only tool the model calls to signal that it has finished
|
|
6
|
+
# researching and is ready to present its implementation plan. Calling it
|
|
7
|
+
# is what breaks the plan loop, so the model has an explicit way to stop
|
|
8
|
+
# reading files instead of looping on read-only tools forever.
|
|
9
|
+
class ExitPlanMode < Tool
|
|
10
|
+
def read_only?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name
|
|
15
|
+
"exit_plan_mode"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def description
|
|
19
|
+
"Call this tool once you have finished researching and are ready to " \
|
|
20
|
+
"present your implementation plan to the user for approval. Pass the " \
|
|
21
|
+
"complete plan as the `plan` argument. As soon as you understand the " \
|
|
22
|
+
"task, STOP calling read-only tools (read_file, list_files) and call " \
|
|
23
|
+
"this tool instead."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parameters
|
|
27
|
+
{
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
plan: { type: "string", description: "The complete implementation plan as markdown text" }
|
|
31
|
+
},
|
|
32
|
+
required: ["plan"]
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def execute(**params)
|
|
37
|
+
params[:plan] || params["plan"] || ""
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
module Tools
|
|
5
|
+
class Grep < Tool
|
|
6
|
+
MAX_RESULTS = 100
|
|
7
|
+
EXCLUDED_DIRS = %w[.git node_modules vendor tmp].freeze
|
|
8
|
+
BINARY_CHECK_BYTES = 8_000
|
|
9
|
+
MAX_LINE_LENGTH = 250
|
|
10
|
+
|
|
11
|
+
def read_only?
|
|
12
|
+
true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
"grep"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def description
|
|
20
|
+
"Search file contents recursively with a Ruby regular expression. " \
|
|
21
|
+
"Returns matches as 'file:line:content'. Hidden files and directories (e.g. .git) are skipped."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parameters
|
|
25
|
+
{
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
pattern: { type: "string", description: "Ruby regular expression to search for" },
|
|
29
|
+
path: { type: "string", description: "Directory to search in (default: current directory)" },
|
|
30
|
+
glob: { type: "string", description: "Glob pattern to filter files (e.g., '*.rb')" }
|
|
31
|
+
},
|
|
32
|
+
required: ["pattern"]
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def execute(**params)
|
|
37
|
+
pattern = params[:pattern] || params["pattern"]
|
|
38
|
+
path = params[:path] || params["path"] || "."
|
|
39
|
+
glob = params[:glob] || params["glob"]
|
|
40
|
+
|
|
41
|
+
regexp = build_regexp(pattern)
|
|
42
|
+
raise "Directory not found: #{path}" unless Dir.exist?(path)
|
|
43
|
+
|
|
44
|
+
matches, truncated = search(regexp, path, glob)
|
|
45
|
+
format_results(matches, truncated, pattern)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_regexp(pattern)
|
|
51
|
+
Regexp.new(pattern)
|
|
52
|
+
rescue RegexpError => e
|
|
53
|
+
raise "Invalid regular expression: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def search(regexp, path, glob)
|
|
57
|
+
matches = []
|
|
58
|
+
|
|
59
|
+
target_files(path, glob).each do |file|
|
|
60
|
+
scan_file(file, regexp) do |line_number, line|
|
|
61
|
+
return [matches, true] if matches.length >= MAX_RESULTS
|
|
62
|
+
|
|
63
|
+
matches << "#{file}:#{line_number}:#{line}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
[matches, false]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Dir.glob without FNM_DOTMATCH already skips dotfiles and dot
|
|
71
|
+
# directories; EXCLUDED_DIRS guards against globs that reach into
|
|
72
|
+
# them explicitly (e.g. vendored or dependency directories).
|
|
73
|
+
def target_files(path, glob)
|
|
74
|
+
glob_pattern = File.join(path, glob ? "**/#{glob}" : "**/*")
|
|
75
|
+
Dir.glob(glob_pattern).select do |file|
|
|
76
|
+
File.file?(file) && !excluded?(file, path)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Only path components below the search root count as excluded, so
|
|
81
|
+
# searching inside e.g. /tmp/project still works.
|
|
82
|
+
def excluded?(file, base)
|
|
83
|
+
relative = file.delete_prefix(File.join(base, ""))
|
|
84
|
+
relative.split(File::SEPARATOR).any? { |part| EXCLUDED_DIRS.include?(part) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def scan_file(file, regexp)
|
|
88
|
+
return if binary?(file)
|
|
89
|
+
|
|
90
|
+
File.foreach(file).with_index(1) do |line, line_number|
|
|
91
|
+
line = line.scrub.chomp
|
|
92
|
+
yield line_number, truncate_line(line) if regexp.match?(line)
|
|
93
|
+
end
|
|
94
|
+
rescue SystemCallError
|
|
95
|
+
# Unreadable files are skipped.
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def binary?(file)
|
|
99
|
+
chunk = File.binread(file, BINARY_CHECK_BYTES)
|
|
100
|
+
chunk&.include?("\0")
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def truncate_line(line)
|
|
106
|
+
line.length > MAX_LINE_LENGTH ? "#{line[0...MAX_LINE_LENGTH]}..." : line
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def format_results(matches, truncated, pattern)
|
|
110
|
+
return "No matches found for pattern: #{pattern}" if matches.empty?
|
|
111
|
+
|
|
112
|
+
result = matches.join("\n")
|
|
113
|
+
result += "\n... (truncated: showing first #{MAX_RESULTS} matches)" if truncated
|
|
114
|
+
result
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -32,14 +32,16 @@ module Yorishiro
|
|
|
32
32
|
|
|
33
33
|
raise "Directory not found: #{path}" unless Dir.exist?(path)
|
|
34
34
|
|
|
35
|
-
if pattern
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
entries = if pattern
|
|
36
|
+
# Glob results already carry the path prefix.
|
|
37
|
+
Dir.glob(File.join(path, pattern)).map { |f| [f, f] }
|
|
38
|
+
else
|
|
39
|
+
# Dir.children returns bare names — resolve them against
|
|
40
|
+
# +path+, not the process cwd, when checking for directories.
|
|
41
|
+
Dir.children(path).sort.map { |name| [name, File.join(path, name)] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
entries.map { |display, full| File.directory?(full) ? "#{display}/" : display }.join("\n")
|
|
43
45
|
end
|
|
44
46
|
end
|
|
45
47
|
end
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
module Yorishiro
|
|
4
4
|
module Tools
|
|
5
5
|
class ReadFile < Tool
|
|
6
|
+
# Cap how much of a file one call can return. Small local-model
|
|
7
|
+
# context windows (e.g. Ollama with num_ctx 8192) are exhausted by a
|
|
8
|
+
# single whole-file dump, so large files must be paged instead.
|
|
9
|
+
MAX_LINES = 200
|
|
10
|
+
MAX_LINE_LENGTH = 500
|
|
11
|
+
|
|
6
12
|
def read_only?
|
|
7
13
|
true
|
|
8
14
|
end
|
|
@@ -12,7 +18,8 @@ module Yorishiro
|
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
def description
|
|
15
|
-
"Read the contents of a file at the specified path.
|
|
21
|
+
"Read the contents of a file at the specified path. Returns at most #{MAX_LINES} lines per call; " \
|
|
22
|
+
"use offset and limit to page through larger files."
|
|
16
23
|
end
|
|
17
24
|
|
|
18
25
|
def parameters
|
|
@@ -21,7 +28,7 @@ module Yorishiro
|
|
|
21
28
|
properties: {
|
|
22
29
|
path: { type: "string", description: "The file path to read" },
|
|
23
30
|
offset: { type: "integer", description: "Line number to start reading from (0-based)" },
|
|
24
|
-
limit: { type: "integer", description: "Maximum number of lines to read" }
|
|
31
|
+
limit: { type: "integer", description: "Maximum number of lines to read (capped at #{MAX_LINES})" }
|
|
25
32
|
},
|
|
26
33
|
required: ["path"]
|
|
27
34
|
}
|
|
@@ -29,21 +36,32 @@ module Yorishiro
|
|
|
29
36
|
|
|
30
37
|
def execute(**params)
|
|
31
38
|
path = params[:path] || params["path"]
|
|
32
|
-
offset = (params[:offset] || params["offset"])
|
|
39
|
+
offset = (params[:offset] || params["offset"]).to_i
|
|
33
40
|
limit = (params[:limit] || params["limit"])&.to_i
|
|
41
|
+
limit = limit.nil? ? MAX_LINES : [limit, MAX_LINES].min
|
|
34
42
|
|
|
35
43
|
raise "File not found: #{path}" unless File.exist?(path)
|
|
36
44
|
raise "Not a file: #{path}" unless File.file?(path)
|
|
37
45
|
|
|
38
46
|
lines = File.readlines(path)
|
|
47
|
+
selected = lines[offset, limit] || []
|
|
48
|
+
|
|
49
|
+
output = selected.each_with_index.map { |line, i| "#{offset + i + 1}: #{truncate_line(line)}" }.join
|
|
50
|
+
output + paging_notice(lines.length, offset, selected.length)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def truncate_line(line)
|
|
56
|
+
return line if line.chomp.length <= MAX_LINE_LENGTH
|
|
57
|
+
|
|
58
|
+
"#{line.chomp[0...MAX_LINE_LENGTH]}... (line truncated)\n"
|
|
59
|
+
end
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
limit ||= lines.length
|
|
43
|
-
lines = lines[offset, limit] || []
|
|
44
|
-
end
|
|
61
|
+
def paging_notice(total, offset, shown)
|
|
62
|
+
return "" unless total > offset + shown
|
|
45
63
|
|
|
46
|
-
|
|
64
|
+
"... (file has #{total} lines; showing #{offset + 1}-#{offset + shown}. Use offset/limit to read more.)"
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
module Tools
|
|
5
|
+
class Task < Tool
|
|
6
|
+
# Read-only: the subagent only ever receives read-only tools, so a
|
|
7
|
+
# task can never mutate anything.
|
|
8
|
+
def read_only?
|
|
9
|
+
true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def name
|
|
13
|
+
"task"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"Delegate a read-only research task to a subagent with its own fresh " \
|
|
18
|
+
"context window. The subagent can read files, list files, and grep, " \
|
|
19
|
+
"and returns a text summary of its findings. Use it for exploratory " \
|
|
20
|
+
"work (finding where something is defined, summarizing several files) " \
|
|
21
|
+
"to keep your own context small. The subagent cannot see this " \
|
|
22
|
+
"conversation, so the prompt must be complete and self-contained."
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parameters
|
|
26
|
+
{
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
description: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Short (3-7 word) label for the task"
|
|
32
|
+
},
|
|
33
|
+
prompt: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "Complete, self-contained instructions: what to investigate and what to report back"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
required: ["prompt"]
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Called by the CLI so the subagent reuses the session's provider and
|
|
43
|
+
# prints its progress to the session's output.
|
|
44
|
+
def attach(provider:, output:)
|
|
45
|
+
@provider = provider
|
|
46
|
+
@output = output
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def execute(**params)
|
|
50
|
+
prompt = params[:prompt] || params["prompt"]
|
|
51
|
+
raise "prompt is required" if prompt.to_s.strip.empty?
|
|
52
|
+
|
|
53
|
+
SubAgent.new(provider: provider, tools: child_tools, output: output).run(prompt)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def provider
|
|
59
|
+
@provider ||= Provider.build(Yorishiro.configuration)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def output
|
|
63
|
+
@output || $stdout
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Read-only tools never prompt for permission, so the subagent loop
|
|
67
|
+
# runs unattended. The task tool itself is excluded so subagents
|
|
68
|
+
# cannot nest.
|
|
69
|
+
def child_tools
|
|
70
|
+
Yorishiro.configuration.allowed_tools.select(&:read_only?).grep_v(Task)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -36,6 +36,15 @@ module Yorishiro
|
|
|
36
36
|
def permission_check(_arguments)
|
|
37
37
|
:ask
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
def preview(arguments)
|
|
41
|
+
path = arguments[:path] || arguments["path"]
|
|
42
|
+
content = arguments[:content] || arguments["content"]
|
|
43
|
+
return nil unless path && content
|
|
44
|
+
|
|
45
|
+
old_content = File.file?(path) ? File.read(path) : ""
|
|
46
|
+
Diff.unified(old_content, content, path: path)
|
|
47
|
+
end
|
|
39
48
|
end
|
|
40
49
|
end
|
|
41
50
|
end
|
data/lib/yorishiro/version.rb
CHANGED
data/lib/yorishiro.rb
CHANGED
|
@@ -6,18 +6,30 @@ require "securerandom"
|
|
|
6
6
|
|
|
7
7
|
require_relative "yorishiro/version"
|
|
8
8
|
require_relative "yorishiro/tool"
|
|
9
|
+
require_relative "yorishiro/diff"
|
|
9
10
|
require_relative "yorishiro/skill"
|
|
11
|
+
require_relative "yorishiro/skill_loader"
|
|
12
|
+
require_relative "yorishiro/hooks"
|
|
10
13
|
require_relative "yorishiro/configuration"
|
|
11
14
|
require_relative "yorishiro/conversation"
|
|
15
|
+
require_relative "yorishiro/tool_result_cap"
|
|
16
|
+
require_relative "yorishiro/input_history"
|
|
17
|
+
require_relative "yorishiro/session_store"
|
|
18
|
+
require_relative "yorishiro/session_resume"
|
|
19
|
+
require_relative "yorishiro/compactor"
|
|
12
20
|
require_relative "yorishiro/provider/base"
|
|
13
21
|
require_relative "yorishiro/provider/anthropic"
|
|
14
22
|
require_relative "yorishiro/provider/open_ai"
|
|
15
23
|
require_relative "yorishiro/provider/ollama"
|
|
24
|
+
require_relative "yorishiro/sub_agent"
|
|
16
25
|
require_relative "yorishiro/tools/read_file"
|
|
17
26
|
require_relative "yorishiro/tools/write_file"
|
|
27
|
+
require_relative "yorishiro/tools/edit_file"
|
|
18
28
|
require_relative "yorishiro/tools/list_files"
|
|
29
|
+
require_relative "yorishiro/tools/grep"
|
|
19
30
|
require_relative "yorishiro/tools/execute_command"
|
|
20
|
-
require_relative "yorishiro/
|
|
31
|
+
require_relative "yorishiro/tools/exit_plan_mode"
|
|
32
|
+
require_relative "yorishiro/tools/task"
|
|
21
33
|
require_relative "yorishiro/mcp/tool"
|
|
22
34
|
require_relative "yorishiro/mcp/server_manager"
|
|
23
35
|
require_relative "yorishiro/cli"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yorishiro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- S-H-GAMELINKS
|
|
@@ -10,7 +10,7 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: base64
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
@@ -24,7 +24,7 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: json
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mcp
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.22'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.22'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: net-http
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -96,23 +110,37 @@ files:
|
|
|
96
110
|
- Rakefile
|
|
97
111
|
- docs/en/README.md
|
|
98
112
|
- docs/ja/README.md
|
|
113
|
+
- docs/ja/how_to_create_skills.md
|
|
114
|
+
- docs/ja/ollama_setup.md
|
|
99
115
|
- exe/yorishiro
|
|
100
116
|
- lib/yorishiro.rb
|
|
101
117
|
- lib/yorishiro/cli.rb
|
|
118
|
+
- lib/yorishiro/compactor.rb
|
|
102
119
|
- lib/yorishiro/configuration.rb
|
|
103
120
|
- lib/yorishiro/conversation.rb
|
|
121
|
+
- lib/yorishiro/diff.rb
|
|
122
|
+
- lib/yorishiro/hooks.rb
|
|
123
|
+
- lib/yorishiro/input_history.rb
|
|
104
124
|
- lib/yorishiro/mcp/server_manager.rb
|
|
105
|
-
- lib/yorishiro/mcp/stdio_transport.rb
|
|
106
125
|
- lib/yorishiro/mcp/tool.rb
|
|
107
126
|
- lib/yorishiro/provider/anthropic.rb
|
|
108
127
|
- lib/yorishiro/provider/base.rb
|
|
109
128
|
- lib/yorishiro/provider/ollama.rb
|
|
110
129
|
- lib/yorishiro/provider/open_ai.rb
|
|
130
|
+
- lib/yorishiro/session_resume.rb
|
|
131
|
+
- lib/yorishiro/session_store.rb
|
|
111
132
|
- lib/yorishiro/skill.rb
|
|
133
|
+
- lib/yorishiro/skill_loader.rb
|
|
134
|
+
- lib/yorishiro/sub_agent.rb
|
|
112
135
|
- lib/yorishiro/tool.rb
|
|
136
|
+
- lib/yorishiro/tool_result_cap.rb
|
|
137
|
+
- lib/yorishiro/tools/edit_file.rb
|
|
113
138
|
- lib/yorishiro/tools/execute_command.rb
|
|
139
|
+
- lib/yorishiro/tools/exit_plan_mode.rb
|
|
140
|
+
- lib/yorishiro/tools/grep.rb
|
|
114
141
|
- lib/yorishiro/tools/list_files.rb
|
|
115
142
|
- lib/yorishiro/tools/read_file.rb
|
|
143
|
+
- lib/yorishiro/tools/task.rb
|
|
116
144
|
- lib/yorishiro/tools/write_file.rb
|
|
117
145
|
- lib/yorishiro/version.rb
|
|
118
146
|
- sig/yorishiro.rbs
|
|
@@ -139,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
139
167
|
- !ruby/object:Gem::Version
|
|
140
168
|
version: '0'
|
|
141
169
|
requirements: []
|
|
142
|
-
rubygems_version: 4.
|
|
170
|
+
rubygems_version: 4.0.16
|
|
143
171
|
specification_version: 4
|
|
144
172
|
summary: A Ruby CLI LLM agent with tool execution, MCP support, and multi-provider
|
|
145
173
|
backends
|