samagotchi 0.21.0 → 0.23.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 +62 -1
- data/bin/chi +9 -4
- data/docs/architecture.md +3 -2
- data/docs/configuration.md +15 -4
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +11 -5
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +11 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +2 -2
- data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -6
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
- data/lib/samagotchi/bridge.rb +12 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
- data/lib/samagotchi/bridge_client.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +39 -29
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cancellation_controller.rb +1 -1
- data/lib/samagotchi/cli/command.rb +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +3 -5
- data/lib/samagotchi/commands/registry.rb +2 -2
- data/lib/samagotchi/config.rb +38 -14
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +6 -6
- data/lib/samagotchi/continue_offer.rb +44 -12
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -4
- data/lib/samagotchi/empty_answer_retry.rb +1 -1
- data/lib/samagotchi/engine.rb +49 -19
- data/lib/samagotchi/gem_update.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +5 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -2
- data/lib/samagotchi/guardrails/gate.rb +2 -2
- data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
- data/lib/samagotchi/hooks/loader.rb +18 -16
- data/lib/samagotchi/hooks/registry.rb +10 -2
- data/lib/samagotchi/host_registry.rb +1 -3
- data/lib/samagotchi/idle_recap.rb +18 -15
- data/lib/samagotchi/idle_reminders.rb +6 -3
- data/lib/samagotchi/image_store.rb +5 -2
- data/lib/samagotchi/kernel_loop.rb +31 -31
- data/lib/samagotchi/llm/chat_loop.rb +9 -6
- data/lib/samagotchi/llm/errors.rb +33 -2
- data/lib/samagotchi/llm/http.rb +12 -12
- data/lib/samagotchi/llm/openai_chat.rb +53 -30
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +9 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
- data/lib/samagotchi/memory_bundle/installer.rb +67 -57
- data/lib/samagotchi/memory_bundle/listing.rb +5 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +6 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
- data/lib/samagotchi/memory_bundle/source.rb +7 -3
- data/lib/samagotchi/memory_bundle/status.rb +3 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
- data/lib/samagotchi/memory_bundle/trash.rb +5 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/model_overlay.rb +3 -5
- data/lib/samagotchi/model_profile.rb +12 -8
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +18 -17
- data/lib/samagotchi/owner_lock.rb +2 -1
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/context.rb +1 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/plugin/service.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +1 -0
- data/lib/samagotchi/plugin_tasks.rb +1 -1
- data/lib/samagotchi/prompt.rb +68 -12
- data/lib/samagotchi/prompt_literal_guard.rb +1 -1
- data/lib/samagotchi/question_desk.rb +7 -13
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/relay_desk.rb +1 -1
- data/lib/samagotchi/reminder_store.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +12 -2
- data/lib/samagotchi/send_command.rb +4 -2
- data/lib/samagotchi/session.rb +2 -2
- data/lib/samagotchi/session_archive_command.rb +5 -1
- data/lib/samagotchi/session_commands.rb +1 -3
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +10 -10
- data/lib/samagotchi/session_observer.rb +3 -2
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/sessions_command.rb +14 -5
- data/lib/samagotchi/system_prompt.rb +19 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
- data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
- data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
- data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
- data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
- data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
- data/lib/samagotchi/terminal_ui.rb +9 -5
- data/lib/samagotchi/text_diff.rb +12 -12
- data/lib/samagotchi/thinking.rb +6 -2
- data/lib/samagotchi/thought_stream_splitter.rb +6 -3
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_call_parser.rb +13 -14
- data/lib/samagotchi/tool_declarations.rb +36 -35
- data/lib/samagotchi/tools/ask_user_question.rb +3 -4
- data/lib/samagotchi/tools/builtin_calls.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +0 -1
- data/lib/samagotchi/tools/edit.rb +14 -13
- data/lib/samagotchi/tools/execute.rb +3 -5
- data/lib/samagotchi/tools/list_reminders.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +21 -22
- data/lib/samagotchi/tools/read.rb +7 -7
- data/lib/samagotchi/tools/task_create.rb +1 -1
- data/lib/samagotchi/tools/task_get.rb +1 -1
- data/lib/samagotchi/tools/task_list.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +8 -9
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +3 -4
- data/lib/samagotchi/tools/web_fetch.rb +2 -4
- data/lib/samagotchi/tools/write.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -8
- data/lib/samagotchi/turn_note.rb +5 -1
- data/lib/samagotchi/turn_state.rb +22 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -2
- data/lib/samagotchi/web/app.rb +13 -6
- data/lib/samagotchi/web/markdown_renderer.rb +2 -1
- data/lib/samagotchi/web/qr.rb +7 -1
- data/lib/samagotchi/web/server.rb +6 -2
- data/lib/samagotchi/worker.rb +10 -2
- metadata +21 -20
|
@@ -90,7 +90,7 @@ module Samagotchi
|
|
|
90
90
|
# As typed: the Engine applies an alias where it resolves the model.
|
|
91
91
|
flag_model_name = model_name.to_s.strip.empty? ? nil : ModelProfile.required_model_name(model_name)
|
|
92
92
|
@effective_model_name = flag_model_name || @default_model_name
|
|
93
|
-
@host_registry
|
|
93
|
+
@host_registry = host_registry || Samagotchi::HostRegistry.new
|
|
94
94
|
# An injected client (specs) stands in for every host's client.
|
|
95
95
|
@host_registry.client_override = client if client
|
|
96
96
|
@client = @host_registry.resolve(@effective_model_name).client
|
|
@@ -101,6 +101,7 @@ module Samagotchi
|
|
|
101
101
|
if session_id && !Session.exist?(session_id)
|
|
102
102
|
raise SessionNotFound, "Session not found: #{session_id}"
|
|
103
103
|
end
|
|
104
|
+
|
|
104
105
|
claim_session!(session_id) if session_id
|
|
105
106
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
106
107
|
if @resume_session
|
|
@@ -146,7 +147,7 @@ module Samagotchi
|
|
|
146
147
|
@between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
|
|
147
148
|
turn_running: -> { @engine.turn_running? }, quiet: @non_interactive)
|
|
148
149
|
@render_event = ->(event) { handle_stream_event(event) }
|
|
149
|
-
@engine
|
|
150
|
+
@engine = Engine.new(
|
|
150
151
|
client: client,
|
|
151
152
|
host_registry: @host_registry,
|
|
152
153
|
profile: profile,
|
|
@@ -504,6 +505,7 @@ module Samagotchi
|
|
|
504
505
|
end
|
|
505
506
|
end
|
|
506
507
|
break if input.nil?
|
|
508
|
+
|
|
507
509
|
local = local_command(input)
|
|
508
510
|
if local == :exit
|
|
509
511
|
@delete_on_exit ||= SessionCommands.delete_on_exit?(input)
|
|
@@ -705,6 +707,7 @@ module Samagotchi
|
|
|
705
707
|
return
|
|
706
708
|
end
|
|
707
709
|
return if show_local_command(input)
|
|
710
|
+
|
|
708
711
|
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
709
712
|
hint = command_registry.unknown_command_hint(input)
|
|
710
713
|
return @surface.commit(hint) if hint
|
|
@@ -849,6 +852,7 @@ module Samagotchi
|
|
|
849
852
|
# #cancel_turn_from_prompt.
|
|
850
853
|
def poll_input_with_reminder_check(awaiting_continue:)
|
|
851
854
|
return :due unless @engine.due_reminder_names.empty?
|
|
855
|
+
|
|
852
856
|
# Specs and pipes: a plain blocking read.
|
|
853
857
|
unless @repl_input
|
|
854
858
|
@between_turns.flush_cards
|
|
@@ -972,10 +976,10 @@ module Samagotchi
|
|
|
972
976
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
973
977
|
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
974
978
|
# the last runs as the next turn. Reminder turns too.
|
|
975
|
-
def with_steering(&
|
|
979
|
+
def with_steering(&)
|
|
976
980
|
return yield unless @repl_input
|
|
977
981
|
|
|
978
|
-
@repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &
|
|
982
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain }, &)
|
|
979
983
|
end
|
|
980
984
|
|
|
981
985
|
# On the reader thread, from ReplInput: takes a line for the running turn.
|
|
@@ -994,6 +998,7 @@ module Samagotchi
|
|
|
994
998
|
# A command, never steering text (/archive waits, back in the prompt).
|
|
995
999
|
return command_during_turn(line) if local || command_registry.command?(line)
|
|
996
1000
|
return true if line.strip.empty?
|
|
1001
|
+
|
|
997
1002
|
# A typo of a command (/modle) is not steering text: say so, take it.
|
|
998
1003
|
if (hint = command_registry.unknown_command_hint(line))
|
|
999
1004
|
@surface.commit(hint)
|
|
@@ -1092,7 +1097,6 @@ module Samagotchi
|
|
|
1092
1097
|
ConfigFile.recap_config
|
|
1093
1098
|
end
|
|
1094
1099
|
|
|
1095
|
-
|
|
1096
1100
|
# Reset the Engine's shared inactivity clock when a prompt opens (a
|
|
1097
1101
|
# Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
|
|
1098
1102
|
# single seam the Engine-owned idle recap detector reads, so the idle clock
|
data/lib/samagotchi/text_diff.rb
CHANGED
|
@@ -46,8 +46,8 @@ module Samagotchi
|
|
|
46
46
|
a_mid = a[pre...(a.size - suf)]
|
|
47
47
|
b_mid = b[pre...(b.size - suf)]
|
|
48
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] }
|
|
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
51
|
(0...suf).each { |s| ops << [:eq, a.size - suf + s, b.size - suf + s] }
|
|
52
52
|
ops
|
|
53
53
|
end
|
|
@@ -64,13 +64,13 @@ module Samagotchi
|
|
|
64
64
|
n = a.size
|
|
65
65
|
m = b.size
|
|
66
66
|
off = n + m + 1
|
|
67
|
-
v = Array.new(2 * off + 1, 0)
|
|
67
|
+
v = Array.new((2 * off) + 1, 0)
|
|
68
68
|
trace = []
|
|
69
69
|
(0..(n + m)).each do |d|
|
|
70
70
|
return nil if d > max_d
|
|
71
71
|
|
|
72
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]
|
|
73
|
+
trace << v[off - d - 1, (2 * d) + 3]
|
|
74
74
|
k = -d
|
|
75
75
|
while k <= d
|
|
76
76
|
x = if k == -d || (k != d && v[off + k - 1] < v[off + k + 1])
|
|
@@ -98,8 +98,8 @@ module Samagotchi
|
|
|
98
98
|
vd = trace[d]
|
|
99
99
|
at = ->(k) { vd[k + d + 1] }
|
|
100
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)
|
|
101
|
+
prev_k = k == -d || (k != d && at.call(k - 1) < at.call(k + 1)) ? k + 1 : k - 1
|
|
102
|
+
prev_x = at.call(prev_k)
|
|
103
103
|
prev_y = prev_x - prev_k
|
|
104
104
|
while x > prev_x && y > prev_y
|
|
105
105
|
x -= 1
|
|
@@ -108,11 +108,11 @@ module Samagotchi
|
|
|
108
108
|
end
|
|
109
109
|
break if d.zero?
|
|
110
110
|
|
|
111
|
-
if x == prev_x
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
ops << if x == prev_x
|
|
112
|
+
[:add, nil, prev_y]
|
|
113
|
+
else
|
|
114
|
+
[:del, prev_x, nil]
|
|
115
|
+
end
|
|
116
116
|
x = prev_x
|
|
117
117
|
y = prev_y
|
|
118
118
|
end
|
|
@@ -144,7 +144,7 @@ module Samagotchi
|
|
|
144
144
|
line = t == :add ? b[j] : a[i]
|
|
145
145
|
mark = { eq: " ", del: "-", add: "+" }[t]
|
|
146
146
|
# Only the "\n": chomp("\n") would take a CRLF's "\r" too.
|
|
147
|
-
lines << mark + (line.end_with?("\n") ? line[0...-1] : line)
|
|
147
|
+
lines << (mark + (line.end_with?("\n") ? line[0...-1] : line))
|
|
148
148
|
lines << NO_NEWLINE unless line.end_with?("\n")
|
|
149
149
|
end
|
|
150
150
|
end
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -23,6 +23,10 @@ module Samagotchi
|
|
|
23
23
|
# An empty thought after the Qwen assistant cue: the model answers at once
|
|
24
24
|
# (what the server's own template does with enable_thinking false).
|
|
25
25
|
QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
|
|
26
|
+
# Gemma 4's empty thought channel after the model cue, as its chat
|
|
27
|
+
# template writes it with enable_thinking false (only where a model turn
|
|
28
|
+
# starts: Prompt.prefill_for).
|
|
29
|
+
GEMMA_EMPTY_THOUGHT = "<|channel>thought\n<channel|>"
|
|
26
30
|
|
|
27
31
|
# What a native prompt gets for a level: the text before the system
|
|
28
32
|
# prompt, the text after the assistant cue, and whether the level means
|
|
@@ -43,7 +47,7 @@ module Samagotchi
|
|
|
43
47
|
return text.to_sym if LEVELS.include?(text.to_sym)
|
|
44
48
|
|
|
45
49
|
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(
|
|
50
|
+
ConfigFile.warn_once "Warning: #{where}: thinking must be one of #{LEVELS.join(", ")}#{hint}; ignored"
|
|
47
51
|
nil
|
|
48
52
|
end
|
|
49
53
|
|
|
@@ -116,7 +120,7 @@ module Samagotchi
|
|
|
116
120
|
off = level == :off
|
|
117
121
|
honoured = !EFFORTS.include?(level)
|
|
118
122
|
case profile&.name
|
|
119
|
-
when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: "", honoured: honoured)
|
|
123
|
+
when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: off ? GEMMA_EMPTY_THOUGHT : "", honoured: honoured)
|
|
120
124
|
when "qwen36" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
|
|
121
125
|
else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
|
|
122
126
|
end
|
|
@@ -74,7 +74,7 @@ module Samagotchi
|
|
|
74
74
|
end
|
|
75
75
|
body = @carry[0...close_pos]
|
|
76
76
|
route_body(body, thinking)
|
|
77
|
-
@carry = @carry[close_pos + @active[:close].length..].to_s
|
|
77
|
+
@carry = @carry[(close_pos + @active[:close].length)..].to_s
|
|
78
78
|
@active = nil
|
|
79
79
|
@state = :normal
|
|
80
80
|
end
|
|
@@ -108,6 +108,7 @@ module Samagotchi
|
|
|
108
108
|
@blocks.each do |block|
|
|
109
109
|
pos = @carry.index(block[:open])
|
|
110
110
|
next if pos.nil?
|
|
111
|
+
|
|
111
112
|
best = { pos: pos, block: block } if best.nil? || pos < best[:pos]
|
|
112
113
|
end
|
|
113
114
|
best
|
|
@@ -118,7 +119,7 @@ module Samagotchi
|
|
|
118
119
|
def flush_normal(text)
|
|
119
120
|
partial = @blocks.map { |block| longest_suffix_prefix_of(@carry, block[:open]) }.max.to_i
|
|
120
121
|
text << @carry[0...(@carry.length - partial)]
|
|
121
|
-
@carry = @carry[@carry.length - partial..].to_s
|
|
122
|
+
@carry = @carry[(@carry.length - partial)..].to_s
|
|
122
123
|
end
|
|
123
124
|
|
|
124
125
|
# In-block state: emit the body seen so far to the active lane, retaining a
|
|
@@ -127,12 +128,13 @@ module Samagotchi
|
|
|
127
128
|
close = @active[:close]
|
|
128
129
|
partial = longest_suffix_prefix_of(@carry, close)
|
|
129
130
|
route_body(@carry[0...(@carry.length - partial)], thinking)
|
|
130
|
-
@carry = @carry[@carry.length - partial..].to_s
|
|
131
|
+
@carry = @carry[(@carry.length - partial)..].to_s
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
# Route a body fragment to the active block's lane (:thinking or :drop).
|
|
134
135
|
def route_body(body, thinking)
|
|
135
136
|
return if body.nil? || body.empty?
|
|
137
|
+
|
|
136
138
|
thinking << body if @active && @active[:lane] == :thinking
|
|
137
139
|
end
|
|
138
140
|
|
|
@@ -141,6 +143,7 @@ module Samagotchi
|
|
|
141
143
|
def longest_suffix_prefix_of(str, marker)
|
|
142
144
|
mlen = marker.length
|
|
143
145
|
return 0 if mlen.zero? || str.empty?
|
|
146
|
+
|
|
144
147
|
[str.length, mlen].min.downto(1) do |n|
|
|
145
148
|
return n if str[-n..] == marker[0, n]
|
|
146
149
|
end
|
|
@@ -25,7 +25,7 @@ module Samagotchi
|
|
|
25
25
|
result = text.to_s
|
|
26
26
|
while (open_pos = result.index(open))
|
|
27
27
|
close_pos = result.index(close, open_pos + open.length)
|
|
28
|
-
result = result[0...open_pos] + (close_pos ? result[close_pos + close.length..] : "")
|
|
28
|
+
result = result[0...open_pos] + (close_pos ? result[(close_pos + close.length)..] : "")
|
|
29
29
|
end
|
|
30
30
|
result.strip
|
|
31
31
|
end
|
|
@@ -37,7 +37,7 @@ module Samagotchi
|
|
|
37
37
|
class Gemma
|
|
38
38
|
# Digits after the first character: plugin tools may have them
|
|
39
39
|
# (Plugin::Api::TOOL_NAME).
|
|
40
|
-
TOOL_CALL_BODY_RE
|
|
40
|
+
TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
|
|
41
41
|
THOUGHT_CHANNEL_OPEN = ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN
|
|
42
42
|
THOUGHT_CHANNEL_CLOSE = ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE
|
|
43
43
|
CONTROL_TOKEN_START = "<|"
|
|
@@ -102,7 +102,7 @@ module Samagotchi
|
|
|
102
102
|
while (open_pos = result.index(THOUGHT_CHANNEL_OPEN))
|
|
103
103
|
close_pos = result.index(THOUGHT_CHANNEL_CLOSE, open_pos)
|
|
104
104
|
result = if close_pos
|
|
105
|
-
result[0...open_pos] + result[close_pos + THOUGHT_CHANNEL_CLOSE.length..]
|
|
105
|
+
result[0...open_pos] + result[(close_pos + THOUGHT_CHANNEL_CLOSE.length)..]
|
|
106
106
|
else
|
|
107
107
|
result[0...open_pos]
|
|
108
108
|
end
|
|
@@ -128,11 +128,10 @@ module Samagotchi
|
|
|
128
128
|
pos = start
|
|
129
129
|
while (p = text.index(CONTROL_TOKEN_START, pos))
|
|
130
130
|
# Skip over a <|"|> token entirely
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
end
|
|
131
|
+
return p unless text[p, @string_delim.length] == @string_delim
|
|
132
|
+
|
|
133
|
+
pos = p + @string_delim.length
|
|
134
|
+
|
|
136
135
|
end
|
|
137
136
|
nil
|
|
138
137
|
end
|
|
@@ -213,7 +212,7 @@ module Samagotchi
|
|
|
213
212
|
e = params_raw.index(@string_delim, s + @string_delim.length)
|
|
214
213
|
break unless e
|
|
215
214
|
|
|
216
|
-
arr << params_raw[s + @string_delim.length...e]
|
|
215
|
+
arr << params_raw[(s + @string_delim.length)...e]
|
|
217
216
|
pos = e + @string_delim.length
|
|
218
217
|
end
|
|
219
218
|
unless arr.empty?
|
|
@@ -334,13 +333,13 @@ module Samagotchi
|
|
|
334
333
|
|
|
335
334
|
def strip_thought(text)
|
|
336
335
|
# Remove all complete <think>...</think> blocks with the newlines after them
|
|
337
|
-
result = text.gsub(
|
|
336
|
+
result = text.gsub(%r{<think>.*?</think>\n*}m, "")
|
|
338
337
|
# Remove any stray opening tags
|
|
339
|
-
result = result.gsub(/<think>.*?(?=\n|$)/m,
|
|
338
|
+
result = result.gsub(/<think>.*?(?=\n|$)/m, "")
|
|
340
339
|
# Remove any orphaned closing tags (and the whitespace around them)
|
|
341
340
|
# The answer's own blank lines stay: they are its markdown paragraphs
|
|
342
341
|
# and lists (a chat host's answer is saved as stripped here).
|
|
343
|
-
result.gsub(
|
|
342
|
+
result.gsub(%r{^\s*</think>\s*}m, "")
|
|
344
343
|
end
|
|
345
344
|
|
|
346
345
|
def strip_tool_calls(text)
|
|
@@ -365,11 +364,11 @@ module Samagotchi
|
|
|
365
364
|
def qwen_params(body)
|
|
366
365
|
params = {}
|
|
367
366
|
|
|
368
|
-
body.scan(
|
|
367
|
+
body.scan(%r{<parameter=(\w+)>(.*?)</parameter>}m) do |key, value|
|
|
369
368
|
params[key.to_s.downcase] = trim_tag_newline(value)
|
|
370
369
|
end
|
|
371
370
|
|
|
372
|
-
body.scan(
|
|
371
|
+
body.scan(%r{<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>}m) do |key, value|
|
|
373
372
|
params[key.to_s.downcase.strip] = trim_tag_newline(value)
|
|
374
373
|
end
|
|
375
374
|
|
|
@@ -71,7 +71,7 @@ module Samagotchi
|
|
|
71
71
|
description: "Content to write to the file"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
|
-
required: [
|
|
74
|
+
required: %w[path content]
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
{
|
|
@@ -101,7 +101,7 @@ module Samagotchi
|
|
|
101
101
|
description: "Optional end line (1-based, inclusive) for range mode. Must be provided with start_line."
|
|
102
102
|
}
|
|
103
103
|
},
|
|
104
|
-
required: [
|
|
104
|
+
required: %w[path new_text]
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
107
|
{
|
|
@@ -148,7 +148,7 @@ module Samagotchi
|
|
|
148
148
|
description: "Set true to save this entry as a model-specific overlay for the current model only (<name>.<model>.md); it is auto-appended when the entry is read under that model and never listed in the index."
|
|
149
149
|
}
|
|
150
150
|
},
|
|
151
|
-
required: [
|
|
151
|
+
required: %w[name content scope]
|
|
152
152
|
}
|
|
153
153
|
},
|
|
154
154
|
{
|
|
@@ -268,7 +268,7 @@ module Samagotchi
|
|
|
268
268
|
description: "How often to remind (1-1440 minutes, i.e. up to 1 day)"
|
|
269
269
|
}
|
|
270
270
|
},
|
|
271
|
-
required: [
|
|
271
|
+
required: %w[name description interval_minutes]
|
|
272
272
|
}
|
|
273
273
|
},
|
|
274
274
|
{
|
|
@@ -321,7 +321,7 @@ module Samagotchi
|
|
|
321
321
|
description: "The note: what the other session should know (up to 16 KiB)"
|
|
322
322
|
}
|
|
323
323
|
},
|
|
324
|
-
required: [
|
|
324
|
+
required: %w[session text]
|
|
325
325
|
}
|
|
326
326
|
},
|
|
327
327
|
{
|
|
@@ -399,30 +399,15 @@ module Samagotchi
|
|
|
399
399
|
description: "Allow freeform/Other text alongside selection. Default false."
|
|
400
400
|
}
|
|
401
401
|
},
|
|
402
|
-
required: [
|
|
402
|
+
required: %w[question options]
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
].freeze
|
|
406
406
|
|
|
407
|
-
# Where Gemma's declaration text says something the schema doesn't:
|
|
408
|
-
# description text
|
|
407
|
+
# Where Gemma's declaration text says something the schema doesn't:
|
|
408
|
+
# extra description text.
|
|
409
409
|
GEMMA_PARAM_OVERRIDES = {
|
|
410
410
|
"edit" => { new_text: { description: "Replacement text (required)" } },
|
|
411
|
-
"task_wait" => {
|
|
412
|
-
timeout: { required: false },
|
|
413
|
-
tail_lines: { required: false },
|
|
414
|
-
done_pattern: { required: false }
|
|
415
|
-
},
|
|
416
|
-
"delegate" => {
|
|
417
|
-
model: { required: false },
|
|
418
|
-
session: { required: false },
|
|
419
|
-
wait: { required: false },
|
|
420
|
-
timeout: { required: false }
|
|
421
|
-
},
|
|
422
|
-
"delegate_result" => {
|
|
423
|
-
session: { required: false },
|
|
424
|
-
timeout: { required: false }
|
|
425
|
-
},
|
|
426
411
|
"ask_user_question" => {
|
|
427
412
|
options: { description: "2-8 answer options as strings (labels). Single/multi selection via multi_select flag." }
|
|
428
413
|
}
|
|
@@ -440,26 +425,42 @@ module Samagotchi
|
|
|
440
425
|
|
|
441
426
|
module_function
|
|
442
427
|
|
|
443
|
-
# Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool,
|
|
428
|
+
# Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool, back
|
|
429
|
+
# to back, as Gemma 4's chat template declares tools.
|
|
444
430
|
# @param schemas [Array<Hash>] a Tools::Registry's schemas
|
|
445
431
|
def gemma_declarations(schemas = TOOL_SCHEMAS)
|
|
446
|
-
schemas.map { |schema| gemma_declaration(schema) }.join
|
|
432
|
+
schemas.map { |schema| gemma_declaration(schema) }.join
|
|
447
433
|
end
|
|
448
434
|
|
|
435
|
+
# One declaration in the template's shape: properties sorted by name,
|
|
436
|
+
# types upper-cased, the required names as a list.
|
|
449
437
|
def gemma_declaration(schema)
|
|
450
438
|
q = GEMMA_QUOTE
|
|
451
|
-
|
|
439
|
+
parameters = schema[:parameters] || {}
|
|
452
440
|
overrides = GEMMA_PARAM_OVERRIDES.fetch(schema[:name], {})
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
description = override.fetch(:description, param[:description])
|
|
456
|
-
line = " #{name}:{type:#{q}#{param[:type]}#{q}, description:#{q}#{description}#{q}"
|
|
457
|
-
req = override.fetch(:required, required.include?(name.to_s) ? true : nil)
|
|
458
|
-
line += ", required:#{req}" unless req.nil?
|
|
459
|
-
"#{line}}"
|
|
441
|
+
properties = (parameters[:properties] || {}).to_h do |name, param|
|
|
442
|
+
[name.to_s, param.merge(overrides.fetch(name.to_sym, {}))]
|
|
460
443
|
end
|
|
461
|
-
|
|
462
|
-
|
|
444
|
+
required = Array(parameters[:required]).map(&:to_s)
|
|
445
|
+
fields = []
|
|
446
|
+
fields << "properties:{#{gemma_properties(properties)}}" unless properties.empty?
|
|
447
|
+
fields << "required:[#{required.map { |name| "#{q}#{name}#{q}" }.join(",")}]" unless required.empty?
|
|
448
|
+
fields << "type:#{q}#{(parameters[:type] || "object").to_s.upcase}#{q}"
|
|
449
|
+
"<|tool>declaration:#{schema[:name]}{description:#{q}#{schema[:description]}#{q},parameters:{#{fields.join(",")}}}<tool|>"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def gemma_properties(properties)
|
|
453
|
+
q = GEMMA_QUOTE
|
|
454
|
+
properties.sort.map do |name, param|
|
|
455
|
+
fields = []
|
|
456
|
+
fields << "description:#{q}#{param[:description]}#{q}" unless param[:description].to_s.empty?
|
|
457
|
+
items = param[:items]
|
|
458
|
+
if param[:type].to_s.casecmp?("array") && items.is_a?(Hash) && !items.empty?
|
|
459
|
+
fields << "items:{#{items.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}:#{q}#{value.to_s.upcase}#{q}" if key.to_s == "type" }.compact.join(",")}}"
|
|
460
|
+
end
|
|
461
|
+
fields << "type:#{q}#{param[:type].to_s.upcase}#{q}"
|
|
462
|
+
"#{name}:{#{fields.join(",")}}"
|
|
463
|
+
end.join(",")
|
|
463
464
|
end
|
|
464
465
|
|
|
465
466
|
# The schemas for the chat path's tools: array: +schemas+ with
|
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
|
|
57
57
|
# Booleans the way models send them (Gemma passes "true"/"false").
|
|
58
58
|
def self.to_bool(value)
|
|
59
|
-
return value if
|
|
59
|
+
return value if [true, false].include?(value)
|
|
60
60
|
|
|
61
61
|
%w[1 true yes on].include?(value.to_s.strip.downcase)
|
|
62
62
|
end
|
|
@@ -90,6 +90,7 @@ module Samagotchi
|
|
|
90
90
|
begin
|
|
91
91
|
parsed = JSON.parse(s)
|
|
92
92
|
return parsed.dup if parsed.is_a?(Array)
|
|
93
|
+
|
|
93
94
|
# If parsed is a String like "a, b", fall through to split
|
|
94
95
|
if parsed.is_a?(String)
|
|
95
96
|
s = parsed
|
|
@@ -124,8 +125,6 @@ module Samagotchi
|
|
|
124
125
|
parts = t.split(/[,;\n]+/).map(&:strip)
|
|
125
126
|
# Strip surrounding quotes from each part
|
|
126
127
|
parts.map { |p| p.gsub(/\A["'\s]+|["'\s]+\z/, "").strip }
|
|
127
|
-
else
|
|
128
|
-
return nil
|
|
129
128
|
end
|
|
130
129
|
end
|
|
131
130
|
|
|
@@ -143,7 +142,7 @@ module Samagotchi
|
|
|
143
142
|
s = s.strip
|
|
144
143
|
return nil if s.empty?
|
|
145
144
|
return nil if s.match?(/\A[\[\],\s]+\z/)
|
|
146
|
-
return nil if
|
|
145
|
+
return nil if ["]", "["].include?(s)
|
|
147
146
|
|
|
148
147
|
s
|
|
149
148
|
end
|
|
@@ -87,7 +87,7 @@ module Samagotchi
|
|
|
87
87
|
# one of its keys must be a property; the rest must all be.
|
|
88
88
|
unknown = ([path_key].compact + Array(o[:verbatim]) + Array(o[:also]) + o.fetch(:aliases, {}).keys) - properties
|
|
89
89
|
unknown << content_keys.join("/") unless content_keys.empty? || content_keys.intersect?(properties)
|
|
90
|
-
raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(
|
|
90
|
+
raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(", ")}" unless unknown.empty?
|
|
91
91
|
raise ArgumentError, "#{name}: a property would replace the call's name" if fields.include?("name")
|
|
92
92
|
|
|
93
93
|
Row.new(name: name, content_keys: content_keys, path_key: path_key, fields: fields,
|
|
@@ -155,7 +155,7 @@ module Samagotchi
|
|
|
155
155
|
# true unless told otherwise; text-based parsers hand strings over.
|
|
156
156
|
def self.parse_wait(value)
|
|
157
157
|
return true if value.nil? || value.to_s.strip.empty?
|
|
158
|
-
return value if
|
|
158
|
+
return value if [true, false].include?(value)
|
|
159
159
|
|
|
160
160
|
!%w[false 0 no off].include?(value.to_s.strip.downcase)
|
|
161
161
|
end
|
|
@@ -137,7 +137,7 @@ module Samagotchi
|
|
|
137
137
|
|
|
138
138
|
# execute: git push origin main
|
|
139
139
|
def what_text(facts)
|
|
140
|
-
facts = facts.is_a?(Hash)
|
|
140
|
+
facts = {} unless facts.is_a?(Hash)
|
|
141
141
|
tool = facts[:label] || facts[:tool] || "tool"
|
|
142
142
|
what = facts[:command] || Array(facts[:paths]).join(", ")
|
|
143
143
|
what = facts[:args].to_s if what.to_s.empty?
|
|
@@ -27,7 +27,7 @@ module Samagotchi
|
|
|
27
27
|
updated, message = result
|
|
28
28
|
File.write(path, updated)
|
|
29
29
|
message
|
|
30
|
-
rescue => e
|
|
30
|
+
rescue StandardError => e
|
|
31
31
|
"Error: #{e.message}"
|
|
32
32
|
end
|
|
33
33
|
|
|
@@ -49,16 +49,16 @@ module Samagotchi
|
|
|
49
49
|
old_text = old_text.to_s
|
|
50
50
|
new_text = new_text.to_s
|
|
51
51
|
|
|
52
|
-
source = read.(path)
|
|
52
|
+
source = read.call(path)
|
|
53
53
|
count = count_occurrences(source, old_text)
|
|
54
54
|
|
|
55
55
|
return "Error: old text not found in #{path}" if count == 0
|
|
56
56
|
return "Error: old text matches #{count} times in #{path}; make it unique" if count > 1
|
|
57
57
|
|
|
58
58
|
idx = source.index(old_text)
|
|
59
|
-
updated = source[0, idx] + new_text + source[idx + old_text.length..]
|
|
59
|
+
updated = source[0, idx] + new_text + source[(idx + old_text.length)..]
|
|
60
60
|
[updated, "Edited #{path}: replaced #{old_text.bytesize} bytes with #{new_text.bytesize} bytes"]
|
|
61
|
-
rescue => e
|
|
61
|
+
rescue StandardError => e
|
|
62
62
|
"Error: #{e.message}"
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -77,7 +77,7 @@ module Samagotchi
|
|
|
77
77
|
# guessed start would silently overwrite the top of the file.
|
|
78
78
|
return START_LINE_REQUIRED if start_num.nil?
|
|
79
79
|
|
|
80
|
-
source = read.(path)
|
|
80
|
+
source = read.call(path)
|
|
81
81
|
lines = source.lines
|
|
82
82
|
total_lines = lines.length
|
|
83
83
|
|
|
@@ -94,12 +94,11 @@ module Samagotchi
|
|
|
94
94
|
end_value = nil
|
|
95
95
|
if end_provided
|
|
96
96
|
if end_num > total_lines
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end
|
|
97
|
+
return "Error: range out of bounds for #{path}: file has #{total_lines} lines" unless OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_ALLOW_OOR_END", default: true)
|
|
98
|
+
|
|
99
|
+
clamp_note = " (end_line #{end_num} exceeds #{total_lines} lines; clamped to line #{total_lines})"
|
|
100
|
+
end_value = total_lines
|
|
101
|
+
|
|
103
102
|
else
|
|
104
103
|
end_value = end_num
|
|
105
104
|
end
|
|
@@ -107,6 +106,7 @@ module Samagotchi
|
|
|
107
106
|
unless OutputGuardrails.env_bool("SAMAGOTCHI_EDIT_END_OPTIONAL", default: true)
|
|
108
107
|
return "Error: start_line and end_line must both be provided for range edits"
|
|
109
108
|
end
|
|
109
|
+
|
|
110
110
|
end_value = total_lines
|
|
111
111
|
end
|
|
112
112
|
|
|
@@ -118,13 +118,13 @@ module Samagotchi
|
|
|
118
118
|
suffix = lines[end_value..]&.join.to_s
|
|
119
119
|
# Ensure new_text ends with a newline when a suffix follows so that the
|
|
120
120
|
# first suffix line isn't concatenated onto the last replacement line.
|
|
121
|
-
normalized =
|
|
121
|
+
normalized = !new_text.empty? && !suffix.empty? && !new_text.end_with?("\n") ? new_text + "\n" : new_text
|
|
122
122
|
updated = prefix + normalized + suffix
|
|
123
123
|
|
|
124
124
|
replaced_lines = (end_value - start_num) + 1
|
|
125
125
|
new_line_count = new_text.lines.length
|
|
126
126
|
[updated, "Edited #{path}: replaced lines #{start_num}-#{end_value} (#{replaced_lines} lines) with #{new_line_count} lines#{clamp_note}"]
|
|
127
|
-
rescue => e
|
|
127
|
+
rescue StandardError => e
|
|
128
128
|
"Error: #{e.message}"
|
|
129
129
|
end
|
|
130
130
|
private_class_method :apply_range_mode
|
|
@@ -136,6 +136,7 @@ module Samagotchi
|
|
|
136
136
|
while (i = haystack.index(needle, pos))
|
|
137
137
|
count += 1
|
|
138
138
|
break if count > 1 # early exit — we only care whether it's 0, 1, or >1
|
|
139
|
+
|
|
139
140
|
pos = i + needle.length
|
|
140
141
|
end
|
|
141
142
|
count
|
|
@@ -67,7 +67,7 @@ module Samagotchi
|
|
|
67
67
|
stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
68
68
|
rescue CommandCancelled => e
|
|
69
69
|
stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
70
|
-
rescue => e
|
|
70
|
+
rescue StandardError => e
|
|
71
71
|
"Error: #{e.message}"
|
|
72
72
|
end
|
|
73
73
|
|
|
@@ -186,7 +186,7 @@ module Samagotchi
|
|
|
186
186
|
@thread = Thread.new do
|
|
187
187
|
Thread.current.report_on_exception = false
|
|
188
188
|
loop { @buffer << io.readpartial(READ_CHUNK_BYTES) }
|
|
189
|
-
rescue EOFError
|
|
189
|
+
rescue IOError # EOFError is one
|
|
190
190
|
nil
|
|
191
191
|
end
|
|
192
192
|
end
|
|
@@ -221,9 +221,7 @@ module Samagotchi
|
|
|
221
221
|
signal_process(pid, "TERM")
|
|
222
222
|
|
|
223
223
|
deadline = monotonic_time + STOP_GRACE_SEC
|
|
224
|
-
while process_alive?(pid) && monotonic_time < deadline
|
|
225
|
-
sleep(STOP_POLL_INTERVAL_SEC)
|
|
226
|
-
end
|
|
224
|
+
sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
|
|
227
225
|
|
|
228
226
|
signal_process(pid, "KILL") if process_alive?(pid)
|
|
229
227
|
end
|