samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- 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 +180 -25
- 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/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- 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 +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -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,115 @@
|
|
|
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
|
+
# The native prompt's switch for +level+ under +profile+.
|
|
103
|
+
# @param profile [ModelProfile]
|
|
104
|
+
# @return [Native]
|
|
105
|
+
def native(level, profile)
|
|
106
|
+
off = level == :off
|
|
107
|
+
honoured = !EFFORTS.include?(level)
|
|
108
|
+
case profile&.name
|
|
109
|
+
when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
|
|
110
|
+
when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
|
|
111
|
+
else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -65,11 +65,13 @@ module Samagotchi
|
|
|
65
65
|
|
|
66
66
|
# "error" for an "Error:" result, and for an execute whose command
|
|
67
67
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
68
|
-
# signal), so the turn tally's "(N failed)" counts it.
|
|
68
|
+
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
69
|
+
# task_wait the user's Stop ended: the task itself runs on.
|
|
69
70
|
def tool_activity_status(result, tool_name = nil)
|
|
70
71
|
text = result.to_s
|
|
71
72
|
return "error" if text.start_with?("Error:")
|
|
72
73
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
74
|
+
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
|
|
73
75
|
|
|
74
76
|
"ok"
|
|
75
77
|
end
|
|
@@ -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.
|
|
@@ -45,7 +53,9 @@ module Samagotchi
|
|
|
45
53
|
# The ask comes after tool_call_started: the UI shows the tool line,
|
|
46
54
|
# then the approval under it.
|
|
47
55
|
settle_ask(verdict) if verdict.ask?
|
|
56
|
+
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
48
57
|
result = verdict.deny? ? denied(call, verdict) : dispatch(call)
|
|
58
|
+
diff = file_change(call, before)
|
|
49
59
|
result = approved(result, verdict) if verdict.allow? && verdict.decided_by
|
|
50
60
|
result, images = attach_images(call, result) if result[:images]
|
|
51
61
|
|
|
@@ -61,17 +71,40 @@ module Samagotchi
|
|
|
61
71
|
completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
62
72
|
tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
|
|
63
73
|
completed[:images] = images if images&.any?
|
|
74
|
+
completed[:diff] = diff if diff
|
|
64
75
|
emit(on_stream_event, completed)
|
|
65
76
|
|
|
66
77
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
67
78
|
run[:images] = images if images&.any?
|
|
68
79
|
run[:shown_params] = params if params && plugin_tool?(call[:name])
|
|
69
80
|
run[:shown_label] = label if label
|
|
81
|
+
run[:diff] = diff if diff
|
|
70
82
|
run
|
|
71
83
|
end
|
|
72
84
|
|
|
73
85
|
private
|
|
74
86
|
|
|
87
|
+
# edit/write only: the file just before the call runs. The row diffs it
|
|
88
|
+
# with the file after, whatever the result says, so an edit that fails
|
|
89
|
+
# after writing still shows its change and one that wrote nothing shows
|
|
90
|
+
# none.
|
|
91
|
+
def file_before(call)
|
|
92
|
+
return NOT_AN_EDIT unless EditPreview.tool?(call[:name])
|
|
93
|
+
|
|
94
|
+
EditPreview.snapshot(Tools::ToolPath.normalize(call[:path]))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
NOT_AN_EDIT
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def file_change(call, before)
|
|
100
|
+
return nil if before.equal?(NOT_AN_EDIT)
|
|
101
|
+
|
|
102
|
+
EditPreview.change(before, EditPreview.snapshot(Tools::ToolPath.normalize(call[:path])))
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Log.warn(:turn, "edit_diff_failed", tool: call[:name], error: "#{e.class}: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
75
108
|
# A tool's output can hold bytes that aren't UTF-8 (`printf '\xff'`, a
|
|
76
109
|
# binary file). They become "?" here, before the output reaches the
|
|
77
110
|
# 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
|
|
@@ -68,12 +68,16 @@ module Samagotchi
|
|
|
68
68
|
Edit::NAME => lambda do |call, _kctx|
|
|
69
69
|
Edit.call(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
|
|
70
70
|
end,
|
|
71
|
-
TaskCreate::NAME =>
|
|
72
|
-
|
|
71
|
+
TaskCreate::NAME => lambda do |call, kctx|
|
|
72
|
+
next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
|
|
73
|
+
|
|
74
|
+
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env])
|
|
75
|
+
end,
|
|
76
|
+
TaskWait::NAME => lambda do |call, kctx|
|
|
73
77
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
74
|
-
done_pattern: call[:done_pattern])
|
|
78
|
+
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
|
|
75
79
|
end,
|
|
76
|
-
Execute::NAME => ->(call,
|
|
80
|
+
Execute::NAME => ->(call, kctx) { Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx)) },
|
|
77
81
|
RegisterReminder::NAME => lambda do |call, kctx|
|
|
78
82
|
RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
|
|
79
83
|
interval_minutes: call[:interval_minutes])
|
|
@@ -94,6 +98,13 @@ module Samagotchi
|
|
|
94
98
|
|
|
95
99
|
module_function
|
|
96
100
|
|
|
101
|
+
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
102
|
+
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
103
|
+
def cancelled_proc(kctx)
|
|
104
|
+
peers = kctx.peers
|
|
105
|
+
-> { peers.respond_to?(:cancelled?) && peers.cancelled? }
|
|
106
|
+
end
|
|
107
|
+
|
|
97
108
|
# @return [Registry] a new registry with the built-ins, in
|
|
98
109
|
# TOOL_SCHEMAS order (an Engine's own, which bundles add to)
|
|
99
110
|
def registry
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../session"
|
|
4
|
+
require_relative "../reply_wait"
|
|
4
5
|
require_relative "output_guardrails"
|
|
5
6
|
require_relative "peers"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
|
-
# Loaded on first use: session_manager requires terminal_ui, which
|
|
9
|
-
# requires KernelLoop and so these tools (a require cycle otherwise).
|
|
10
|
-
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
11
|
-
|
|
12
9
|
module Tools
|
|
13
10
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
14
|
-
# delegate_result
|
|
15
|
-
# (the
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# worker respawn loses them, which only repeats the newest reply once.
|
|
11
|
+
# delegate_result: ReplyWait in the tools' words, with a cursor per
|
|
12
|
+
# child (the newest reply this parent was already given). The cursors
|
|
13
|
+
# live in this process (a worker runs one session), keyed by parent and
|
|
14
|
+
# child; a worker respawn loses them, which only repeats the newest reply
|
|
15
|
+
# once.
|
|
20
16
|
module DelegateWait
|
|
21
17
|
TIMEOUT_DEFAULT = 600
|
|
22
18
|
POLL_INTERVAL = 0.5
|
|
@@ -40,70 +36,34 @@ module Samagotchi
|
|
|
40
36
|
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
|
|
41
37
|
sd = peers.state_dir || Session.default_state_dir
|
|
42
38
|
key = [peers.session_id, child_id]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (pending = session.pending_question)
|
|
63
|
-
return result(child_id, "waiting_for_answer", waiting_text(child_id, pending))
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
if peers.respond_to?(:cancelled?) && peers.cancelled?
|
|
67
|
-
return result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
if session.status == Session::STATUS_RUNNING
|
|
71
|
-
seen_running = true
|
|
72
|
-
elsif seen_running
|
|
73
|
-
# It ran and is idle again with no new reply: canceled, failed or
|
|
74
|
-
# empty. Before it was ever seen running, idle means a
|
|
75
|
-
# file-delivered follow-up its worker has not picked up yet.
|
|
76
|
-
return result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
return timeout_result(child_id, timeout) if monotonic > deadline
|
|
80
|
-
|
|
81
|
-
sleep(poll_interval)
|
|
39
|
+
cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
|
|
40
|
+
wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
|
|
41
|
+
poll_interval: poll_interval, cancelled: cancelled)
|
|
42
|
+
case wait.status
|
|
43
|
+
when :done
|
|
44
|
+
seen[key] = wait.file
|
|
45
|
+
reply_result(child_id, wait.text)
|
|
46
|
+
when :error
|
|
47
|
+
result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
|
|
48
|
+
when :stopped
|
|
49
|
+
result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
50
|
+
when :waiting_for_answer
|
|
51
|
+
result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
|
|
52
|
+
when :canceled
|
|
53
|
+
result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
54
|
+
when :no_reply
|
|
55
|
+
result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
|
|
56
|
+
else
|
|
57
|
+
timeout_result(child_id, timeout)
|
|
82
58
|
end
|
|
83
59
|
rescue ArgumentError => e
|
|
84
60
|
"Error: #{e.message}"
|
|
85
61
|
end
|
|
86
62
|
|
|
87
|
-
# @return [String, nil] the newest reply filename (sortable timestamps)
|
|
88
|
-
def newest_reply(child_id, state_dir:)
|
|
89
|
-
dir = reply_dir(child_id, state_dir: state_dir)
|
|
90
|
-
return nil unless Dir.exist?(dir)
|
|
91
|
-
|
|
92
|
-
Dir.children(dir).select { |f| f.end_with?(".txt") }.max
|
|
93
|
-
end
|
|
94
|
-
|
|
95
63
|
# Point the cursor at the newest reply now, so only a later one counts
|
|
96
64
|
# (a follow-up sent to a child that already answered).
|
|
97
65
|
def mark_seen(parent_id, child_id, state_dir:)
|
|
98
|
-
seen[[parent_id, child_id]] = newest_reply(child_id, state_dir: state_dir)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def reply_dir(child_id, state_dir:)
|
|
102
|
-
File.join(Session.session_dir(child_id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def newer?(file, cursor)
|
|
106
|
-
cursor.nil? || file > cursor
|
|
66
|
+
seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
|
|
107
67
|
end
|
|
108
68
|
|
|
109
69
|
def reply_result(child_id, text)
|
|
@@ -145,9 +105,6 @@ module Samagotchi
|
|
|
145
105
|
].join("\n")
|
|
146
106
|
end
|
|
147
107
|
|
|
148
|
-
def monotonic
|
|
149
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
150
|
-
end
|
|
151
108
|
end
|
|
152
109
|
end
|
|
153
110
|
end
|