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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yorishiro
4
+ # Summarizes older conversation history into a compact summary so long
5
+ # sessions stay within the model's context window (similar to Claude Code's
6
+ # auto-compaction). Uses the active provider to generate the summary.
7
+ class Compactor
8
+ KEEP_RECENT_ROUNDS = 2
9
+
10
+ SUMMARY_SYSTEM_PROMPT = <<~PROMPT
11
+ You compress conversation history for an AI coding assistant. Produce a
12
+ concise but complete summary that preserves everything needed to continue
13
+ the work: the user's goals and requests, key decisions made, files and
14
+ code examined or changed, tool results that still matter, and any
15
+ unresolved questions or next steps. Prefer terse bullet points. Do not add
16
+ commentary or ask questions — output only the summary.
17
+ PROMPT
18
+
19
+ SUMMARY_INSTRUCTION = "Summarize the following conversation transcript so the assistant can continue seamlessly:"
20
+
21
+ def initialize(provider)
22
+ @provider = provider
23
+ end
24
+
25
+ # Compact the given conversation in place. Returns the number of messages
26
+ # that were summarized away (0 if nothing was compacted).
27
+ def compact(conversation, keep_recent_rounds: KEEP_RECENT_ROUNDS)
28
+ conversation.compact!(keep_recent_rounds: keep_recent_rounds) do |old_messages|
29
+ summarize(old_messages)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def summarize(old_messages)
36
+ transcript = old_messages.map { |msg| format_message(msg) }.join("\n\n")
37
+
38
+ request = Conversation.new(system_prompt: SUMMARY_SYSTEM_PROMPT)
39
+ request.add_message(:user, "#{SUMMARY_INSTRUCTION}\n\n#{transcript}")
40
+
41
+ @provider.chat(request)[:content]
42
+ end
43
+
44
+ def format_message(msg)
45
+ lines = ["#{msg[:role]}: #{msg[:content]}"]
46
+ if msg[:tool_calls]
47
+ calls = msg[:tool_calls].map { |tc| "#{tc[:name]}(#{tc[:arguments]})" }.join(", ")
48
+ lines << " [tool_calls: #{calls}]"
49
+ end
50
+ lines.join("\n")
51
+ end
52
+ end
53
+ end
@@ -3,7 +3,8 @@
3
3
  module Yorishiro
4
4
  class Configuration
5
5
  attr_reader :provider_name, :api_key, :model, :allowed_tools, :skills,
6
- :mcp_servers, :system_prompt_text, :plan_mode_enabled
6
+ :mcp_servers, :system_prompt_text, :plan_mode_enabled, :ollama_num_ctx_value,
7
+ :auto_compact_enabled, :hooks
7
8
 
8
9
  def initialize
9
10
  @provider_name = nil
@@ -14,6 +15,9 @@ module Yorishiro
14
15
  @mcp_servers = {}
15
16
  @system_prompt_text = nil
16
17
  @plan_mode_enabled = false
18
+ @ollama_num_ctx_value = nil
19
+ @auto_compact_enabled = true
20
+ @hooks = Hooks.new
17
21
  end
18
22
 
19
23
  def use(provider:, api_key: nil, model: nil)
@@ -22,6 +26,20 @@ module Yorishiro
22
26
  @model = model
23
27
  end
24
28
 
29
+ # Switch provider/model at runtime (e.g. the /model command). Re-validates
30
+ # and rolls back to the previous settings if the new combination is
31
+ # invalid, so a rejected switch never leaves the config half-changed.
32
+ def switch!(provider:, model:, api_key:)
33
+ previous = [@provider_name, @api_key, @model]
34
+ @provider_name = provider
35
+ @api_key = api_key
36
+ @model = model
37
+ validate!
38
+ rescue ConfigurationError
39
+ @provider_name, @api_key, @model = previous
40
+ raise
41
+ end
42
+
25
43
  def allow_tool(tool, **options)
26
44
  tool.configure(options) if tool.respond_to?(:configure)
27
45
  @allowed_tools << tool
