yorishiro 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yorishirorc.sample +51 -0
- data/CHANGELOG.md +41 -0
- data/README.md +134 -8
- data/docs/en/README.md +97 -2
- data/docs/ja/README.md +97 -2
- data/docs/ja/how_to_create_skills.md +0 -0
- data/docs/ja/ollama_setup.md +148 -0
- data/lib/yorishiro/cli.rb +474 -93
- data/lib/yorishiro/compactor.rb +53 -0
- data/lib/yorishiro/configuration.rb +69 -1
- data/lib/yorishiro/conversation.rb +117 -0
- data/lib/yorishiro/diff.rb +92 -0
- data/lib/yorishiro/hooks.rb +64 -0
- data/lib/yorishiro/input_history.rb +49 -0
- data/lib/yorishiro/mcp/server_manager.rb +4 -4
- data/lib/yorishiro/provider/anthropic.rb +37 -8
- data/lib/yorishiro/provider/base.rb +20 -4
- data/lib/yorishiro/provider/ollama.rb +70 -45
- data/lib/yorishiro/provider/open_ai.rb +21 -5
- data/lib/yorishiro/session_resume.rb +73 -0
- data/lib/yorishiro/session_store.rb +168 -0
- data/lib/yorishiro/skill.rb +11 -0
- data/lib/yorishiro/skill_loader.rb +53 -0
- data/lib/yorishiro/sub_agent.rb +143 -0
- data/lib/yorishiro/tool.rb +7 -0
- data/lib/yorishiro/tool_result_cap.rb +31 -0
- data/lib/yorishiro/tools/edit_file.rb +87 -0
- data/lib/yorishiro/tools/execute_command.rb +8 -0
- data/lib/yorishiro/tools/exit_plan_mode.rb +41 -0
- data/lib/yorishiro/tools/grep.rb +118 -0
- data/lib/yorishiro/tools/list_files.rb +10 -8
- data/lib/yorishiro/tools/read_file.rb +27 -9
- data/lib/yorishiro/tools/task.rb +74 -0
- data/lib/yorishiro/tools/write_file.rb +9 -0
- data/lib/yorishiro/version.rb +1 -1
- data/lib/yorishiro.rb +13 -1
- metadata +33 -5
- data/lib/yorishiro/mcp/stdio_transport.rb +0 -85
|
@@ -0,0 +1,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].
|
|
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 =
|
|
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-
|
|
11
|
-
claude-
|
|
12
|
-
claude-
|
|
13
|
-
claude-
|
|
14
|
-
claude-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
33
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|