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
data/lib/yorishiro/cli.rb
CHANGED
|
@@ -5,6 +5,13 @@ require "optparse"
|
|
|
5
5
|
|
|
6
6
|
module Yorishiro
|
|
7
7
|
class CLI
|
|
8
|
+
# Fraction of the context budget at which auto-compaction kicks in.
|
|
9
|
+
COMPACT_THRESHOLD = 0.8
|
|
10
|
+
|
|
11
|
+
# Env vars the /model command reads an API key from when switching to a
|
|
12
|
+
# different provider. Ollama needs none.
|
|
13
|
+
API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", open_ai: "OPENAI_API_KEY" }.freeze
|
|
14
|
+
|
|
8
15
|
def initialize
|
|
9
16
|
@conversation = nil
|
|
10
17
|
@provider = nil
|
|
@@ -18,6 +25,7 @@ module Yorishiro
|
|
|
18
25
|
print_welcome
|
|
19
26
|
repl_loop
|
|
20
27
|
ensure
|
|
28
|
+
@input_history&.save
|
|
21
29
|
@mcp_manager&.stop_all
|
|
22
30
|
end
|
|
23
31
|
|
|
@@ -41,6 +49,14 @@ module Yorishiro
|
|
|
41
49
|
@cli_opts[:plan_mode] = true
|
|
42
50
|
end
|
|
43
51
|
|
|
52
|
+
opts.on("--continue", "Resume the most recent session") do
|
|
53
|
+
@cli_opts[:continue] = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.on("--resume [ID]", "Resume a saved session (interactive picker when ID is omitted)") do |v|
|
|
57
|
+
@cli_opts[:resume] = v || :pick
|
|
58
|
+
end
|
|
59
|
+
|
|
44
60
|
opts.on("--version", "Show version") do
|
|
45
61
|
@output.puts "yorishiro #{Yorishiro::VERSION}"
|
|
46
62
|
exit
|
|
@@ -57,10 +73,10 @@ module Yorishiro
|
|
|
57
73
|
config = Yorishiro.configuration
|
|
58
74
|
config.load!
|
|
59
75
|
|
|
60
|
-
config
|
|
61
|
-
config.instance_variable_set(:@model, @cli_opts[:model]) if @cli_opts[:model]
|
|
76
|
+
apply_cli_overrides!(config)
|
|
62
77
|
|
|
63
78
|
@provider = Provider.build(config)
|
|
79
|
+
attach_tools!
|
|
64
80
|
@plan_mode = @cli_opts.fetch(:plan_mode, config.plan_mode_enabled)
|
|
65
81
|
@conversation = Conversation.new(system_prompt: config.system_prompt_text)
|
|
66
82
|
|
|
@@ -69,6 +85,36 @@ module Yorishiro
|
|
|
69
85
|
configuration: config
|
|
70
86
|
)
|
|
71
87
|
@mcp_manager.start_all
|
|
88
|
+
|
|
89
|
+
@input_history = InputHistory.new
|
|
90
|
+
@input_history.load
|
|
91
|
+
|
|
92
|
+
@session_store = SessionStore.new
|
|
93
|
+
@session_id = nil
|
|
94
|
+
resume_from_options!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Route --provider/--model through switch! so the override is validated
|
|
98
|
+
# (and rolled back on failure) instead of wiping the rc file's api_key
|
|
99
|
+
# and model the way a bare use(provider:) would. Changing provider reads
|
|
100
|
+
# the key from that provider's env var and drops the rc model, which
|
|
101
|
+
# belongs to the old provider; --model alone keeps both.
|
|
102
|
+
def apply_cli_overrides!(config)
|
|
103
|
+
provider = @cli_opts[:provider]
|
|
104
|
+
model = @cli_opts[:model]
|
|
105
|
+
return unless provider || model
|
|
106
|
+
|
|
107
|
+
provider ||= config.provider_name
|
|
108
|
+
model ||= config.model if provider == config.provider_name
|
|
109
|
+
config.switch!(provider: provider, model: model, api_key: resolve_api_key(provider))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Hand the session's provider and output to tools that spawn their own
|
|
113
|
+
# LLM loops (e.g. the task tool's subagent).
|
|
114
|
+
def attach_tools!
|
|
115
|
+
Yorishiro.configuration.allowed_tools.each do |tool|
|
|
116
|
+
tool.attach(provider: @provider, output: @output) if tool.respond_to?(:attach)
|
|
117
|
+
end
|
|
72
118
|
end
|
|
73
119
|
|
|
74
120
|
def print_welcome
|
|
@@ -84,42 +130,51 @@ module Yorishiro
|
|
|
84
130
|
break if input.nil?
|
|
85
131
|
next if input.strip.empty?
|
|
86
132
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
133
|
+
# Slash commands share the error handling: a skill can raise or
|
|
134
|
+
# inject a prompt that runs the full agent loop, and neither may
|
|
135
|
+
# take down the REPL. /exit still works — SystemExit is not a
|
|
136
|
+
# StandardError.
|
|
137
|
+
begin
|
|
138
|
+
if input.strip.start_with?("/")
|
|
139
|
+
handle_slash_command(input.strip)
|
|
140
|
+
else
|
|
141
|
+
process_user_input(input)
|
|
142
|
+
end
|
|
143
|
+
rescue Yorishiro::ProviderError => e
|
|
144
|
+
@output.puts "\n[Error] #{e.message}"
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
@output.puts "\n[Error] #{e.class}: #{e.message}"
|
|
90
147
|
end
|
|
91
|
-
|
|
92
|
-
process_user_input(input)
|
|
93
148
|
end
|
|
94
149
|
rescue Interrupt
|
|
95
150
|
@output.puts "\nGoodbye!"
|
|
96
151
|
end
|
|
97
152
|
|
|
153
|
+
# Read a (possibly multi-line) message in a single editable buffer so the
|
|
154
|
+
# user can move the cursor back to earlier lines and edit them. Submitting
|
|
155
|
+
# follows the existing "Enter on a blank line sends" gesture. Returns nil on
|
|
156
|
+
# EOF (Ctrl-D) to terminate the REPL.
|
|
98
157
|
def read_input
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return nil if line.nil?
|
|
106
|
-
|
|
107
|
-
if line.empty?
|
|
108
|
-
empty_count += 1
|
|
109
|
-
break if empty_count >= 1 && !lines.empty?
|
|
110
|
-
|
|
111
|
-
lines << "" unless lines.empty?
|
|
112
|
-
else
|
|
113
|
-
empty_count = 0
|
|
114
|
-
lines << line
|
|
115
|
-
end
|
|
116
|
-
prompt = " .. "
|
|
158
|
+
buffer = Reline.readmultiline("you> ", true) do |input|
|
|
159
|
+
# Reline appends the just-pressed newline before calling this block, so
|
|
160
|
+
# a trailing blank line (Enter on an empty line) shows up as a double
|
|
161
|
+
# newline. Submit then, provided some non-empty content was entered —
|
|
162
|
+
# the existing "Enter on a blank line sends" gesture.
|
|
163
|
+
!input.strip.empty? && input.end_with?("\n\n")
|
|
117
164
|
end
|
|
165
|
+
return nil if buffer.nil?
|
|
118
166
|
|
|
119
|
-
|
|
167
|
+
@input_history.save
|
|
168
|
+
buffer.strip
|
|
120
169
|
end
|
|
121
170
|
|
|
122
171
|
def process_user_input(input)
|
|
172
|
+
denial = Yorishiro.configuration.hooks.run_user_prompt_submit(input)
|
|
173
|
+
if denial
|
|
174
|
+
@output.puts "[Hook] Prompt blocked: #{denial.reason}"
|
|
175
|
+
return
|
|
176
|
+
end
|
|
177
|
+
|
|
123
178
|
@conversation.add_message(:user, input)
|
|
124
179
|
|
|
125
180
|
if @plan_mode
|
|
@@ -127,73 +182,152 @@ module Yorishiro
|
|
|
127
182
|
else
|
|
128
183
|
agent_loop
|
|
129
184
|
end
|
|
185
|
+
ensure
|
|
186
|
+
# Save even when the turn raised, so a crash loses at most the
|
|
187
|
+
# in-flight completion.
|
|
188
|
+
persist_session
|
|
130
189
|
end
|
|
131
190
|
|
|
132
191
|
def agent_loop
|
|
133
192
|
loop do
|
|
134
193
|
tools = Yorishiro.configuration.tool_definitions
|
|
135
194
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
result = @provider.chat(@conversation, tools: tools) do |text|
|
|
139
|
-
@output.print text
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
@output.puts
|
|
143
|
-
@output.puts
|
|
195
|
+
result = request_completion(tools)
|
|
144
196
|
|
|
145
197
|
content = result[:content]
|
|
146
198
|
tool_calls = result[:tool_calls]
|
|
147
199
|
|
|
148
|
-
|
|
200
|
+
record_assistant_message(content, tool_calls)
|
|
149
201
|
|
|
150
202
|
break if tool_calls.empty?
|
|
151
203
|
|
|
152
204
|
execute_tool_calls(tool_calls)
|
|
205
|
+
persist_session # long tool loops save progressively
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Record the assistant turn, unless the model returned nothing at all:
|
|
210
|
+
# Anthropic rejects empty assistant content on every later request, so
|
|
211
|
+
# one empty completion would poison the history and brick the session
|
|
212
|
+
# until /clear (warn_if_empty_or_truncated already told the user).
|
|
213
|
+
def record_assistant_message(content, tool_calls)
|
|
214
|
+
return if tool_calls.empty? && content.to_s.strip.empty?
|
|
215
|
+
|
|
216
|
+
@conversation.add_message(:assistant, content, tool_calls: tool_calls.empty? ? nil : tool_calls)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Keep the conversation within the provider's context budget, stream one
|
|
220
|
+
# completion, then surface truncation / empty-response conditions so the
|
|
221
|
+
# session never silently goes quiet.
|
|
222
|
+
def request_completion(tools)
|
|
223
|
+
manage_context!
|
|
224
|
+
|
|
225
|
+
@output.print "\nassistant> "
|
|
226
|
+
|
|
227
|
+
result = @provider.chat(@conversation, tools: tools) do |text|
|
|
228
|
+
@output.print text
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
@output.puts
|
|
232
|
+
@output.puts
|
|
233
|
+
|
|
234
|
+
warn_if_empty_or_truncated(result)
|
|
235
|
+
accumulate_usage(result)
|
|
236
|
+
result
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def accumulate_usage(result)
|
|
240
|
+
usage = result[:usage]
|
|
241
|
+
return unless usage && (usage[:input] || usage[:output])
|
|
242
|
+
|
|
243
|
+
@last_usage = usage
|
|
244
|
+
totals = (@session_usage ||= { input: 0, output: 0 })
|
|
245
|
+
totals[:input] += usage[:input].to_i
|
|
246
|
+
totals[:output] += usage[:output].to_i
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Keep the conversation inside the budget in three escalating steps:
|
|
250
|
+
# summarize old rounds (auto-compact), then blank out old tool results —
|
|
251
|
+
# the only thing that can shrink a single long tool loop, where round
|
|
252
|
+
# trimming and compaction have nothing to drop — and finally trim whole
|
|
253
|
+
# rounds as the last resort.
|
|
254
|
+
def manage_context!
|
|
255
|
+
budget = @provider.context_budget_tokens
|
|
256
|
+
return unless budget
|
|
257
|
+
|
|
258
|
+
auto_compact_if_needed(budget)
|
|
259
|
+
|
|
260
|
+
elided = @conversation.elide_old_tool_results!(max_tokens: budget)
|
|
261
|
+
@output.puts "[i] Removed #{elided} old tool result(s) to stay within the context limit." if elided.positive?
|
|
262
|
+
|
|
263
|
+
removed = @conversation.trim_to_budget!(max_tokens: budget)
|
|
264
|
+
@output.puts "[i] Dropped #{removed} old message(s) to stay within the context limit." if removed.positive?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def auto_compact_if_needed(budget)
|
|
268
|
+
return unless Yorishiro.configuration.auto_compact_enabled
|
|
269
|
+
return unless @conversation.estimated_tokens > budget * COMPACT_THRESHOLD
|
|
270
|
+
|
|
271
|
+
compact_conversation
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def compact_conversation
|
|
275
|
+
@output.puts "[i] Compacting context by summarizing earlier history..."
|
|
276
|
+
compacted = Compactor.new(@provider).compact(@conversation)
|
|
277
|
+
@output.puts "[i] #{compaction_notice(compacted)}"
|
|
278
|
+
compacted
|
|
279
|
+
rescue Yorishiro::ProviderError => e
|
|
280
|
+
@output.puts "[!] Compaction failed (#{e.message}). Old messages will be dropped if needed."
|
|
281
|
+
0
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def compaction_notice(compacted)
|
|
285
|
+
compacted.positive? ? "Summarized and compacted #{compacted} earlier message(s)." : "No old history available to compact."
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def warn_if_empty_or_truncated(result)
|
|
289
|
+
if result[:content].to_s.empty? && result[:tool_calls].empty?
|
|
290
|
+
@output.puts "[!] The model returned an empty response (the context may have been exceeded). " \
|
|
291
|
+
"Reset with /clear or increase OLLAMA_NUM_CTX."
|
|
153
292
|
end
|
|
293
|
+
|
|
294
|
+
budget = @provider.context_budget_tokens
|
|
295
|
+
prompt_tokens = result.dig(:meta, :prompt_eval_count)
|
|
296
|
+
return unless budget && prompt_tokens && prompt_tokens >= budget
|
|
297
|
+
|
|
298
|
+
@output.puts "[!] The prompt is approaching the context limit (#{prompt_tokens} tokens). " \
|
|
299
|
+
"Older messages may have been truncated."
|
|
154
300
|
end
|
|
155
301
|
|
|
156
302
|
def plan_then_execute
|
|
157
303
|
@output.puts "\n[Plan Mode] Generating plan..."
|
|
158
304
|
|
|
159
|
-
|
|
305
|
+
# Expose the read-only tools plus a dedicated exit_plan_mode tool the model
|
|
306
|
+
# calls to signal the plan is ready. Without it the loop can only end on an
|
|
307
|
+
# empty tool_calls response, so a model that keeps reading never terminates.
|
|
308
|
+
exit_tool = Tools::ExitPlanMode.new
|
|
309
|
+
plan_tools = Yorishiro.configuration.read_only_tool_definitions + [exit_tool.definition]
|
|
160
310
|
|
|
161
311
|
loop do
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
result = @provider.chat(@conversation, tools: read_only_tools) do |text|
|
|
165
|
-
@output.print text
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
@output.puts
|
|
169
|
-
@output.puts
|
|
312
|
+
result = request_completion(plan_tools)
|
|
170
313
|
|
|
171
314
|
content = result[:content]
|
|
172
315
|
tool_calls = result[:tool_calls]
|
|
173
316
|
|
|
174
|
-
|
|
317
|
+
record_assistant_message(content, tool_calls)
|
|
175
318
|
|
|
176
319
|
break if tool_calls.empty?
|
|
177
320
|
|
|
178
|
-
#
|
|
179
|
-
tool_calls.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
@output.puts "[Tool] Executing: #{tc[:name]}(#{format_args(tc[:arguments])})"
|
|
187
|
-
begin
|
|
188
|
-
output = tool.execute(**symbolize_keys(tc[:arguments]))
|
|
189
|
-
@output.puts "[Tool] Result: #{truncate(output, 200)}"
|
|
190
|
-
@conversation.add_tool_result(tool_call_id: tc[:id], content: output)
|
|
191
|
-
rescue StandardError => e
|
|
192
|
-
error_msg = "Error: #{e.message}"
|
|
193
|
-
@output.puts "[Tool] #{error_msg}"
|
|
194
|
-
@conversation.add_tool_result(tool_call_id: tc[:id], content: error_msg)
|
|
195
|
-
end
|
|
321
|
+
# The model signalled the plan is complete: present it and leave the loop.
|
|
322
|
+
exit_call = tool_calls.find { |tc| tc[:name] == exit_tool.name }
|
|
323
|
+
if exit_call
|
|
324
|
+
present_plan(exit_call, exit_tool)
|
|
325
|
+
skip_sibling_tool_calls(tool_calls, exit_call)
|
|
326
|
+
break
|
|
196
327
|
end
|
|
328
|
+
|
|
329
|
+
execute_read_only_tool_calls(tool_calls)
|
|
330
|
+
persist_session # long tool loops save progressively
|
|
197
331
|
end
|
|
198
332
|
|
|
199
333
|
answer = Reline.readline("Execute this plan? [y/n]: ", false)
|
|
@@ -207,6 +341,61 @@ module Yorishiro
|
|
|
207
341
|
end
|
|
208
342
|
end
|
|
209
343
|
|
|
344
|
+
# Run plan-mode read-only tool calls inline (no permission prompt).
|
|
345
|
+
# before/after hooks still apply, so a policy denial is enforced in
|
|
346
|
+
# plan mode too. Only the read-only tool definitions are offered in
|
|
347
|
+
# plan mode, but the model can still name any registered tool — so a
|
|
348
|
+
# non-read-only call must be refused here, or it would run without
|
|
349
|
+
# the permission prompt the normal loop enforces.
|
|
350
|
+
def execute_read_only_tool_calls(tool_calls)
|
|
351
|
+
tool_calls.each do |tc|
|
|
352
|
+
tool = Yorishiro.configuration.find_tool(tc[:name])
|
|
353
|
+
unless tool
|
|
354
|
+
@conversation.add_tool_result(tool_call_id: tc[:id], content: "Error: Unknown tool '#{tc[:name]}'")
|
|
355
|
+
next
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
unless tool.read_only?
|
|
359
|
+
@output.puts "[Plan Mode] Blocked non-read-only tool: #{tc[:name]}"
|
|
360
|
+
@conversation.add_tool_result(
|
|
361
|
+
tool_call_id: tc[:id],
|
|
362
|
+
content: "Error: '#{tc[:name]}' is not available in plan mode — only read-only tools can be used " \
|
|
363
|
+
"while planning. Include this step in the plan and call exit_plan_mode when it is ready."
|
|
364
|
+
)
|
|
365
|
+
next
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
next if denied_by_hook?(tc)
|
|
369
|
+
|
|
370
|
+
run_tool(tool, tc)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Tool calls issued in the same response as exit_plan_mode are not
|
|
375
|
+
# executed — the plan is already final — but each still needs a tool
|
|
376
|
+
# result: an assistant tool_call left unanswered makes Anthropic/OpenAI
|
|
377
|
+
# reject the follow-up request after the user approves the plan.
|
|
378
|
+
def skip_sibling_tool_calls(tool_calls, exit_call)
|
|
379
|
+
tool_calls.each do |tc|
|
|
380
|
+
next if tc[:id] == exit_call[:id]
|
|
381
|
+
|
|
382
|
+
@conversation.add_tool_result(
|
|
383
|
+
tool_call_id: tc[:id],
|
|
384
|
+
content: "Skipped: exit_plan_mode was called in the same response, so this tool was not executed. " \
|
|
385
|
+
"Run it again after the plan is approved if the result is still needed."
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Show the plan the model passed to exit_plan_mode and record a tool result
|
|
391
|
+
# for the call — required so the assistant tool_call is never left dangling
|
|
392
|
+
# (the follow-up agent_loop after "y" would otherwise send an unpaired
|
|
393
|
+
# tool_call, which Anthropic/OpenAI reject).
|
|
394
|
+
def present_plan(exit_call, exit_tool)
|
|
395
|
+
@output.puts "\n[Plan]\n#{exit_tool.execute(**symbolize_keys(exit_call[:arguments] || {}))}"
|
|
396
|
+
@conversation.add_tool_result(tool_call_id: exit_call[:id], content: "Plan presented to the user for approval.")
|
|
397
|
+
end
|
|
398
|
+
|
|
210
399
|
def execute_tool_calls(tool_calls)
|
|
211
400
|
tool_calls.each do |tc|
|
|
212
401
|
tool = Yorishiro.configuration.find_tool(tc[:name])
|
|
@@ -217,6 +406,8 @@ module Yorishiro
|
|
|
217
406
|
next
|
|
218
407
|
end
|
|
219
408
|
|
|
409
|
+
next if denied_by_hook?(tc)
|
|
410
|
+
|
|
220
411
|
permission = tool.permission_check(tc[:arguments])
|
|
221
412
|
|
|
222
413
|
if permission == :ask
|
|
@@ -227,33 +418,58 @@ module Yorishiro
|
|
|
227
418
|
end
|
|
228
419
|
end
|
|
229
420
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
begin
|
|
233
|
-
output = tool.execute(**symbolize_keys(tc[:arguments]))
|
|
234
|
-
@output.puts "[Tool] Result: #{truncate(output, 200)}"
|
|
235
|
-
@conversation.add_tool_result(tool_call_id: tc[:id], content: output)
|
|
236
|
-
rescue StandardError => e
|
|
237
|
-
error_msg = "Error: #{e.message}"
|
|
238
|
-
@output.puts "[Tool] #{error_msg}"
|
|
239
|
-
@conversation.add_tool_result(tool_call_id: tc[:id], content: error_msg)
|
|
240
|
-
end
|
|
421
|
+
run_tool(tool, tc)
|
|
241
422
|
end
|
|
242
423
|
end
|
|
243
424
|
|
|
425
|
+
# before_tool_use hooks fire ahead of the permission prompt (like
|
|
426
|
+
# Claude Code's PreToolUse) so a policy denial never asks the user.
|
|
427
|
+
# The denial is returned to the LLM as the tool result so it can
|
|
428
|
+
# change course.
|
|
429
|
+
def denied_by_hook?(tool_call)
|
|
430
|
+
denial = Yorishiro.configuration.hooks.run_before_tool_use(tool_call[:name], tool_call[:arguments])
|
|
431
|
+
return false unless denial
|
|
432
|
+
|
|
433
|
+
@output.puts "[Hook] Denied: #{tool_call[:name]} (#{denial.reason})"
|
|
434
|
+
@conversation.add_tool_result(tool_call_id: tool_call[:id], content: "Tool call denied by hook: #{denial.reason}")
|
|
435
|
+
true
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def run_tool(tool, tool_call)
|
|
439
|
+
@output.puts "[Tool] Executing: #{tool_call[:name]}(#{format_args(tool_call[:arguments])})"
|
|
440
|
+
output = tool.execute(**symbolize_keys(tool_call[:arguments]))
|
|
441
|
+
@output.puts "[Tool] Result: #{truncate(output, 200)}"
|
|
442
|
+
@conversation.add_tool_result(tool_call_id: tool_call[:id], content: cap_tool_result(output))
|
|
443
|
+
run_after_hooks(tool_call, output) # hooks (e.g. audit logs) see the full output
|
|
444
|
+
rescue StandardError => e
|
|
445
|
+
error_msg = "Error: #{e.message}"
|
|
446
|
+
@output.puts "[Tool] #{error_msg}"
|
|
447
|
+
@conversation.add_tool_result(tool_call_id: tool_call[:id], content: error_msg)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def cap_tool_result(output)
|
|
451
|
+
ToolResultCap.cap(output, budget: @provider&.context_budget_tokens)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def max_tool_result_chars
|
|
455
|
+
ToolResultCap.max_chars(@provider&.context_budget_tokens)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# after hooks are observational: a failure is warned about but never
|
|
459
|
+
# alters the already-recorded tool result.
|
|
460
|
+
def run_after_hooks(tool_call, output)
|
|
461
|
+
Yorishiro.configuration.hooks.run_after_tool_use(tool_call[:name], tool_call[:arguments], output)
|
|
462
|
+
rescue StandardError => e
|
|
463
|
+
@output.puts "[!] after_tool_use hook error: #{e.message}"
|
|
464
|
+
end
|
|
465
|
+
|
|
244
466
|
def ask_permission(tool, tool_call)
|
|
245
467
|
@output.puts
|
|
246
468
|
@output.puts "[Permission] #{tool.name}"
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
preview = truncate(str, 500)
|
|
252
|
-
preview.each_line { |line| @output.puts " #{line}" }
|
|
253
|
-
else
|
|
254
|
-
@output.puts " #{key}: #{str}"
|
|
255
|
-
end
|
|
256
|
-
end
|
|
469
|
+
|
|
470
|
+
preview = tool_preview(tool, tool_call[:arguments])
|
|
471
|
+
preview ? @output.puts(preview) : print_arguments(tool_call[:arguments])
|
|
472
|
+
|
|
257
473
|
answer = Reline.readline("[y] Allow once [a] Always allow [n] Deny: ", false)&.strip&.downcase
|
|
258
474
|
|
|
259
475
|
case answer
|
|
@@ -267,6 +483,68 @@ module Yorishiro
|
|
|
267
483
|
end
|
|
268
484
|
end
|
|
269
485
|
|
|
486
|
+
def resume_from_options!
|
|
487
|
+
resumed_id = if @cli_opts[:continue]
|
|
488
|
+
session_resume.continue_latest
|
|
489
|
+
elsif @cli_opts[:resume] == :pick
|
|
490
|
+
session_resume.pick
|
|
491
|
+
elsif @cli_opts[:resume]
|
|
492
|
+
session_resume.resume_by_id(@cli_opts[:resume])
|
|
493
|
+
end
|
|
494
|
+
@session_id = resumed_id if resumed_id
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def choose_and_resume_session
|
|
498
|
+
resumed_id = session_resume.pick
|
|
499
|
+
@session_id = resumed_id if resumed_id
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def session_resume
|
|
503
|
+
SessionResume.new(
|
|
504
|
+
store: @session_store,
|
|
505
|
+
conversation: @conversation,
|
|
506
|
+
output: @output,
|
|
507
|
+
current_target: "#{Yorishiro.configuration.provider_name}:#{@provider.model_name}"
|
|
508
|
+
)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def persist_session
|
|
512
|
+
return if @session_store.nil? || @conversation.nil? || @conversation.messages.empty?
|
|
513
|
+
|
|
514
|
+
@session_id = @session_store.save(
|
|
515
|
+
id: @session_id,
|
|
516
|
+
messages: @conversation.serializable_messages,
|
|
517
|
+
provider: Yorishiro.configuration.provider_name,
|
|
518
|
+
model: @provider.model_name
|
|
519
|
+
) || @session_id
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# A failing preview must never break the permission flow — fall back
|
|
523
|
+
# to the plain argument dump. Colors only when the output is a TTY.
|
|
524
|
+
def tool_preview(tool, arguments)
|
|
525
|
+
return nil unless arguments
|
|
526
|
+
|
|
527
|
+
preview = tool.preview(arguments)
|
|
528
|
+
return nil unless preview
|
|
529
|
+
|
|
530
|
+
@output.respond_to?(:tty?) && @output.tty? ? Diff.colorize(preview) : preview
|
|
531
|
+
rescue StandardError
|
|
532
|
+
nil
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def print_arguments(arguments)
|
|
536
|
+
arguments&.each do |key, value|
|
|
537
|
+
str = value.to_s
|
|
538
|
+
if str.length > 80 || str.include?("\n")
|
|
539
|
+
@output.puts " #{key}:"
|
|
540
|
+
preview = truncate(str, 500)
|
|
541
|
+
preview.each_line { |line| @output.puts " #{line}" }
|
|
542
|
+
else
|
|
543
|
+
@output.puts " #{key}: #{str}"
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
270
548
|
def handle_slash_command(input)
|
|
271
549
|
command, *args = input.split(/\s+/)
|
|
272
550
|
|
|
@@ -275,26 +553,125 @@ module Yorishiro
|
|
|
275
553
|
@plan_mode = !@plan_mode
|
|
276
554
|
@output.puts "Plan mode: #{@plan_mode ? "ON" : "OFF"}"
|
|
277
555
|
when "/clear"
|
|
278
|
-
|
|
279
|
-
|
|
556
|
+
clear_conversation!
|
|
557
|
+
when "/compact"
|
|
558
|
+
compact_conversation
|
|
559
|
+
when "/resume"
|
|
560
|
+
choose_and_resume_session
|
|
280
561
|
when "/tools"
|
|
281
562
|
list_tools
|
|
282
563
|
when "/skills"
|
|
283
564
|
list_skills
|
|
565
|
+
when "/usage"
|
|
566
|
+
print_usage
|
|
567
|
+
when "/model"
|
|
568
|
+
switch_model(args)
|
|
284
569
|
when "/exit", "/quit"
|
|
285
570
|
@output.puts "Goodbye!"
|
|
286
571
|
exit
|
|
287
572
|
when "/help"
|
|
288
573
|
print_help
|
|
289
574
|
else
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
575
|
+
handle_skill_command(command, args)
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def handle_skill_command(command, args)
|
|
580
|
+
skill = Yorishiro.configuration.skills.find { |s| "/#{s.name}" == command }
|
|
581
|
+
unless skill
|
|
582
|
+
@output.puts "Unknown command: #{command}. Type /help for available commands."
|
|
583
|
+
return
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
result = skill.execute({ conversation: @conversation, args: args })
|
|
587
|
+
case result
|
|
588
|
+
when Yorishiro::Skill::Prompt
|
|
589
|
+
process_user_input(result.text) # inject prompt -> plan/agent loop
|
|
590
|
+
when String
|
|
591
|
+
@output.puts result
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# /model -> list current + available models
|
|
596
|
+
# /model <name> -> switch model within the current provider
|
|
597
|
+
# /model <prov> <m> -> switch provider and model
|
|
598
|
+
def switch_model(args)
|
|
599
|
+
case args.length
|
|
600
|
+
when 0
|
|
601
|
+
show_model_options
|
|
602
|
+
when 1
|
|
603
|
+
apply_model_switch(provider: Yorishiro.configuration.provider_name, model: args[0])
|
|
604
|
+
else
|
|
605
|
+
apply_model_switch(provider: args[0].to_sym, model: args[1])
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def apply_model_switch(provider:, model:)
|
|
610
|
+
config = Yorishiro.configuration
|
|
611
|
+
config.switch!(provider: provider, model: model, api_key: resolve_api_key(provider))
|
|
612
|
+
@provider = Provider.build(config)
|
|
613
|
+
attach_tools! # re-point subagent tools at the new provider
|
|
614
|
+
@output.puts "Now using #{config.provider_name}:#{@provider.model_name}"
|
|
615
|
+
rescue Yorishiro::ConfigurationError => e
|
|
616
|
+
@output.puts "[!] #{e.message}"
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Reuse the existing key when the provider is unchanged; otherwise read it
|
|
620
|
+
# from the provider's conventional env var (nil for Ollama, which needs none).
|
|
621
|
+
def resolve_api_key(provider)
|
|
622
|
+
config = Yorishiro.configuration
|
|
623
|
+
return config.api_key if provider == config.provider_name
|
|
624
|
+
|
|
625
|
+
env = API_KEY_ENV[provider]
|
|
626
|
+
env && ENV.fetch(env, nil)
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def show_model_options
|
|
630
|
+
config = Yorishiro.configuration
|
|
631
|
+
@output.puts "Current: #{config.provider_name}:#{@provider.model_name}"
|
|
632
|
+
|
|
633
|
+
models = Provider.for(config.provider_name).supported_models
|
|
634
|
+
if models.empty?
|
|
635
|
+
@output.puts " (no model list available for #{config.provider_name})"
|
|
636
|
+
else
|
|
637
|
+
models.each { |m| @output.puts " #{m}" }
|
|
297
638
|
end
|
|
639
|
+
@output.puts "Usage: /model <name> | /model <provider> <name>"
|
|
640
|
+
rescue Yorishiro::ProviderNotImplementedError => e
|
|
641
|
+
@output.puts "[!] #{e.message}"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def clear_conversation!
|
|
645
|
+
@conversation = Conversation.new(system_prompt: Yorishiro.configuration.system_prompt_text)
|
|
646
|
+
@session_id = nil # the old session file stays on disk for /resume
|
|
647
|
+
@session_usage = { input: 0, output: 0 }
|
|
648
|
+
@last_usage = nil
|
|
649
|
+
@output.puts "Conversation cleared. Started a new session."
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def print_usage
|
|
653
|
+
totals = (@session_usage ||= { input: 0, output: 0 })
|
|
654
|
+
unless @last_usage || totals[:input].positive? || totals[:output].positive?
|
|
655
|
+
estimate = @conversation&.estimated_tokens
|
|
656
|
+
note = "No token usage reported yet by this provider."
|
|
657
|
+
note += " Estimated conversation size: ~#{estimate} tokens." if estimate
|
|
658
|
+
@output.puts note
|
|
659
|
+
return
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
if @last_usage
|
|
663
|
+
input = @last_usage[:input].to_i
|
|
664
|
+
output = @last_usage[:output].to_i
|
|
665
|
+
@output.puts "Token usage (last turn): prompt #{input}, completion #{output}, total #{input + output}"
|
|
666
|
+
end
|
|
667
|
+
@output.puts "Token usage (session): prompt #{totals[:input]}, completion #{totals[:output]}, " \
|
|
668
|
+
"total #{totals[:input] + totals[:output]}"
|
|
669
|
+
|
|
670
|
+
budget = @provider.context_budget_tokens
|
|
671
|
+
return unless budget && @last_usage && @last_usage[:input]
|
|
672
|
+
|
|
673
|
+
input = @last_usage[:input].to_i
|
|
674
|
+
@output.puts "Context: #{input} / #{budget} tokens (#{(input * 100.0 / budget).round}%)"
|
|
298
675
|
end
|
|
299
676
|
|
|
300
677
|
def list_tools
|
|
@@ -320,8 +697,12 @@ module Yorishiro
|
|
|
320
697
|
Commands:
|
|
321
698
|
/plan - Toggle plan mode
|
|
322
699
|
/clear - Clear conversation
|
|
700
|
+
/compact - Summarize and compact conversation history
|
|
701
|
+
/resume - List and resume a saved session
|
|
323
702
|
/tools - List available tools
|
|
324
703
|
/skills - List available skills
|
|
704
|
+
/usage - Show token usage
|
|
705
|
+
/model - Switch provider/model (list when no args)
|
|
325
706
|
/exit - Exit yorishiro
|
|
326
707
|
/help - Show this help
|
|
327
708
|
HELP
|