@@ -34,6 +52,15 @@ module Yorishiro
34
52
  @skills << skill_instance
35
53
  end
36
54
 
55
+ # Register a skill, replacing any same-name skill registered earlier
56
+ # (so ./.yorishiro/skills overrides ~/.yorishiro/skills).
57
+ def replace_skill(skill_instance)
58
+ raise SkillNotImplementedError, "Skill must implement #name" unless skill_instance.respond_to?(:name)
59
+
60
+ @skills.reject! { |s| s.name == skill_instance.name }
61
+ skill(skill_instance)
62
+ end
63
+
37
64
  def mcp_server(name, command:, args: [], env: {})
38
65
  @mcp_servers[name] = { command: command, args: args, env: env }
39
66
  end
@@ -46,9 +73,38 @@ module Yorishiro
46
73
  @plan_mode_enabled = enabled
47
74
  end
48
75
 
76
+ # Override the Ollama context window (num_ctx). Set from .yorishirorc, e.g.
77
+ # `ollama_num_ctx 16384`. Also settable via the OLLAMA_NUM_CTX env var.
78
+ def ollama_num_ctx(value)
79
+ @ollama_num_ctx_value = value
80
+ end
81
+
82
+ # Toggle automatic context compaction (LLM summarization of old history
83
+ # when the conversation nears the context window). Enabled by default;
84
+ # disable from .yorishirorc with `auto_compact false`.
85
+ def auto_compact(enabled)
86
+ @auto_compact_enabled = enabled
87
+ end
88
+
89
+ # Register a lifecycle hook from .yorishirorc, e.g.
90
+ # on :before_tool_use do |tool_name, args|
91
+ # deny("rm is not allowed") if args["command"].to_s.start_with?("rm")
92
+ # end
93
+ def on(event, &)
94
+ @hooks.on(event, &)
95
+ end
96
+
97
+ # Hook helper: return deny("reason") from a before_tool_use or
98
+ # user_prompt_submit block to veto the action.
99
+ def deny(reason = "denied by hook")
100
+ Hooks::Denial.new(reason)
101
+ end
102
+
49
103
  def load!
50
104
  load_rc_file(global_rc_path)
51
105
  load_rc_file(local_rc_path)
106
+ load_skill_files(global_skills_dir)
107
+ load_skill_files(local_skills_dir)
52
108
  validate!
53
109
  end
54
110
 
@@ -86,6 +142,18 @@ module Yorishiro
86
142
  File.join(Dir.pwd, ".lyorishirorc")
87
143
  end
88
144
 
145
+ def global_skills_dir
146
+ File.join(Dir.home, ".yorishiro", "skills")
147
+ end
148
+
149
+ def local_skills_dir
150
+ File.join(Dir.pwd, ".yorishiro", "skills")
151
+ end
152
+
153
+ def load_skill_files(dir)
154
+ SkillLoader.new(self).load_dir(dir)
155
+ end
156
+
89
157
  def load_rc_file(path)
90
158
  return unless File.exist?(path)
91
159
 
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Yorishiro
4
4
  class Conversation
5
+ # Rough heuristic used to estimate token counts without a real tokenizer.
6
+ # English/code averages ~4 characters per token.
7
+ CHARS_PER_TOKEN = 4
8
+
5
9
  attr_reader :messages
6
10
 
7
11
  def initialize(system_prompt: nil)
@@ -44,6 +48,31 @@ module Yorishiro
44
48
  @messages.clear
45
49
  end
46
50
 
