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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.yorishirorc.sample +51 -0
  3. data/CHANGELOG.md +41 -0
  4. data/README.md +134 -8
  5. data/docs/en/README.md +97 -2
  6. data/docs/ja/README.md +97 -2
  7. data/docs/ja/how_to_create_skills.md +0 -0
  8. data/docs/ja/ollama_setup.md +148 -0
  9. data/lib/yorishiro/cli.rb +474 -93
  10. data/lib/yorishiro/compactor.rb +53 -0
  11. data/lib/yorishiro/configuration.rb +69 -1
  12. data/lib/yorishiro/conversation.rb +117 -0
  13. data/lib/yorishiro/diff.rb +92 -0
  14. data/lib/yorishiro/hooks.rb +64 -0
  15. data/lib/yorishiro/input_history.rb +49 -0
  16. data/lib/yorishiro/mcp/server_manager.rb +4 -4
  17. data/lib/yorishiro/provider/anthropic.rb +37 -8
  18. data/lib/yorishiro/provider/base.rb +20 -4
  19. data/lib/yorishiro/provider/ollama.rb +70 -45
  20. data/lib/yorishiro/provider/open_ai.rb +21 -5
  21. data/lib/yorishiro/session_resume.rb +73 -0
  22. data/lib/yorishiro/session_store.rb +168 -0
  23. data/lib/yorishiro/skill.rb +11 -0
  24. data/lib/yorishiro/skill_loader.rb +53 -0
  25. data/lib/yorishiro/sub_agent.rb +143 -0
  26. data/lib/yorishiro/tool.rb +7 -0
  27. data/lib/yorishiro/tool_result_cap.rb +31 -0
  28. data/lib/yorishiro/tools/edit_file.rb +87 -0
  29. data/lib/yorishiro/tools/execute_command.rb +8 -0
  30. data/lib/yorishiro/tools/exit_plan_mode.rb +41 -0
  31. data/lib/yorishiro/tools/grep.rb +118 -0
  32. data/lib/yorishiro/tools/list_files.rb +10 -8
  33. data/lib/yorishiro/tools/read_file.rb +27 -9
  34. data/lib/yorishiro/tools/task.rb +74 -0
  35. data/lib/yorishiro/tools/write_file.rb +9 -0
  36. data/lib/yorishiro/version.rb +1 -1
  37. data/lib/yorishiro.rb +13 -1
  38. metadata +33 -5
  39. 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.use(provider: @cli_opts[:provider]) if @cli_opts[:provider]
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
- if input.strip.start_with?("/")
88
- handle_slash_command(input.strip)
89
- next
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
- lines = []
100
- empty_count = 0
101
- prompt = "you> "
102
-
103
- loop do
104
- line = Reline.readline(prompt, true)
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
- lines.join("\n")
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
- @output.print "\nassistant> "
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
- @conversation.add_message(:assistant, content, tool_calls: tool_calls.empty? ? nil : tool_calls)
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
- read_only_tools = Yorishiro.configuration.read_only_tool_definitions
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
- @output.print "\nassistant> "
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
- @conversation.add_message(:assistant, content, tool_calls: tool_calls.empty? ? nil : tool_calls)
317
+ record_assistant_message(content, tool_calls)
175
318
 
176
319
  break if tool_calls.empty?
177
320
 
178
- # read-only ツールはパーミッション不要で即実行
179
- tool_calls.each do |tc|
180
- tool = Yorishiro.configuration.find_tool(tc[:name])
181
- unless tool
182
- @conversation.add_tool_result(tool_call_id: tc[:id], content: "Error: Unknown tool '#{tc[:name]}'")
183
- next
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
- @output.puts "[Tool] Executing: #{tc[:name]}(#{format_args(tc[:arguments])})"
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
- tool_call[:arguments]&.each do |key, value|
248
- str = value.to_s
249
- if str.length > 80 || str.include?("\n")
250
- @output.puts " #{key}:"
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
- @conversation = Conversation.new(system_prompt: Yorishiro.configuration.system_prompt_text)
279
- @output.puts "Conversation cleared."
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
- skill = Yorishiro.configuration.skills.find { |s| "/#{s.name}" == command }
291
- if skill
292
- result = skill.execute({ conversation: @conversation, args: args })
293
- @output.puts result if result
294
- else
295
- @output.puts "Unknown command: #{command}. Type /help for available commands."
296
- end
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