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
|
@@ -4,9 +4,13 @@ module Yorishiro
|
|
|
4
4
|
module Provider
|
|
5
5
|
class Ollama < Base
|
|
6
6
|
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
7
|
+
DEFAULT_NUM_CTX = 8192
|
|
8
|
+
OUTPUT_TOKEN_RESERVE = 2048
|
|
9
|
+
MIN_CONTEXT_BUDGET = 1024
|
|
7
10
|
|
|
8
|
-
def initialize(api_key: nil, model: nil, base_url: nil)
|
|
11
|
+
def initialize(api_key: nil, model: nil, base_url: nil, num_ctx: nil)
|
|
9
12
|
@base_url = base_url || ENV.fetch("OLLAMA_HOST", DEFAULT_BASE_URL)
|
|
13
|
+
@num_ctx = num_ctx
|
|
10
14
|
super(api_key: api_key || "unused", model: model)
|
|
11
15
|
end
|
|
12
16
|
|
|
@@ -22,32 +26,60 @@ module Yorishiro
|
|
|
22
26
|
[]
|
|
23
27
|
end
|
|
24
28
|
|
|
25
|
-
def chat(conversation, tools: [], &
|
|
29
|
+
def chat(conversation, tools: [], &)
|
|
26
30
|
uri = URI("#{@base_url}/api/chat")
|
|
27
31
|
|
|
28
32
|
body = {
|
|
29
33
|
model: @model_name,
|
|
30
|
-
messages: format_messages(conversation.to_api_messages)
|
|
34
|
+
messages: format_messages(conversation.to_api_messages),
|
|
35
|
+
keep_alive: ENV.fetch("OLLAMA_KEEP_ALIVE", "10m"),
|
|
36
|
+
options: { num_ctx: num_ctx },
|
|
37
|
+
stream: true
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
headers = { "Content-Type" => "application/json" }
|
|
34
41
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
body[:tools] = format_tools(tools) unless tools.empty?
|
|
43
|
+
|
|
44
|
+
debug_log("Ollama request", body)
|
|
45
|
+
|
|
46
|
+
@last_meta = {}
|
|
47
|
+
result = post_stream(uri, headers: headers, body: body, &)
|
|
48
|
+
result[:meta] = @last_meta
|
|
49
|
+
result[:usage] = { input: @last_meta[:prompt_eval_count], output: @last_meta[:eval_count] }
|
|
50
|
+
debug_log("Ollama response", result)
|
|
51
|
+
result
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Token budget the conversation should be trimmed to before each request.
|
|
55
|
+
# Reserves headroom for the model's output within the configured context window.
|
|
56
|
+
def context_budget_tokens
|
|
57
|
+
[num_ctx - OUTPUT_TOKEN_RESERVE, MIN_CONTEXT_BUDGET].max
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
private
|
|
46
61
|
|
|
62
|
+
# Resolve the Ollama context window (num_ctx). Priority:
|
|
63
|
+
# 1. explicit value from .yorishirorc (constructor argument)
|
|
64
|
+
# 2. OLLAMA_NUM_CTX environment variable
|
|
65
|
+
# 3. DEFAULT_NUM_CTX
|
|
66
|
+
def num_ctx
|
|
67
|
+
return @num_ctx.to_i if @num_ctx
|
|
68
|
+
|
|
69
|
+
env = ENV.fetch("OLLAMA_NUM_CTX", nil)
|
|
70
|
+
return env.to_i if env && !env.empty?
|
|
71
|
+
|
|
72
|
+
DEFAULT_NUM_CTX
|
|
73
|
+
end
|
|
74
|
+
|
|
47
75
|
def default_model
|
|
48
76
|
"llama3.1"
|
|
49
77
|
end
|
|
50
78
|
|
|
79
|
+
def read_timeout
|
|
80
|
+
nil # local inference (prompt eval on large inputs) can take arbitrarily long
|
|
81
|
+
end
|
|
82
|
+
|
|
51
83
|
def format_messages(api_messages)
|
|
52
84
|
api_messages.map do |msg|
|
|
53
85
|
if msg[:role] == "tool"
|
|
@@ -83,39 +115,6 @@ module Yorishiro
|
|
|
83
115
|
end
|
|
84
116
|
end
|
|
85
117
|
|
|
86
|
-
def post_no_stream(uri, headers:, body:, &block)
|
|
87
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
88
|
-
http.use_ssl = (uri.scheme == "https")
|
|
89
|
-
http.read_timeout = 120
|
|
90
|
-
|
|
91
|
-
request = Net::HTTP::Post.new(uri)
|
|
92
|
-
headers.each { |k, v| request[k] = v }
|
|
93
|
-
request.body = JSON.generate(body)
|
|
94
|
-
|
|
95
|
-
debug_log("Ollama request", body)
|
|
96
|
-
|
|
97
|
-
response = http.request(request)
|
|
98
|
-
handle_error_response!(response) unless response.is_a?(Net::HTTPSuccess)
|
|
99
|
-
|
|
100
|
-
data = JSON.parse(response.body)
|
|
101
|
-
debug_log("Ollama response", data)
|
|
102
|
-
message = data["message"] || {}
|
|
103
|
-
|
|
104
|
-
content = message["content"] || ""
|
|
105
|
-
block&.call(content) unless content.empty?
|
|
106
|
-
|
|
107
|
-
tool_calls = (message["tool_calls"] || []).map do |tc|
|
|
108
|
-
func = tc["function"]
|
|
109
|
-
{
|
|
110
|
-
id: "ollama_#{SecureRandom.hex(8)}",
|
|
111
|
-
name: func["name"],
|
|
112
|
-
arguments: func["arguments"].is_a?(Hash) ? func["arguments"] : JSON.parse(func["arguments"].to_s)
|
|
113
|
-
}
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
{ content: content, tool_calls: tool_calls }
|
|
117
|
-
end
|
|
118
|
-
|
|
119
118
|
def parse_stream(response, tool_calls:, &block)
|
|
120
119
|
buffer = +""
|
|
121
120
|
|
|
@@ -125,7 +124,13 @@ module Yorishiro
|
|
|
125
124
|
line = buffer.slice!(0, idx + 1).strip
|
|
126
125
|
next if line.empty?
|
|
127
126
|
|
|
128
|
-
data =
|
|
127
|
+
data = parse_ndjson_line(line)
|
|
128
|
+
next unless data
|
|
129
|
+
|
|
130
|
+
raise ProviderError, "Ollama error: #{data["error"]}" if data["error"]
|
|
131
|
+
|
|
132
|
+
capture_meta(data)
|
|
133
|
+
|
|
129
134
|
message = data["message"]
|
|
130
135
|
next unless message
|
|
131
136
|
|
|
@@ -146,6 +151,26 @@ module Yorishiro
|
|
|
146
151
|
end
|
|
147
152
|
end
|
|
148
153
|
end
|
|
154
|
+
|
|
155
|
+
# Parse a single NDJSON line, skipping (rather than crashing on) malformed
|
|
156
|
+
# or partial lines that Ollama can emit under load or on unexpected output.
|
|
157
|
+
def parse_ndjson_line(line)
|
|
158
|
+
JSON.parse(line)
|
|
159
|
+
rescue JSON::ParserError => e
|
|
160
|
+
debug_log("Ollama: skipping malformed NDJSON line", e.message)
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Capture the final-chunk stats so the CLI can detect context truncation
|
|
165
|
+
# (prompt_eval_count approaching num_ctx) and empty responses.
|
|
166
|
+
def capture_meta(data)
|
|
167
|
+
return unless data["done"]
|
|
168
|
+
|
|
169
|
+
@last_meta ||= {}
|
|
170
|
+
%w[done_reason prompt_eval_count eval_count].each do |key|
|
|
171
|
+
@last_meta[key.to_sym] = data[key] if data.key?(key)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
149
174
|
end
|
|
150
175
|
end
|
|
151
176
|
end
|
|
@@ -5,15 +5,15 @@ module Yorishiro
|
|
|
5
5
|
class OpenAI < Base
|
|
6
6
|
API_URL = "https://api.openai.com/v1/chat/completions"
|
|
7
7
|
|
|
8
|
+
# Chat-completions models compatible with this client, which always
|
|
9
|
+
# streams and sends system messages and tools — the o1/o3 reasoning
|
|
10
|
+
# series rejects parts of that flow, so it is intentionally absent.
|
|
8
11
|
SUPPORTED_MODELS = %w[
|
|
9
12
|
gpt-4o
|
|
10
13
|
gpt-4o-mini
|
|
11
14
|
gpt-4-turbo
|
|
12
15
|
gpt-4
|
|
13
16
|
gpt-3.5-turbo
|
|
14
|
-
o1
|
|
15
|
-
o1-mini
|
|
16
|
-
o3-mini
|
|
17
17
|
].freeze
|
|
18
18
|
|
|
19
19
|
def self.supported_models
|
|
@@ -26,7 +26,8 @@ module Yorishiro
|
|
|
26
26
|
body = {
|
|
27
27
|
model: @model_name,
|
|
28
28
|
messages: format_messages(conversation.to_api_messages),
|
|
29
|
-
stream: true
|
|
29
|
+
stream: true,
|
|
30
|
+
stream_options: { include_usage: true }
|
|
30
31
|
}
|
|
31
32
|
body[:tools] = format_tools(tools) unless tools.empty?
|
|
32
33
|
|
|
@@ -35,7 +36,10 @@ module Yorishiro
|
|
|
35
36
|
"Authorization" => "Bearer #{@api_key}"
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
@last_usage = {}
|
|
40
|
+
result = post_stream(uri, headers: headers, body: body, &)
|
|
41
|
+
result[:usage] = @last_usage
|
|
42
|
+
result
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
private
|
|
@@ -85,6 +89,16 @@ module Yorishiro
|
|
|
85
89
|
end
|
|
86
90
|
end
|
|
87
91
|
|
|
92
|
+
# A trailing usage-only chunk (with an empty choices array) carries the
|
|
93
|
+
# token counts when stream_options.include_usage is set.
|
|
94
|
+
def capture_usage(data)
|
|
95
|
+
usage = data["usage"]
|
|
96
|
+
return unless usage
|
|
97
|
+
|
|
98
|
+
@last_usage[:input] = usage["prompt_tokens"]
|
|
99
|
+
@last_usage[:output] = usage["completion_tokens"]
|
|
100
|
+
end
|
|
101
|
+
|
|
88
102
|
def parse_stream(response, tool_calls:, &block)
|
|
89
103
|
buffer = +""
|
|
90
104
|
tool_call_buffers = {}
|
|
@@ -96,6 +110,8 @@ module Yorishiro
|
|
|
96
110
|
parsed = parse_sse_event(event_str)
|
|
97
111
|
next unless parsed[:data]
|
|
98
112
|
|
|
113
|
+
capture_usage(parsed[:data])
|
|
114
|
+
|
|
99
115
|
choice = parsed[:data].dig("choices", 0)
|
|
100
116
|
next unless choice
|
|
101
117
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "reline"
|
|
4
|
+
|
|
5
|
+
module Yorishiro
|
|
6
|
+
# Interactive session-resume flows shared by the --continue/--resume CLI
|
|
7
|
+
# flags and the /resume command. Applies the chosen session to the
|
|
8
|
+
# conversation and returns its id, or nil when nothing was resumed (the
|
|
9
|
+
# caller keeps its current session in that case).
|
|
10
|
+
class SessionResume
|
|
11
|
+
def initialize(store:, conversation:, output:, current_target:)
|
|
12
|
+
@store = store
|
|
13
|
+
@conversation = conversation
|
|
14
|
+
@output = output
|
|
15
|
+
@current_target = current_target # "provider:model" of the running CLI
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def continue_latest
|
|
19
|
+
apply(@store.latest, missing: "No previous session found. Starting a new one.")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resume_by_id(id)
|
|
23
|
+
apply(@store.load(id), missing: "Session not found: #{id}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pick
|
|
27
|
+
sessions = @store.list
|
|
28
|
+
if sessions.empty?
|
|
29
|
+
@output.puts "[i] No saved sessions."
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
print_list(sessions)
|
|
34
|
+
answer = Reline.readline("Select session [1-#{sessions.length}]: ", false)&.strip
|
|
35
|
+
index = answer.to_i
|
|
36
|
+
unless index.between?(1, sessions.length)
|
|
37
|
+
@output.puts "Cancelled."
|
|
38
|
+
return nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
apply(sessions[index - 1])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def apply(session, missing: "Session not found.")
|
|
47
|
+
unless session
|
|
48
|
+
@output.puts "[i] #{missing}"
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
unless @store.claim(session[:id])
|
|
53
|
+
@output.puts "[i] Session #{session[:id]} is in use by another process. Starting a new session."
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@conversation.restore_messages!(session[:messages])
|
|
58
|
+
|
|
59
|
+
recorded = "#{session[:provider]}:#{session[:model]}"
|
|
60
|
+
@output.puts "[i] This session was recorded with #{recorded}. Continuing with #{@current_target}." if recorded != @current_target
|
|
61
|
+
@output.puts "[i] Resumed session #{session[:id]} (#{session[:title]}, #{session[:messages].length} messages)"
|
|
62
|
+
session[:id]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def print_list(sessions)
|
|
66
|
+
sessions.each_with_index do |session, index|
|
|
67
|
+
stamp = session[:updated_at].to_s[0, 16].tr("T", " ")
|
|
68
|
+
@output.puts "#{(index + 1).to_s.rjust(3)}. [#{stamp}] #{session[:provider]}:#{session[:model]} " \
|
|
69
|
+
"(#{session[:messages].length} msgs) #{session[:title]}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Yorishiro
|
|
6
|
+
# Persists conversations to .yorishiro/sessions/<id>.json under the
|
|
7
|
+
# launch directory so they can be resumed later (--continue / --resume /
|
|
8
|
+
# /resume). Each session is one JSON file rewritten wholesale through an
|
|
9
|
+
# atomic tmp-file rename: compaction rewrites history destructively (an
|
|
10
|
+
# append-only log would need replaying), and the atomic swap means a
|
|
11
|
+
# crash mid-write never corrupts the previously saved state.
|
|
12
|
+
class SessionStore
|
|
13
|
+
DIR_NAME = File.join(".yorishiro", "sessions")
|
|
14
|
+
MAX_SESSIONS = 50
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
TITLE_MAX_LENGTH = 60
|
|
17
|
+
|
|
18
|
+
def initialize(dir: Dir.pwd, max_sessions: MAX_SESSIONS)
|
|
19
|
+
@sessions_dir = File.join(dir, DIR_NAME)
|
|
20
|
+
@max_sessions = max_sessions
|
|
21
|
+
@locks = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :sessions_dir
|
|
25
|
+
|
|
26
|
+
# Take exclusive ownership of a session id for this process, so a second
|
|
27
|
+
# `yorishiro` resuming the same session in the same directory doesn't
|
|
28
|
+
# write over this one's history (last-writer-wins data loss). Returns
|
|
29
|
+
# true when acquired (or already held by us), false when another live
|
|
30
|
+
# process owns it. The advisory lock is released automatically on
|
|
31
|
+
# process exit. Filesystems without flock fail open to preserve the
|
|
32
|
+
# single-user behaviour.
|
|
33
|
+
def claim(id)
|
|
34
|
+
return true if @locks.key?(id)
|
|
35
|
+
|
|
36
|
+
FileUtils.mkdir_p(@sessions_dir)
|
|
37
|
+
# Held open for the process lifetime so the advisory lock persists;
|
|
38
|
+
# the block form would close it and release the lock immediately.
|
|
39
|
+
lock = File.open(lock_path_for(id), File::CREAT | File::RDWR, 0o644) # rubocop:disable Style/FileOpen
|
|
40
|
+
if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
41
|
+
@locks[id] = lock
|
|
42
|
+
true
|
|
43
|
+
else
|
|
44
|
+
lock.close
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Release every lock held by this store. The OS also releases them on
|
|
52
|
+
# process exit; this exists for tests and explicit teardown.
|
|
53
|
+
def release_locks
|
|
54
|
+
@locks.each_value { |lock| lock.close unless lock.closed? }
|
|
55
|
+
@locks.clear
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Write the session and return its id (generating one when nil). The
|
|
59
|
+
# original created_at is preserved across saves. Returns nil when the
|
|
60
|
+
# write fails — persistence must never break a REPL turn.
|
|
61
|
+
def save(id:, messages:, provider:, model:)
|
|
62
|
+
id ||= generate_id
|
|
63
|
+
claim(id)
|
|
64
|
+
existing = parse_session(path_for(id))
|
|
65
|
+
now = Time.now.utc.iso8601
|
|
66
|
+
|
|
67
|
+
session = {
|
|
68
|
+
version: SCHEMA_VERSION,
|
|
69
|
+
id: id,
|
|
70
|
+
provider: provider,
|
|
71
|
+
model: model,
|
|
72
|
+
created_at: existing&.dig(:created_at) || now,
|
|
73
|
+
updated_at: now,
|
|
74
|
+
title: title_from(messages),
|
|
75
|
+
messages: messages
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
write_atomically(path_for(id), JSON.generate(session))
|
|
79
|
+
prune!
|
|
80
|
+
id
|
|
81
|
+
rescue SystemCallError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Load a session by exact id or id prefix (most recent match wins).
|
|
86
|
+
# Returns nil when missing or corrupt.
|
|
87
|
+
def load(id)
|
|
88
|
+
path = path_for(id)
|
|
89
|
+
path = Dir.glob(File.join(@sessions_dir, "#{id}*.json")).max unless File.exist?(path)
|
|
90
|
+
return nil unless path
|
|
91
|
+
|
|
92
|
+
parse_session(path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def latest
|
|
96
|
+
list.first
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# All sessions, most recently updated first. Corrupt files are skipped.
|
|
100
|
+
def list
|
|
101
|
+
Dir.glob(File.join(@sessions_dir, "*.json"))
|
|
102
|
+
.filter_map { |path| parse_session(path) }
|
|
103
|
+
.sort_by { |session| session[:updated_at].to_s }
|
|
104
|
+
.reverse
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def parse_session(path)
|
|
110
|
+
return nil unless File.exist?(path)
|
|
111
|
+
|
|
112
|
+
data = JSON.parse(File.read(path))
|
|
113
|
+
return nil unless data.is_a?(Hash) && data["messages"].is_a?(Array)
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
id: data["id"],
|
|
117
|
+
provider: data["provider"],
|
|
118
|
+
model: data["model"],
|
|
119
|
+
created_at: data["created_at"],
|
|
120
|
+
updated_at: data["updated_at"],
|
|
121
|
+
title: data["title"],
|
|
122
|
+
messages: data["messages"]
|
|
123
|
+
}
|
|
124
|
+
rescue JSON::ParserError, SystemCallError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write_atomically(path, content)
|
|
129
|
+
FileUtils.mkdir_p(@sessions_dir)
|
|
130
|
+
tmp_path = "#{path}.#{Process.pid}.tmp"
|
|
131
|
+
File.write(tmp_path, content)
|
|
132
|
+
File.rename(tmp_path, path)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def prune!
|
|
136
|
+
paths = Dir.glob(File.join(@sessions_dir, "*.json")).sort_by { |path| File.mtime(path) }
|
|
137
|
+
excess = paths.length - @max_sessions
|
|
138
|
+
return unless excess.positive?
|
|
139
|
+
|
|
140
|
+
paths.first(excess).each do |path|
|
|
141
|
+
File.delete(path)
|
|
142
|
+
FileUtils.rm_f("#{path}.lock")
|
|
143
|
+
end
|
|
144
|
+
rescue SystemCallError
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def path_for(id)
|
|
149
|
+
File.join(@sessions_dir, "#{id}.json")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def lock_path_for(id)
|
|
153
|
+
"#{path_for(id)}.lock"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Timestamp prefix keeps ids readable and roughly sortable; the random
|
|
157
|
+
# suffix avoids collisions between sessions started in the same second.
|
|
158
|
+
def generate_id
|
|
159
|
+
"#{Time.now.strftime("%Y%m%dT%H%M%S")}-#{SecureRandom.hex(3)}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def title_from(messages)
|
|
163
|
+
first_user = messages.find { |msg| msg["role"].to_s == "user" }
|
|
164
|
+
first_line = first_user&.fetch("content", nil).to_s.lines.first.to_s.strip
|
|
165
|
+
first_line.length > TITLE_MAX_LENGTH ? "#{first_line[0...TITLE_MAX_LENGTH]}..." : first_line
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/yorishiro/skill.rb
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Yorishiro
|
|
4
4
|
class Skill
|
|
5
|
+
# Returned from #execute to inject a prompt into the LLM instead of just
|
|
6
|
+
# printing output. The CLI feeds +text+ to the model as a user message and
|
|
7
|
+
# runs the agent/plan loop. Build one with the #prompt helper.
|
|
8
|
+
Prompt = Struct.new(:text)
|
|
9
|
+
|
|
10
|
+
# Convenience for subclasses: `prompt("...")` inside #execute returns a
|
|
11
|
+
# Prompt so the returned text is sent to the LLM.
|
|
12
|
+
def prompt(text)
|
|
13
|
+
Prompt.new(text)
|
|
14
|
+
end
|
|
15
|
+
|
|
5
16
|
def name
|
|
6
17
|
raise SkillNotImplementedError, "#{self.class}#name is not implemented"
|
|
7
18
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
# Auto-loads custom skills (slash commands) from skill directories such
|
|
5
|
+
# as ~/.yorishiro/skills and ./.yorishiro/skills. A skill file just
|
|
6
|
+
# defines Yorishiro::Skill subclasses — no registration call needed.
|
|
7
|
+
# New classes are detected by diffing the Skill class tree around the
|
|
8
|
+
# load and registered through Configuration#replace_skill, so the usual
|
|
9
|
+
# validation applies and later directories override same-name skills.
|
|
10
|
+
class SkillLoader
|
|
11
|
+
def initialize(configuration)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load_dir(dir)
|
|
16
|
+
return unless Dir.exist?(dir)
|
|
17
|
+
|
|
18
|
+
Dir.glob(File.join(dir, "*.rb")).each { |path| load_file(path) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def load_file(path)
|
|
24
|
+
before = skill_classes
|
|
25
|
+
load path
|
|
26
|
+
(skill_classes - before).each { |klass| register(klass, path) }
|
|
27
|
+
rescue ConfigurationError
|
|
28
|
+
raise
|
|
29
|
+
rescue StandardError, SyntaxError => e
|
|
30
|
+
raise ConfigurationError, "Error loading skill file #{path}: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def register(klass, path)
|
|
34
|
+
instance = klass.new
|
|
35
|
+
instance.name # abstract intermediate classes raise here — skip them
|
|
36
|
+
@configuration.replace_skill(instance)
|
|
37
|
+
rescue SkillNotImplementedError
|
|
38
|
+
nil
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
raise ConfigurationError, "Error registering skill #{klass} from #{path}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Walk the whole subtree so skills inheriting from an intermediate
|
|
44
|
+
# base class (not directly from Skill) are found too.
|
|
45
|
+
def skill_classes
|
|
46
|
+
collect_subclasses(Skill)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def collect_subclasses(klass)
|
|
50
|
+
klass.subclasses.flat_map { |sub| [sub] + collect_subclasses(sub) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yorishiro
|
|
4
|
+
# Runs a delegated task in its own fresh Conversation so exploratory tool
|
|
5
|
+
# output never enters the parent conversation — only the final assistant
|
|
6
|
+
# text is returned. The caller decides which tools the subagent may use;
|
|
7
|
+
# Tools::Task passes read-only tools only, so no permission prompts fire.
|
|
8
|
+
class SubAgent
|
|
9
|
+
MAX_ITERATIONS = 15
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
12
|
+
You are a subagent handling a task delegated by a coding assistant.
|
|
13
|
+
Investigate using the available read-only tools, then reply with your
|
|
14
|
+
findings as plain text. Only your final message is returned to the
|
|
15
|
+
caller, so make it complete and self-contained. Do not ask questions —
|
|
16
|
+
nobody can answer them.
|
|
17
|
+
PROMPT
|
|
18
|
+
|
|
19
|
+
def initialize(provider:, tools:, output: $stdout,
|
|
20
|
+
hooks: Yorishiro.configuration.hooks,
|
|
21
|
+
max_iterations: MAX_ITERATIONS)
|
|
22
|
+
@provider = provider
|
|
23
|
+
@tools = tools
|
|
24
|
+
@output = output
|
|
25
|
+
@hooks = hooks
|
|
26
|
+
@max_iterations = max_iterations
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Run the agent loop for +prompt+ and return the final assistant text.
|
|
30
|
+
def run(prompt)
|
|
31
|
+
conversation = Conversation.new(system_prompt: SYSTEM_PROMPT)
|
|
32
|
+
conversation.add_message(:user, prompt)
|
|
33
|
+
last_content = nil
|
|
34
|
+
|
|
35
|
+
@max_iterations.times do |iteration|
|
|
36
|
+
manage_context!(conversation)
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
result = @provider.chat(conversation, tools: @tools.map(&:definition))
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
# A failed completion must not throw away the investigation done so
|
|
42
|
+
# far — salvage it the same way the iteration limit does.
|
|
43
|
+
return provider_error_notice(last_content, e)
|
|
44
|
+
end
|
|
45
|
+
content = result[:content]
|
|
46
|
+
tool_calls = result[:tool_calls]
|
|
47
|
+
|
|
48
|
+
conversation.add_message(:assistant, content, tool_calls: tool_calls.empty? ? nil : tool_calls)
|
|
49
|
+
last_content = content unless content.to_s.empty?
|
|
50
|
+
|
|
51
|
+
return final_answer(content) if tool_calls.empty?
|
|
52
|
+
|
|
53
|
+
# Pending tool calls on the last iteration are pointless to execute —
|
|
54
|
+
# there is no follow-up completion to consume their results.
|
|
55
|
+
break if iteration == @max_iterations - 1
|
|
56
|
+
|
|
57
|
+
execute_tool_calls(conversation, tool_calls)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
iteration_limit_notice(last_content)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Same escalation as the CLI minus compaction (an extra LLM call is
|
|
66
|
+
# overkill for a short-lived loop) and minus the "[i]" notices — the
|
|
67
|
+
# subagent shrinks its context silently.
|
|
68
|
+
def manage_context!(conversation)
|
|
69
|
+
budget = @provider.context_budget_tokens
|
|
70
|
+
return unless budget
|
|
71
|
+
|
|
72
|
+
conversation.elide_old_tool_results!(max_tokens: budget)
|
|
73
|
+
conversation.trim_to_budget!(max_tokens: budget)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def final_answer(content)
|
|
77
|
+
content.to_s.empty? ? "The subagent returned no findings." : content
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def iteration_limit_notice(last_content)
|
|
81
|
+
notice = "[Subagent reached the #{@max_iterations}-iteration limit without a final answer.]"
|
|
82
|
+
last_content ? "#{last_content}\n\n#{notice}" : notice
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def provider_error_notice(last_content, error)
|
|
86
|
+
notice = "[Subagent stopped early after a provider error: #{error.message}]"
|
|
87
|
+
last_content ? "#{last_content}\n\n#{notice}" : notice
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def execute_tool_calls(conversation, tool_calls)
|
|
91
|
+
tool_calls.each do |tc|
|
|
92
|
+
@output.puts " [task] #{tc[:name]}(#{format_args(tc[:arguments])})"
|
|
93
|
+
|
|
94
|
+
denial = @hooks.run_before_tool_use(tc[:name], tc[:arguments])
|
|
95
|
+
if denial
|
|
96
|
+
conversation.add_tool_result(tool_call_id: tc[:id], content: "Tool call denied by hook: #{denial.reason}")
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
tool = @tools.find { |t| t.name == tc[:name] }
|
|
101
|
+
unless tool
|
|
102
|
+
conversation.add_tool_result(tool_call_id: tc[:id], content: "Error: Unknown tool '#{tc[:name]}'")
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
run_tool(conversation, tool, tc)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_tool(conversation, tool, tool_call)
|
|
111
|
+
output = tool.execute(**symbolize_keys(tool_call[:arguments]))
|
|
112
|
+
conversation.add_tool_result(
|
|
113
|
+
tool_call_id: tool_call[:id],
|
|
114
|
+
content: ToolResultCap.cap(output, budget: @provider.context_budget_tokens)
|
|
115
|
+
)
|
|
116
|
+
run_after_hooks(tool_call, output) # hooks (e.g. audit logs) see the full output
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
conversation.add_tool_result(tool_call_id: tool_call[:id], content: "Error: #{e.message}")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# after hooks are observational: a failure is warned about but never
|
|
122
|
+
# alters the already-recorded tool result.
|
|
123
|
+
def run_after_hooks(tool_call, output)
|
|
124
|
+
@hooks.run_after_tool_use(tool_call[:name], tool_call[:arguments], output)
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@output.puts "[!] after_tool_use hook error: #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def format_args(args)
|
|
130
|
+
return "" unless args
|
|
131
|
+
|
|
132
|
+
args.map { |k, v| "#{k}: #{truncate(v.to_s, 50)}" }.join(", ")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def truncate(str, max)
|
|
136
|
+
str.length > max ? "#{str[0...max]}..." : str
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def symbolize_keys(hash)
|
|
140
|
+
hash.transform_keys(&:to_sym)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/yorishiro/tool.rb
CHANGED
|
@@ -37,5 +37,12 @@ module Yorishiro
|
|
|
37
37
|
def configure(_options)
|
|
38
38
|
# Override in subclasses to handle allow_tool options
|
|
39
39
|
end
|
|
40
|
+
|
|
41
|
+
# Return a human-readable preview of what execute would do (e.g. a
|
|
42
|
+
# diff), shown in the permission prompt instead of the raw argument
|
|
43
|
+
# dump. Return nil to keep the default argument dump.
|
|
44
|
+
def preview(_arguments)
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
40
47
|
end
|
|
41
48
|
end
|