51
+ # JSON-safe deep copy of the messages (string keys) for session
52
+ # persistence. The system prompt is intentionally excluded — a resumed
53
+ # session uses whatever the current configuration provides.
54
+ def serializable_messages
55
+ JSON.parse(JSON.generate(@messages))
56
+ end
57
+
58
+ # Replace the messages with ones loaded from a saved session
59
+ # (string-keyed hashes as produced by #serializable_messages).
60
+ # Tool-call arguments keep their string keys: providers pass them
61
+ # through as-is and the CLI symbolizes at execution time, matching the
62
+ # live message flow.
63
+ def restore_messages!(raw_messages)
64
+ @messages = raw_messages.map do |msg|
65
+ role = msg["role"].to_sym
66
+ validate_role!(role)
67
+ {
68
+ role: role,
69
+ content: msg["content"],
70
+ tool_calls: msg["tool_calls"]&.map { |tc| { id: tc["id"], name: tc["name"], arguments: tc["arguments"] } },
71
+ tool_call_id: msg["tool_call_id"]
72
+ }.compact
73
+ end
74
+ end
75
+
47
76
  def last_role
48
77
  @messages.last&.fetch(:role, nil)
49
78
  end
@@ -52,8 +81,96 @@ module Yorishiro
52
81
  @messages.length
53
82
  end
54
83
 
84
+ # Rough estimate of the prompt size in tokens (system prompt + all messages).
85
+ def estimated_tokens
86
+ total = @system_prompt.to_s.length
87
+ total += @messages.sum { |msg| message_char_size(msg) }
88
+ (total.to_f / CHARS_PER_TOKEN).ceil
89
+ end
90
+
91
+ # Replace the oldest rounds with a single summary while keeping the most
92
+ # recent +keep_recent_rounds+ rounds verbatim. The block receives the old
93
+ # messages and must return the summary text; rounds are handled whole so a
94
+ # tool call is never split from its result, and the summary is inserted as a
95
+ # leading :user message (keeping the user-first ordering providers expect).
96
+ # Returns the number of messages that were compacted away (0 if there was
97
+ # nothing old enough to compact or the summary came back empty).
98
+ def compact!(keep_recent_rounds: 2)
99
+ starts = user_message_indices
100
+ return 0 if starts.length <= keep_recent_rounds
101
+
102
+ cut = starts[starts.length - keep_recent_rounds]
103
+ old = @messages[0...cut]
104
+ return 0 if old.empty?
105
+
106
+ summary = yield(old)
107
+ return 0 if summary.nil? || summary.strip.empty?
108
+
109
+ @messages = [summary_message(summary)] + (@messages[cut..] || [])
110
+ old.length
111
+ end
112
+
113
+ ELIDED_TOOL_RESULT = "[Old tool result removed to free context. Re-run the tool if the output is needed again.]"
114
+
115
+ # Replace the contents of the oldest tool results with a short
116
+ # placeholder until the conversation fits within +max_tokens+, keeping
117
+ # the most recent +keep_recent+ tool results verbatim. Unlike
118
+ # trim_to_budget!, this frees space inside a single round — a long tool
119
+ # loop after one user message — where whole-round trimming cannot drop
120
+ # anything. Message structure is preserved, so an assistant tool_call is
121
+ # never left without its result. Returns the number of results elided.
122
+ def elide_old_tool_results!(max_tokens:, keep_recent: 2)
123
+ tool_indices = @messages.each_index.select { |i| @messages[i][:role] == :tool }
124
+ candidates = keep_recent.positive? ? tool_indices[0...-keep_recent] : tool_indices
125
+ elided = 0
126
+
127
+ (candidates || []).each do |index|
128
+ break if estimated_tokens <= max_tokens
129
+ next if @messages[index][:content] == ELIDED_TOOL_RESULT
130
+
131
+ @messages[index][:content] = ELIDED_TOOL_RESULT
132
+ elided += 1
133
+ end
134
+
135
+ elided
136
+ end
137
+
138
+ # Drop the oldest conversation rounds until the estimated size fits within
139
+ # +max_tokens+. A "round" starts at a :user message and includes the
140
+ # assistant reply and any tool results that follow, so whole rounds are
141
+ # removed together — never splitting an assistant tool_call from its tool
142
+ # result. The system prompt and the most recent round are always kept.
143
+ # Returns the number of messages removed.
144
+ def trim_to_budget!(max_tokens:)
145
+ removed_count = 0
146
+
147
+ while estimated_tokens > max_tokens
148
+ round_starts = user_message_indices
149
+ break if round_starts.length <= 1 # keep at least the latest round
150
+
151
+ removed = @messages.slice!(0, round_starts[1])
152
+ removed_count += removed.length
153
+ end
154
+
155
+ removed_count
156
+ end
157
+
55
158
  private
