samagotchi 0.3.0 → 0.5.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/CHANGELOG.md +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Line-based unified diff (like `diff -u` without the file header), used to
|
|
5
|
+
# show what chi's edit/write tools change. Pure: strings in, a hash out.
|
|
6
|
+
#
|
|
7
|
+
# TextDiff.unified("a\nb\n", "a\nc\n")
|
|
8
|
+
# # => {text: "@@ -1,2 +1,2 @@\n a\n-b\n+c", added: 1, removed: 1, truncated: false}
|
|
9
|
+
#
|
|
10
|
+
# The common prefix and suffix are trimmed first (edits are local), then
|
|
11
|
+
# Myers O(ND) runs on the middle. When the edit distance passes
|
|
12
|
+
# MAX_EDIT_DISTANCE it gives up on a minimal diff and shows the whole middle
|
|
13
|
+
# as removed then added: correct, just not minimal. The text is cut at
|
|
14
|
+
# MAX_LINES / MAX_BYTES with a "… N more lines" line; the counts stay exact.
|
|
15
|
+
module TextDiff
|
|
16
|
+
CONTEXT = 3
|
|
17
|
+
MAX_LINES = 120
|
|
18
|
+
MAX_BYTES = 8 * 1024
|
|
19
|
+
MAX_EDIT_DISTANCE = 1000 # D² work: ~0.35 s for a dense 5 000-line edit
|
|
20
|
+
NO_NEWLINE = "\"
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def unified(before, after, context: CONTEXT, max_lines: MAX_LINES, max_bytes: MAX_BYTES)
|
|
25
|
+
a = clean(before).lines
|
|
26
|
+
b = clean(after).lines
|
|
27
|
+
ops = edit_script(a, b)
|
|
28
|
+
added = ops.count { |op| op[0] == :add }
|
|
29
|
+
removed = ops.count { |op| op[0] == :del }
|
|
30
|
+
text, truncated = render(hunks(ops, context), a, b, max_lines, max_bytes)
|
|
31
|
+
{ text: text, added: added, removed: removed, truncated: truncated }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clean(text)
|
|
35
|
+
text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("�")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Ops are [:eq, ai, bi], [:del, ai, nil] or [:add, nil, bi] in file order.
|
|
39
|
+
def edit_script(a, b)
|
|
40
|
+
pre = 0
|
|
41
|
+
pre += 1 while pre < a.size && pre < b.size && a[pre] == b[pre]
|
|
42
|
+
suf = 0
|
|
43
|
+
suf += 1 while suf < a.size - pre && suf < b.size - pre && a[-1 - suf] == b[-1 - suf]
|
|
44
|
+
|
|
45
|
+
ops = (0...pre).map { |i| [:eq, i, i] }
|
|
46
|
+
a_mid = a[pre...(a.size - suf)]
|
|
47
|
+
b_mid = b[pre...(b.size - suf)]
|
|
48
|
+
middle = myers(a_mid, b_mid) ||
|
|
49
|
+
a_mid.each_index.map { |i| [:del, i, nil] } + b_mid.each_index.map { |j| [:add, nil, j] }
|
|
50
|
+
middle.each { |t, i, j| ops << [t, i && i + pre, j && j + pre] }
|
|
51
|
+
(0...suf).each { |s| ops << [:eq, a.size - suf + s, b.size - suf + s] }
|
|
52
|
+
ops
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Myers' greedy forward search, keeping one slice of V per round for the
|
|
56
|
+
# backtrack. Returns nil when the edit distance passes max_d.
|
|
57
|
+
def myers(a, b, max_d = MAX_EDIT_DISTANCE)
|
|
58
|
+
ids = {}
|
|
59
|
+
a = a.map { |l| ids[l] ||= ids.size }
|
|
60
|
+
b = b.map { |l| ids[l] ||= ids.size }
|
|
61
|
+
# No line in common (a full rewrite): the fallback is the minimal diff.
|
|
62
|
+
return nil if ids.size == a.uniq.size + b.uniq.size
|
|
63
|
+
|
|
64
|
+
n = a.size
|
|
65
|
+
m = b.size
|
|
66
|
+
off = n + m + 1
|
|
67
|
+
v = Array.new(2 * off + 1, 0)
|
|
68
|
+
trace = []
|
|
69
|
+
(0..(n + m)).each do |d|
|
|
70
|
+
return nil if d > max_d
|
|
71
|
+
|
|
72
|
+
# Round d reads k-1 and k+1 for k in -d..d: keep -(d+1)..d+1.
|
|
73
|
+
trace << v[off - d - 1, 2 * d + 3]
|
|
74
|
+
k = -d
|
|
75
|
+
while k <= d
|
|
76
|
+
x = if k == -d || (k != d && v[off + k - 1] < v[off + k + 1])
|
|
77
|
+
v[off + k + 1]
|
|
78
|
+
else
|
|
79
|
+
v[off + k - 1] + 1
|
|
80
|
+
end
|
|
81
|
+
y = x - k
|
|
82
|
+
while x < n && y < m && a[x] == b[y]
|
|
83
|
+
x += 1
|
|
84
|
+
y += 1
|
|
85
|
+
end
|
|
86
|
+
v[off + k] = x
|
|
87
|
+
return backtrack(trace, n, m) if x >= n && y >= m
|
|
88
|
+
|
|
89
|
+
k += 2
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def backtrack(trace, x, y)
|
|
96
|
+
ops = []
|
|
97
|
+
(trace.size - 1).downto(0) do |d|
|
|
98
|
+
vd = trace[d]
|
|
99
|
+
at = ->(k) { vd[k + d + 1] }
|
|
100
|
+
k = x - y
|
|
101
|
+
prev_k = k == -d || (k != d && at.(k - 1) < at.(k + 1)) ? k + 1 : k - 1
|
|
102
|
+
prev_x = at.(prev_k)
|
|
103
|
+
prev_y = prev_x - prev_k
|
|
104
|
+
while x > prev_x && y > prev_y
|
|
105
|
+
x -= 1
|
|
106
|
+
y -= 1
|
|
107
|
+
ops << [:eq, x, y]
|
|
108
|
+
end
|
|
109
|
+
break if d.zero?
|
|
110
|
+
|
|
111
|
+
if x == prev_x
|
|
112
|
+
ops << [:add, nil, prev_y]
|
|
113
|
+
else
|
|
114
|
+
ops << [:del, prev_x, nil]
|
|
115
|
+
end
|
|
116
|
+
x = prev_x
|
|
117
|
+
y = prev_y
|
|
118
|
+
end
|
|
119
|
+
ops.reverse
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Groups ops into hunks: runs of changes whose gaps are at most 2*context
|
|
123
|
+
# unchanged lines, each padded with up to context lines on both sides.
|
|
124
|
+
def hunks(ops, context)
|
|
125
|
+
changed = ops.each_index.reject { |i| ops[i][0] == :eq }
|
|
126
|
+
return [] if changed.empty?
|
|
127
|
+
|
|
128
|
+
groups = [[changed.first, changed.first]]
|
|
129
|
+
changed.drop(1).each do |i|
|
|
130
|
+
if i - groups.last[1] - 1 <= 2 * context
|
|
131
|
+
groups.last[1] = i
|
|
132
|
+
else
|
|
133
|
+
groups << [i, i]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
groups.map { |first, last| ops[[first - context, 0].max..[last + context, ops.size - 1].min] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def render(hunks, a, b, max_lines, max_bytes)
|
|
140
|
+
lines = []
|
|
141
|
+
hunks.each do |hunk|
|
|
142
|
+
lines << header(hunk)
|
|
143
|
+
hunk.each do |t, i, j|
|
|
144
|
+
line = t == :add ? b[j] : a[i]
|
|
145
|
+
mark = { eq: " ", del: "-", add: "+" }[t]
|
|
146
|
+
# Only the "\n": chomp("\n") would take a CRLF's "\r" too.
|
|
147
|
+
lines << mark + (line.end_with?("\n") ? line[0...-1] : line)
|
|
148
|
+
lines << NO_NEWLINE unless line.end_with?("\n")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
cut(lines, max_lines, max_bytes)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @@ -start,len +start,len @@ with diff -u's conventions: ",1" is left
|
|
155
|
+
# out. A side with no lines in the hunk is an empty file (any other hunk
|
|
156
|
+
# has context lines), shown as 0,0.
|
|
157
|
+
def header(hunk)
|
|
158
|
+
a_lines = hunk.filter_map { |t, i, _| i unless t == :add }
|
|
159
|
+
b_lines = hunk.filter_map { |t, _, j| j unless t == :del }
|
|
160
|
+
"@@ -#{range(a_lines)} +#{range(b_lines)} @@"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def range(indexes)
|
|
164
|
+
return "0,0" if indexes.empty?
|
|
165
|
+
|
|
166
|
+
indexes.size == 1 ? (indexes.first + 1).to_s : "#{indexes.first + 1},#{indexes.size}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def cut(lines, max_lines, max_bytes)
|
|
170
|
+
bytes = 0
|
|
171
|
+
lines.each_with_index do |line, i|
|
|
172
|
+
bytes += line.bytesize + 1
|
|
173
|
+
next unless i >= max_lines || bytes > max_bytes
|
|
174
|
+
|
|
175
|
+
rest = lines.size - i
|
|
176
|
+
return [(lines[0, i] + ["… #{rest} more lines"]).join("\n"), true]
|
|
177
|
+
end
|
|
178
|
+
[lines.join("\n"), false]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# How much a model thinks: one level per model, host or run, mapped to each
|
|
7
|
+
# backend's own switch. The one place that knows the mapping.
|
|
8
|
+
#
|
|
9
|
+
# off thinking off where the backend can do it
|
|
10
|
+
# low, medium, high the effort a chat host is asked for
|
|
11
|
+
# default chi sends nothing: the provider's or template's own default
|
|
12
|
+
#
|
|
13
|
+
# Order (#resolve): --thinking / SAMAGOTCHI_THINKING_LEVEL, then
|
|
14
|
+
# models.<key>.thinking, hosts.<name>.thinking, thinking.level in
|
|
15
|
+
# config.yml, then default.
|
|
16
|
+
module Thinking
|
|
17
|
+
LEVELS = %i[off low medium high default].freeze
|
|
18
|
+
EFFORTS = %i[low medium high].freeze
|
|
19
|
+
DEFAULT = :default
|
|
20
|
+
|
|
21
|
+
# Gemma 4 turns thinking on with this token at the start of the system prompt.
|
|
22
|
+
GEMMA_THINK_TOKEN = "<|think|>\n"
|
|
23
|
+
# An empty thought after the Qwen assistant cue: the model answers at once
|
|
24
|
+
# (what the server's own template does with enable_thinking false).
|
|
25
|
+
QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
|
|
26
|
+
|
|
27
|
+
# What a native prompt gets for a level: the text before the system
|
|
28
|
+
# prompt, the text after the assistant cue, and whether the level means
|
|
29
|
+
# anything there (low/medium/high have no native knob).
|
|
30
|
+
Native = Data.define(:system_token, :prefill, :honoured)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# A configured value as a level; nil when unset or not a level (that
|
|
35
|
+
# warns once, naming +where+). YAML reads an unquoted `off` as false,
|
|
36
|
+
# which is :off; true (`on`) isn't a level.
|
|
37
|
+
# @return [Symbol, nil]
|
|
38
|
+
def level(value, where)
|
|
39
|
+
return nil if value.nil?
|
|
40
|
+
return :off if value == false || value.to_s.strip.downcase == "false"
|
|
41
|
+
|
|
42
|
+
text = value.to_s.strip.downcase
|
|
43
|
+
return text.to_sym if LEVELS.include?(text.to_sym)
|
|
44
|
+
|
|
45
|
+
hint = value == true || %w[true on].include?(text) ? "; `on` isn't one, `default` leaves it to the model" : ""
|
|
46
|
+
ConfigFile.warn_once "Warning: #{where}: thinking must be one of #{LEVELS.join(', ')}#{hint}; ignored"
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The level for +target+ and where it came from ("--thinking",
|
|
51
|
+
# "models: qwen", "hosts.work", "thinking.level"; nil for the default).
|
|
52
|
+
# @param target [HostRegistry::ModelTarget]
|
|
53
|
+
# @param names [Array<String>, nil] the model's lookup names (Engine#model_lookup_names)
|
|
54
|
+
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
55
|
+
# @return [Array(Symbol, String|nil)]
|
|
56
|
+
def resolve(target, names: nil, models: nil)
|
|
57
|
+
global, origin = global_level
|
|
58
|
+
return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
|
|
59
|
+
|
|
60
|
+
models ||= begin
|
|
61
|
+
ConfigFile.model_settings
|
|
62
|
+
rescue StandardError
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
names ||= [target.model, target.bare_model]
|
|
66
|
+
key, value = ConfigFile.model_setting(names, :thinking, models: models)
|
|
67
|
+
return [value, "models: #{key}"] if key
|
|
68
|
+
|
|
69
|
+
host = target.entry.respond_to?(:thinking) ? target.entry.thinking : nil
|
|
70
|
+
return [host, "hosts.#{target.entry.name}"] if host
|
|
71
|
+
return [global, "thinking.level"] if global
|
|
72
|
+
|
|
73
|
+
[DEFAULT, nil]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# thinking.level as a level and its origin (:cli, :env, :file), or nil.
|
|
77
|
+
def global_level
|
|
78
|
+
raw, origin = Config.get_with_origin("thinking.level")
|
|
79
|
+
return nil if origin == :default
|
|
80
|
+
|
|
81
|
+
where = { cli: "--thinking", env: "SAMAGOTCHI_THINKING_LEVEL" }.fetch(origin, "thinking.level")
|
|
82
|
+
value = level(raw, where)
|
|
83
|
+
value ? [value, origin] : nil
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Request fields for an OpenAI-style chat host. off sends both switches
|
|
89
|
+
# (the template's enable_thinking and the OpenAI-style reasoning_effort
|
|
90
|
+
# "none"): llama.cpp, Splash and OpenRouter each honour one of them.
|
|
91
|
+
# An effort goes as reasoning_effort (llama.cpp ignores it). default
|
|
92
|
+
# sends nothing.
|
|
93
|
+
# @return [Hash] frozen
|
|
94
|
+
def chat_fields(level)
|
|
95
|
+
case level
|
|
96
|
+
when :off then { chat_template_kwargs: { enable_thinking: false }.freeze, reasoning_effort: "none" }.freeze
|
|
97
|
+
when *EFFORTS then { reasoning_effort: level.to_s }.freeze
|
|
98
|
+
else {}.freeze
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether a chat host's /props (+props+, Client::ServerProps) says its
|
|
103
|
+
# chat template takes no effort (llama.cpp's
|
|
104
|
+
# chat_template_caps.supports_reasoning_effort: false), so +level+'s
|
|
105
|
+
# reasoning_effort is ignored. false when it doesn't say.
|
|
106
|
+
def effort_ignored?(level, props)
|
|
107
|
+
return false unless EFFORTS.include?(level) && props&.answered? && props.body.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
caps = props.body["chat_template_caps"]
|
|
110
|
+
caps.is_a?(Hash) && caps["supports_reasoning_effort"] == false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The native prompt's switch for +level+ under +profile+.
|
|
114
|
+
# @param profile [ModelProfile]
|
|
115
|
+
# @return [Native]
|
|
116
|
+
def native(level, profile)
|
|
117
|
+
off = level == :off
|
|
118
|
+
honoured = !EFFORTS.include?(level)
|
|
119
|
+
case profile&.name
|
|
120
|
+
when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
|
|
121
|
+
when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
|
|
122
|
+
else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -31,13 +31,63 @@ module Samagotchi
|
|
|
31
31
|
module_function
|
|
32
32
|
|
|
33
33
|
# @param registry [Tools::Registry, nil] the session's tools
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# @param cwd [String, nil] what a file tool's title is relative to (the
|
|
35
|
+
# worker runs in its session's working directory)
|
|
36
|
+
def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd)
|
|
37
|
+
event = {
|
|
36
38
|
action: tool_activity_action(tool_name, registry: registry),
|
|
37
39
|
tool: tool_name,
|
|
38
40
|
params: tool_activity_params(tool_name, call, registry: registry),
|
|
39
41
|
status: tool_activity_status(result, tool_name)
|
|
40
42
|
}
|
|
43
|
+
title = tool_title(tool_name, call, cwd: cwd)
|
|
44
|
+
event[:title] = title if title
|
|
45
|
+
event
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What the call did, for a web row's one line ("edit lib/a.rb", the
|
|
49
|
+
# command without its "cd … &&"): a file tool's path relative to +cwd+
|
|
50
|
+
# when it is under it, a command's first line, a memory's name; nil for
|
|
51
|
+
# the other tools (a plugin's row keeps its preview, the params).
|
|
52
|
+
# +params+ stays the full key=value line (the TUI, the guardrails).
|
|
53
|
+
def tool_title(tool_name, call, cwd: nil)
|
|
54
|
+
text = case tool_name
|
|
55
|
+
when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
|
|
56
|
+
when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
|
|
57
|
+
when Tools::Execute::NAME, Tools::TaskCreate::NAME then command_title(call[:content])
|
|
58
|
+
end
|
|
59
|
+
text = text.to_s.strip
|
|
60
|
+
return nil if text.empty?
|
|
61
|
+
|
|
62
|
+
case tool_name
|
|
63
|
+
when Tools::Read::NAME, Tools::Write::NAME, Tools::Edit::NAME
|
|
64
|
+
path = relative_path(text, cwd)
|
|
65
|
+
path.length > TOOL_ACTIVITY_PREVIEW_LIMIT ? "…#{path[-(TOOL_ACTIVITY_PREVIEW_LIMIT - 1)..]}" : path
|
|
66
|
+
else
|
|
67
|
+
text = text.gsub(/\s+/, " ")
|
|
68
|
+
text.length > TOOL_ACTIVITY_PREVIEW_LIMIT ? "#{text[0, TOOL_ACTIVITY_PREVIEW_LIMIT - 1]}…" : text
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
|
|
73
|
+
# eats the line.
|
|
74
|
+
LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
|
|
75
|
+
|
|
76
|
+
def command_title(command)
|
|
77
|
+
line = command.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
|
|
78
|
+
line.sub(LEADING_CD, "")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# +path+ relative to +cwd+ when it is inside it, compared by path
|
|
82
|
+
# components ("/p/app-2" is not inside "/p/app"); else as given.
|
|
83
|
+
def relative_path(path, cwd)
|
|
84
|
+
return path if cwd.to_s.empty? || !path.start_with?("/")
|
|
85
|
+
|
|
86
|
+
parts = File.expand_path(path).split("/")
|
|
87
|
+
base = File.expand_path(cwd).split("/")
|
|
88
|
+
return path unless parts.length > base.length && parts[0, base.length] == base
|
|
89
|
+
|
|
90
|
+
parts.drop(base.length).join("/")
|
|
41
91
|
end
|
|
42
92
|
|
|
43
93
|
def tool_activity_action(tool_name, registry: nil)
|
|
@@ -4,6 +4,8 @@ require_relative "tool_activity"
|
|
|
4
4
|
require_relative "guardrails"
|
|
5
5
|
require_relative "vision_context"
|
|
6
6
|
require_relative "log"
|
|
7
|
+
require_relative "edit_preview"
|
|
8
|
+
require_relative "tools/tool_path"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# The single per-call path both loops use: the tool_call_started and
|
|
@@ -17,6 +19,10 @@ module Samagotchi
|
|
|
17
19
|
# that returns many screenshots can't flood the context).
|
|
18
20
|
MAX_IMAGES_PER_RESULT = 4
|
|
19
21
|
|
|
22
|
+
# file_before for a call that isn't an edit/write, or won't run.
|
|
23
|
+
NOT_AN_EDIT = Object.new.freeze
|
|
24
|
+
private_constant :NOT_AN_EDIT
|
|
25
|
+
|
|
20
26
|
# @param kernel [KernelLoop] read lazily: Engine sets its hooks after
|
|
21
27
|
# the kernel is built.
|
|
22
28
|
def initialize(kernel)
|
|
@@ -29,7 +35,9 @@ module Samagotchi
|
|
|
29
35
|
# shown_params: the params line the live row showed, and shown_label:
|
|
30
36
|
# its label ("chrome: screenshot"), only for a tool that isn't built in
|
|
31
37
|
# (the loops save them with the result, so a reload without the plugin
|
|
32
|
-
# shows the same row)
|
|
38
|
+
# shows the same row), and diff: what an edit/write changed in its
|
|
39
|
+
# file (EditPreview.change; also on tool_call_completed, never in the
|
|
40
|
+
# model's output)
|
|
33
41
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
34
42
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
35
43
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
@@ -40,12 +48,17 @@ module Samagotchi
|
|
|
40
48
|
started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
41
49
|
tool: call[:name], call: call.dup, params: params }
|
|
42
50
|
started[:label] = label if label
|
|
51
|
+
# The worker runs in its session's working directory.
|
|
52
|
+
title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
|
|
53
|
+
started[:title] = title if title
|
|
43
54
|
emit(on_stream_event, started)
|
|
44
55
|
|
|
45
56
|
# The ask comes after tool_call_started: the UI shows the tool line,
|
|
46
57
|
# then the approval under it.
|
|
47
58
|
settle_ask(verdict) if verdict.ask?
|
|
59
|
+
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
48
60
|
result = verdict.deny? ? denied(call, verdict) : dispatch(call)
|
|
61
|
+
diff = file_change(call, before)
|
|
49
62
|
result = approved(result, verdict) if verdict.allow? && verdict.decided_by
|
|
50
63
|
result, images = attach_images(call, result) if result[:images]
|
|
51
64
|
|
|
@@ -61,17 +74,40 @@ module Samagotchi
|
|
|
61
74
|
completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
62
75
|
tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
|
|
63
76
|
completed[:images] = images if images&.any?
|
|
77
|
+
completed[:diff] = diff if diff
|
|
64
78
|
emit(on_stream_event, completed)
|
|
65
79
|
|
|
66
80
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
67
81
|
run[:images] = images if images&.any?
|
|
68
82
|
run[:shown_params] = params if params && plugin_tool?(call[:name])
|
|
69
83
|
run[:shown_label] = label if label
|
|
84
|
+
run[:diff] = diff if diff
|
|
70
85
|
run
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
private
|
|
74
89
|
|
|
90
|
+
# edit/write only: the file just before the call runs. The row diffs it
|
|
91
|
+
# with the file after, whatever the result says, so an edit that fails
|
|
92
|
+
# after writing still shows its change and one that wrote nothing shows
|
|
93
|
+
# none.
|
|
94
|
+
def file_before(call)
|
|
95
|
+
return NOT_AN_EDIT unless EditPreview.tool?(call[:name])
|
|
96
|
+
|
|
97
|
+
EditPreview.snapshot(Tools::ToolPath.normalize(call[:path]))
|
|
98
|
+
rescue StandardError
|
|
99
|
+
NOT_AN_EDIT
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def file_change(call, before)
|
|
103
|
+
return nil if before.equal?(NOT_AN_EDIT)
|
|
104
|
+
|
|
105
|
+
EditPreview.change(before, EditPreview.snapshot(Tools::ToolPath.normalize(call[:path])))
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
Log.warn(:turn, "edit_diff_failed", tool: call[:name], error: "#{e.class}: #{e.message}")
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
75
111
|
# A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
|
|
76
112
|
# binary file). They become "?" here, before the output reaches the
|
|
77
113
|
# conversation, the events and the saved session: JSON.generate raises
|
|
@@ -19,30 +19,50 @@ module Samagotchi
|
|
|
19
19
|
|
|
20
20
|
def self.name = NAME
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
def self.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
QUESTION_REQUIRED_ERROR = "Error: ask_user_question requires 'question'"
|
|
23
|
+
|
|
24
|
+
# The tool result for a wrong number of options.
|
|
25
|
+
def self.options_count_error(count)
|
|
26
|
+
"Error: ask_user_question requires 2-8 options (got #{count}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The one check of a model's ask_user_question call (the kernel runs it
|
|
30
|
+
# before the question opens). Dumb-model tolerant: options may be a JSON
|
|
31
|
+
# string or bracket noise, a single option is salvaged, missing options
|
|
32
|
+
# are read from the call's content, booleans may be strings; wire control
|
|
33
|
+
# tokens are stripped from the question and header.
|
|
34
|
+
# @param call [Hash] the tool call (question:/content:, options:, header:,
|
|
35
|
+
# multi_select:, allow_freeform:)
|
|
36
|
+
# @return [Hash, String] the payload {question:, options:, header:,
|
|
37
|
+
# multi_select:, allow_freeform:} (no header key when empty), or the
|
|
38
|
+
# plain-text error the model gets as the tool result
|
|
39
|
+
def self.validate(call)
|
|
40
|
+
question = strip_wire_tokens(call[:question] || call[:content])
|
|
41
|
+
return QUESTION_REQUIRED_ERROR if question.empty?
|
|
42
|
+
|
|
43
|
+
options = normalize_options_lenient(call[:options])
|
|
44
|
+
options = normalize_options_lenient(call[:content].to_s) if options.empty? && call[:content]
|
|
45
|
+
return options_count_error(options.size) if options.empty? || options.size > 8
|
|
46
|
+
|
|
47
|
+
header = strip_wire_tokens(call[:header])
|
|
48
|
+
{
|
|
38
49
|
question: question,
|
|
39
|
-
options:
|
|
40
|
-
header: header,
|
|
41
|
-
multi_select:
|
|
42
|
-
allow_freeform:
|
|
50
|
+
options: options,
|
|
51
|
+
header: (header unless header.empty?),
|
|
52
|
+
multi_select: to_bool(call[:multi_select]),
|
|
53
|
+
allow_freeform: to_bool(call[:allow_freeform])
|
|
43
54
|
}.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Booleans the way models send them (Gemma passes "true"/"false").
|
|
58
|
+
def self.to_bool(value)
|
|
59
|
+
return value if value == true || value == false
|
|
44
60
|
|
|
45
|
-
|
|
61
|
+
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.strip_wire_tokens(text)
|
|
65
|
+
text.to_s.gsub(/<\|[^|]*\|>/, "").gsub(/<\||\|>/, "").strip
|
|
46
66
|
end
|
|
47
67
|
|
|
48
68
|
# Normalize options param: accept Array or JSON string; strip, reject empty.
|
|
@@ -135,18 +155,6 @@ module Samagotchi
|
|
|
135
155
|
|
|
136
156
|
arr.map { |v| sanitize_option(v) }.reject { |v| v.nil? || v.empty? }
|
|
137
157
|
end
|
|
138
|
-
|
|
139
|
-
def self.to_bool(v)
|
|
140
|
-
return nil if v.nil?
|
|
141
|
-
return v if v == true || v == false
|
|
142
|
-
|
|
143
|
-
s = v.to_s.strip.downcase
|
|
144
|
-
return true if %w[1 true yes on].include?(s)
|
|
145
|
-
return false if %w[0 false no off].include?(s)
|
|
146
|
-
|
|
147
|
-
nil
|
|
148
|
-
end
|
|
149
|
-
private_class_method :to_bool
|
|
150
158
|
end
|
|
151
159
|
end
|
|
152
160
|
end
|
|
@@ -22,9 +22,25 @@ module Samagotchi
|
|
|
22
22
|
|
|
23
23
|
def self.call(content, path:, start_line: nil, end_line: nil)
|
|
24
24
|
path = ToolPath.normalize(path)
|
|
25
|
+
result = apply(content, path: path, start_line: start_line, end_line: end_line)
|
|
26
|
+
return result if result.is_a?(String)
|
|
27
|
+
|
|
28
|
+
updated, message = result
|
|
29
|
+
File.write(path, updated)
|
|
30
|
+
message
|
|
31
|
+
rescue => e
|
|
32
|
+
"Error: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The edit without the write: [updated, message] or "Error: …", with
|
|
36
|
+
# exactly the strings call returns. +read+ reads the file, so a dry run
|
|
37
|
+
# (EditPreview) can reuse a copy it already has; each mode keeps its own
|
|
38
|
+
# check order (exact: tags first; range: file not found first).
|
|
39
|
+
def self.apply(content, path:, start_line: nil, end_line: nil, read: ->(p) { File.read(p) })
|
|
40
|
+
path = ToolPath.normalize(path)
|
|
25
41
|
|
|
26
42
|
if range_requested?(start_line, end_line)
|
|
27
|
-
return
|
|
43
|
+
return apply_range_mode(content, path: path, start_line: start_line, end_line: end_line, read: read)
|
|
28
44
|
end
|
|
29
45
|
|
|
30
46
|
old_text = extract_tag(content, "old")
|
|
@@ -35,7 +51,7 @@ module Samagotchi
|
|
|
35
51
|
return "Error: <old> block is empty" if old_text.empty?
|
|
36
52
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
37
53
|
|
|
38
|
-
source =
|
|
54
|
+
source = read.(path)
|
|
39
55
|
count = count_occurrences(source, old_text)
|
|
40
56
|
|
|
41
57
|
return "Error: old text not found in #{path}" if count == 0
|
|
@@ -43,13 +59,12 @@ module Samagotchi
|
|
|
43
59
|
|
|
44
60
|
idx = source.index(old_text)
|
|
45
61
|
updated = source[0, idx] + new_text + source[idx + old_text.length..]
|
|
46
|
-
|
|
47
|
-
"Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"
|
|
62
|
+
[updated, "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"]
|
|
48
63
|
rescue => e
|
|
49
64
|
"Error: #{e.message}"
|
|
50
65
|
end
|
|
51
66
|
|
|
52
|
-
def self.
|
|
67
|
+
def self.apply_range_mode(content, path:, start_line:, end_line:, read:)
|
|
53
68
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
54
69
|
|
|
55
70
|
new_text = extract_tag(content, "new")
|
|
@@ -59,7 +74,7 @@ module Samagotchi
|
|
|
59
74
|
return start_num if start_num.is_a?(String)
|
|
60
75
|
return "Error: start_line must be provided for range edits" if start_num.nil?
|
|
61
76
|
|
|
62
|
-
source =
|
|
77
|
+
source = read.(path)
|
|
63
78
|
lines = source.lines
|
|
64
79
|
total_lines = lines.length
|
|
65
80
|
|
|
@@ -102,15 +117,14 @@ module Samagotchi
|
|
|
102
117
|
# first suffix line isn't concatenated onto the last replacement line.
|
|
103
118
|
normalized = (!new_text.empty? && !suffix.empty? && !new_text.end_with?("\n")) ? new_text + "\n" : new_text
|
|
104
119
|
updated = prefix + normalized + suffix
|
|
105
|
-
File.write(path, updated)
|
|
106
120
|
|
|
107
121
|
replaced_lines = (end_value - start_num) + 1
|
|
108
122
|
new_line_count = new_text.lines.length
|
|
109
|
-
"Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"
|
|
123
|
+
[updated, "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"]
|
|
110
124
|
rescue => e
|
|
111
125
|
"Error: #{e.message}"
|
|
112
126
|
end
|
|
113
|
-
private_class_method :
|
|
127
|
+
private_class_method :apply_range_mode
|
|
114
128
|
|
|
115
129
|
# Count non-overlapping literal occurrences of +needle+ in +haystack+.
|
|
116
130
|
def self.count_occurrences(haystack, needle)
|
|
@@ -195,8 +195,8 @@ module Samagotchi
|
|
|
195
195
|
def self.output_block(label, content)
|
|
196
196
|
return nil if content.nil? || content.empty?
|
|
197
197
|
|
|
198
|
-
truncate_at_bytes = OutputGuardrails.
|
|
199
|
-
preview_bytes = OutputGuardrails.
|
|
198
|
+
truncate_at_bytes = OutputGuardrails.config_positive_int("execute.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
|
|
199
|
+
preview_bytes = OutputGuardrails.config_positive_int("execute.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
|
|
200
200
|
bytes = content.bytesize
|
|
201
201
|
return "#{label}:\n#{content}" if bytes <= truncate_at_bytes
|
|
202
202
|
|
|
@@ -220,7 +220,7 @@ module Samagotchi
|
|
|
220
220
|
def self.telemetry_lines_for(content)
|
|
221
221
|
OutputGuardrails.telemetry_lines_for(
|
|
222
222
|
content: content,
|
|
223
|
-
|
|
223
|
+
threshold_key: "execute.telemetry_threshold_pct",
|
|
224
224
|
threshold_default: OutputGuardrails::DEFAULT_TELEMETRY_THRESHOLD_PCT,
|
|
225
225
|
token_key: "estimated_tokens_for_command_output",
|
|
226
226
|
pct_key: "estimated_window_pct_for_command_output"
|