56
159
 
160
+ def user_message_indices
161
+ @messages.each_index.select { |i| @messages[i][:role] == :user }
162
+ end
163
+
164
+ def summary_message(summary)
165
+ { role: :user, content: "[Summary of earlier conversation]\n#{summary}" }
166
+ end
167
+
168
+ def message_char_size(msg)
169
+ size = msg[:content].to_s.length
170
+ size += msg[:tool_calls].to_s.length if msg[:tool_calls]
171
+ size
172
+ end
173
+
57
174
  def validate_role!(role)
58
175
  return if %i[user assistant tool].include?(role)
59
176
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yorishiro
4
+ # Minimal unified-diff generator used to preview file changes in the
5
+ # permission prompt. A single hunk is built by stripping the common
6
+ # prefix/suffix lines — accurate for the local edits tools make, and
7
+ # O(n) without an external diff gem.
8
+ module Diff
9
+ CONTEXT_LINES = 3
10
+ MAX_DIFF_LINES = 200
11
+
12
+ module_function
13
+
14
+ # Returns a unified diff between the two texts, or nil when they are
15
+ # identical. When old_text is empty the file is treated as new
16
+ # (--- /dev/null, every line added).
17
+ def unified(old_text, new_text, path:)
18
+ return nil if old_text == new_text
19
+
20
+ old_lines = old_text.lines(chomp: true)
21
+ new_lines = new_text.lines(chomp: true)
22
+
23
+ prefix = common_prefix_length(old_lines, new_lines)
24
+ suffix = common_suffix_length(old_lines, new_lines, prefix)
25
+
26
+ removed = old_lines[prefix...(old_lines.length - suffix)] || []
27
+ added = new_lines[prefix...(new_lines.length - suffix)] || []
28
+
29
+ header(old_text, path) + hunk(old_lines, prefix, suffix, removed, added)
30
+ end
31
+
32
+ # Adds ANSI colors to a diff produced by .unified. Kept separate so
33
+ # callers can decide whether the output supports color.
34
+ def colorize(diff_text)
35
+ diff_text.lines(chomp: true).map { |line| colorize_line(line) }.join("\n")
36
+ end
37
+
38
+ def common_prefix_length(old_lines, new_lines)
39
+ max = [old_lines.length, new_lines.length].min
40
+ (0...max).each { |i| return i if old_lines[i] != new_lines[i] }
41
+ max
42
+ end
43
+
44
+ def common_suffix_length(old_lines, new_lines, prefix)
45
+ max = [old_lines.length, new_lines.length].min - prefix
46
+ (0...max).each { |i| return i if old_lines[-1 - i] != new_lines[-1 - i] }
47
+ max
48
+ end
49
+
50
+ def header(old_text, path)
51
+ # "/dev/null" is the unified-diff convention for a new file, not a
52
+ # filesystem path, so File::NULL would be wrong on Windows.
53
+ original = old_text.empty? ? "/dev/null" : path # rubocop:disable Style/FileNull
54
+ "--- #{original}\n+++ #{path}\n"
55
+ end
56
+
57
+ def hunk(old_lines, prefix, suffix, removed, added)
58
+ context_start = [prefix - CONTEXT_LINES, 0].max
59
+ before = old_lines[context_start...prefix] || []
60
+ after = old_lines[old_lines.length - suffix, CONTEXT_LINES] || []
61
+
62
+ old_count = before.length + removed.length + after.length
63
+ new_count = before.length + added.length + after.length
64
+ old_start = old_count.zero? ? 0 : context_start + 1
65
+ new_start = new_count.zero? ? 0 : context_start + 1
66
+
67
+ lines = ["@@ -#{old_start},#{old_count} +#{new_start},#{new_count} @@"]
68
+ lines += before.map { |l| " #{l}" }
69
+ lines += removed.map { |l| "-#{l}" }
70
+ lines += added.map { |l| "+#{l}" }
71
+ lines += after.map { |l| " #{l}" }
72
+ truncate(lines).join("\n")
73
+ end
74
+
75
+ def truncate(lines)
76
+ return lines if lines.length <= MAX_DIFF_LINES
77
+
78
+ lines.first(MAX_DIFF_LINES) + ["... (diff truncated, #{lines.length - MAX_DIFF_LINES} more lines)"]
79
+ end
80
+
81
+ def colorize_line(line)
82
+ return line if line.start_with?("--- ", "+++ ")
83
+
84
+ case line
85
+ when /\A@@/ then "\e[36m#{line}\e[0m"
86
+ when /\A\+/ then "\e[32m#{line}\e[0m"
87
+ when /\A-/ then "\e[31m#{line}\e[0m"
88
+ else line
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yorishiro
4
+ # Registry for lifecycle hooks declared in .yorishirorc via the `on` DSL.
5
+ # before_tool_use / user_prompt_submit blocks can veto the action by
6
+ # returning :deny or a Denial (built with the `deny("reason")` helper).
7
+ # Any other return value (including nil) lets the action proceed, so
8
+ # logging-only hooks are safe by default.
9
+ class Hooks
10
+ EVENTS = %i[before_tool_use after_tool_use user_prompt_submit].freeze
11
+
12
+ Denial = Struct.new(:reason)
13
+
14
+ def initialize
15
+ @blocks = Hash.new { |hash, key| hash[key] = [] }
16
+ end
17
+
18
+ def on(event, &block)
19
+ raise ConfigurationError, "Unknown hook event: #{event}. Available events: #{EVENTS.join(", ")}" unless EVENTS.include?(event)
20
+
21
+ @blocks[event] << block
22
+ end
23
+
24
+ def any?(event)
25
+ @blocks[event].any?
26
+ end
27
+
28
+ # Returns nil to proceed, or a Denial. A hook that raises denies the
29
+ # call (fail closed) so a broken guard cannot silently let tools run.
30
+ def run_before_tool_use(tool_name, arguments)
31
+ first_denial(:before_tool_use, tool_name, arguments)
32
+ end
33
+
34
+ def run_user_prompt_submit(input)
35
+ first_denial(:user_prompt_submit, input)
36
+ end
37
+
38
+ # Exceptions propagate to the caller: after hooks are observational,
39
+ # so the CLI just warns and keeps the tool result.
40
+ def run_after_tool_use(tool_name, arguments, result)
41
+ @blocks[:after_tool_use].each { |block| block.call(tool_name, arguments, result) }
42
+ nil
43
+ end
44
+
45
+ private
46
+
47
+ def first_denial(event, *args)
48
+ @blocks[event].each do |block|
49
+ denial = to_denial(block.call(*args))
50
+ return denial if denial
51
+ rescue StandardError => e
52
+ return Denial.new("hook raised #{e.class}: #{e.message}")
53
+ end
54
+ nil
55
+ end
56
+
57
+ def to_denial(value)
58
+ case value
59
+ when Denial then value
60
+ when :deny then Denial.new("denied by hook")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Yorishiro
6
+ # Persists the Reline input-line history to disk so that past prompts can be
7
+ # recalled with the up arrow across sessions. The history file lives under the
8
+ # launch directory (cwd), so each project keeps its own history. Entries are
9
+ # stored as a JSON array of strings; JSON is used (rather than newline-
10
+ # separated text) because a single multi-line input is one entry containing
11
+ # embedded newlines.
12
+ class InputHistory
13
+ DIR_NAME = ".yorishiro"
14
+ FILE_NAME = "history.json"
15
+
16
+ # Cap the on-disk history so it does not grow without bound.
17
+ MAX_ENTRIES = 1000
18
+
19
+ def initialize(dir: Dir.pwd, max_entries: MAX_ENTRIES)
20
+ @path = File.join(dir, DIR_NAME, FILE_NAME)
21
+ @max_entries = max_entries
22
+ end
23
+
24
+ attr_reader :path
25
+
26
+ # Populate Reline::HISTORY from the saved file. No-op if the file is absent
27
+ # or unreadable/corrupt (a broken history file should never block startup).
28
+ def load
29
+ return unless File.exist?(@path)
30
+
31
+ entries = JSON.parse(File.read(@path))
32
+ return unless entries.is_a?(Array)
33
+
34
+ entries.each { |entry| Reline::HISTORY << entry.to_s }
35
+ rescue JSON::ParserError, SystemCallError
36
+ nil
37
+ end
38
+
39
+ # Write the current Reline::HISTORY (trimmed to the most recent
40
+ # +max_entries+) to disk as a JSON array.
41
+ def save
42
+ FileUtils.mkdir_p(File.dirname(@path))
43
+ entries = Reline::HISTORY.to_a.last(@max_entries)
44
+ File.write(@path, JSON.generate(entries))
45
+ rescue SystemCallError
46
+ nil
47
+ end
48
+ end
49
+ end
@@ -23,7 +23,7 @@ module Yorishiro
23
23
 
24
24
  def stop_all
25
25
  @servers.each_value do |server|
26
- server[:transport].stop
26
+ server[:transport].close
27
27
  rescue StandardError => e
28
28
  warn "[MCP] Error stopping server: #{e.message}"
29
29
  end
@@ -33,14 +33,14 @@ module Yorishiro
33
33
  private
34
34
 
35
35
  def start_server(name, config)
36
- transport = StdioTransport.new(
36
+ transport = ::MCP::Client::Stdio.new(
37
37
  command: config[:command],
38
38
  args: config.fetch(:args, []),
39
- env: config.fetch(:env, {})
39
+ env: config.fetch(:env, {}).compact
40
40
  )
41
- transport.start
42
41
 
43
42
  client = ::MCP::Client.new(transport: transport)
43
+ client.connect
44
44
 
45
45
  mcp_tools = client.tools
46
46
  mcp_tools.each do |mcp_tool|
@@ -6,12 +6,20 @@ module Yorishiro
6
6
  API_URL = "https://api.anthropic.com/v1/messages"
7
7
  API_VERSION = "2023-06-01"
8
8
 
9
+ # Alias IDs (no date suffix) from Anthropic's model catalog. Aliases
10
+ # track the latest snapshot of each model, so this list goes stale far
11
+ # more slowly than the dated IDs it used to hold — but it is still a
12
+ # snapshot: add new entries here as models are released.
9
13
  SUPPORTED_MODELS = %w[
10
- claude-opus-4-20250514
11
- claude-sonnet-4-20250514
12
- claude-haiku-4-20250414
13
- claude-3-5-sonnet-20241022
14
- claude-3-5-haiku-20241022
14
+ claude-fable-5
15
+ claude-opus-4-8
16
+ claude-opus-4-7
17
+ claude-opus-4-6
18
+ claude-opus-4-5
19
+ claude-sonnet-5
20
+ claude-sonnet-4-6
21
+ claude-sonnet-4-5
22
+ claude-haiku-4-5
15
23
  ].freeze
16
24
 
17
25
  def self.supported_models
@@ -38,17 +46,20 @@ module Yorishiro
38
46
  "anthropic-version" => API_VERSION
39
47
  }
40
48
 
41
- post_stream(uri, headers: headers, body: body, &)
49
+ @last_usage = {}
50
+ result = post_stream(uri, headers: headers, body: body, &)
51
+ result[:usage] = @last_usage
52
+ result
42
53
  end
43
54
 
44
55
  private
45
56
 
46
57
  def default_model
47
- "claude-sonnet-4-20250514"
58
+ "claude-opus-4-8"
48
59
  end
49
60
 
50
61
  def format_messages(api_messages)
51
- messages = api_messages.reject { |m| m[:role] == "system" }
62
+ messages = api_messages.reject { |m| m[:role] == "system" || empty_assistant?(m) }
52
63
 
53
64
  messages.map do |msg|
54
65
  if msg[:role] == "tool"
@@ -78,6 +89,14 @@ module Yorishiro
78
89
  end
79
90
  end
80
91
 
92
+ # The API rejects assistant messages whose content is empty, and the
93
+ # error repeats on every request — so an empty completion recorded in
94
+ # the history (e.g. by a session saved before the CLI filtered them
95
+ # out) is dropped rather than poisoning the whole session.
96
+ def empty_assistant?(msg)
97
+ msg[:role] == "assistant" && msg[:tool_calls].to_a.empty? && msg[:content].to_s.strip.empty?
98
+ end
99
+
81
100
  def extract_system_prompt(api_messages)
82
101
  system_msg = api_messages.find { |m| m[:role] == "system" }
83
102
  system_msg&.fetch(:content, nil)
@@ -93,6 +112,14 @@ module Yorishiro
93
112
  end
94
113
  end
95
114
 
115
+ # input_tokens arrives on message_start, output_tokens on message_delta.
116
+ def capture_usage(data)
117
+ input = data.dig("message", "usage", "input_tokens")
118
+ output = data.dig("usage", "output_tokens")
119
+ @last_usage[:input] = input if input
120
+ @last_usage[:output] = output if output
121
+ end
122
+
96
123
  def parse_stream(response, tool_calls:, &block)
97
124
  buffer = +""
98
125
  current_tool_call = nil
@@ -105,6 +132,8 @@ module Yorishiro
105
132
  next unless parsed[:data]
106
133
 
107
134
  case parsed[:event] || parsed[:data]["type"]
135
+ when "message_start", "message_delta"
136
+ capture_usage(parsed[:data])
108
137
  when "content_block_start"
109
138
  content_block = parsed[:data]["content_block"]
110
139
  if content_block && content_block["type"] == "tool_use"
@@ -29,22 +29,33 @@ module Yorishiro
29
29
  def debug_log(label, data = nil)
30
30
  return unless debug?
31
31
 
32
- $stderr.puts "[DEBUG] #{label}"
33
- $stderr.puts(data.is_a?(String) ? data : JSON.pretty_generate(data)) if data
32
+ warn "[DEBUG] #{label}"
33
+ warn(data.is_a?(String) ? data : JSON.pretty_generate(data)) if data
34
34
  rescue StandardError
35
35
  nil
36
36
  end
37
37
 
38
+ # Token budget the conversation should be trimmed to before each request.
39
+ # nil means "no known context window" — the conversation is not trimmed.
40
+ # Providers with a fixed local context window (Ollama) override this.
41
+ def context_budget_tokens
42
+ nil
43
+ end
44
+
38
45
  private
39
46
 
40
47
  def default_model
41
48
  raise ProviderNotImplementedError, "#{self.class}#default_model is not implemented"
42
49
  end
43
50
 
51
+ def read_timeout
52
+ 120
53
+ end
54
+
44
55
  def post_stream(uri, headers:, body:, &block)
45
56
  http = Net::HTTP.new(uri.host, uri.port)
46
57
  http.use_ssl = (uri.scheme == "https")
47
- http.read_timeout = 120
58
+ http.read_timeout = read_timeout
48
59
 
49
60
  request = Net::HTTP::Post.new(uri)
50
61
  headers.each { |k, v| request[k] = v }
@@ -115,7 +126,12 @@ module Yorishiro
115
126
 
116
127
  def self.build(config)
117
128
  provider_class = self.for(config.provider_name)
118
- provider_class.new(api_key: config.api_key, model: config.model)
129
+
130
+ if config.provider_name == :ollama
131
+ provider_class.new(api_key: config.api_key, model: config.model, num_ctx: config.ollama_num_ctx_value)
132
+ else
133
+ provider_class.new(api_key: config.api_key, model: config.model)
134
+ end
119
135
  end
120
136
  end
121
137
  end