samagotchi 0.47.0 → 0.49.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 +50 -1
- data/docs/configuration.md +1 -1
- data/docs/context.md +9 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +39 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +249 -0
- data/lib/samagotchi/child_reports.rb +32 -8
- data/lib/samagotchi/engine.rb +2 -1
- data/lib/samagotchi/model_profile.rb +11 -4
- data/lib/samagotchi/plugin/context.rb +13 -1
- data/lib/samagotchi/prompt.rb +26 -11
- data/lib/samagotchi/prompt_literal_guard.rb +4 -1
- data/lib/samagotchi/send_command.rb +6 -1
- data/lib/samagotchi/session.rb +35 -0
- data/lib/samagotchi/session_manager.rb +131 -53
- data/lib/samagotchi/terminal_ui/question_prompt.rb +7 -0
- data/lib/samagotchi/terminal_ui.rb +1 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +31 -3
- data/lib/samagotchi/tools/task_runtime.rb +4 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +3 -3
- data/lib/samagotchi/web/app.rb +4 -2
- data/lib/samagotchi/web/public/app.js +13 -7
- data/lib/samagotchi/web/public/chains.js +17 -4
- data/lib/samagotchi/worker.rb +12 -1
- metadata +2 -1
|
@@ -57,16 +57,21 @@ module Samagotchi
|
|
|
57
57
|
# @param wake [#call, nil] (parent id) wakes a parent with no worker;
|
|
58
58
|
# nil: SessionManager.wake_for_report on a thread
|
|
59
59
|
# @return [String, nil] the ring file, nil when none was written
|
|
60
|
+
# The parent is read fresh: a continue may have moved the child to the
|
|
61
|
+
# chain's new link (Session::PARENT_FILE) since its worker loaded it.
|
|
60
62
|
def ring(child, why:, state_dir:, wake: nil)
|
|
61
63
|
return nil if mode == "off"
|
|
62
|
-
return nil unless child&.delegate?
|
|
64
|
+
return nil unless child&.delegate?
|
|
65
|
+
|
|
66
|
+
parent_id = Session.parent_override(child.id, state_dir: state_dir) || child.parent_id
|
|
67
|
+
return nil unless parent_id
|
|
63
68
|
# A deleted parent gets no orphan dir.
|
|
64
|
-
return nil unless Session.exist?(
|
|
69
|
+
return nil unless Session.exist?(parent_id, state_dir: state_dir)
|
|
65
70
|
|
|
66
|
-
parent_dir = Session.session_dir(
|
|
71
|
+
parent_dir = Session.session_dir(parent_id, state_dir: state_dir)
|
|
67
72
|
path = SessionInbox.write_ring(parent_dir, child_id: child.id, why: why)
|
|
68
|
-
Log.info(:worker, "delegate_rang", parent:
|
|
69
|
-
wake_parent(
|
|
73
|
+
Log.info(:worker, "delegate_rang", parent: parent_id[0, 8], why: why)
|
|
74
|
+
wake_parent(parent_id, state_dir: state_dir, wake: wake) if mode == "wake"
|
|
70
75
|
path
|
|
71
76
|
rescue StandardError => e
|
|
72
77
|
Log.warn(:worker, "delegate_ring_failed", parent: child&.parent_id.to_s[0, 8], error: e.class.name, msg: e.message)
|
|
@@ -129,10 +134,14 @@ module Samagotchi
|
|
|
129
134
|
def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
|
|
130
135
|
end
|
|
131
136
|
|
|
132
|
-
|
|
137
|
+
# @param predecessor [String, nil] the link this session continues
|
|
138
|
+
# (Session#continues): a child a continue moved here may have rung it
|
|
139
|
+
# just as it moved (#ring_files)
|
|
140
|
+
def initialize(session_id:, state_dir:, predecessor: nil)
|
|
133
141
|
@session_id = session_id
|
|
134
142
|
@state_dir = state_dir
|
|
135
143
|
@session_dir = Session.session_dir(session_id, state_dir: state_dir)
|
|
144
|
+
@predecessor_dir = Session.valid_id?(predecessor) ? Session.session_dir(predecessor, state_dir: state_dir) : nil
|
|
136
145
|
# child id → the Report this turn took for it (rings merged).
|
|
137
146
|
@taken = {}
|
|
138
147
|
end
|
|
@@ -142,7 +151,7 @@ module Samagotchi
|
|
|
142
151
|
return false if ChildRing.mode == "off"
|
|
143
152
|
|
|
144
153
|
taken = taken_rings
|
|
145
|
-
|
|
154
|
+
ring_files.any? { |file| !taken.include?(file) }
|
|
146
155
|
end
|
|
147
156
|
|
|
148
157
|
# The reports for rings this turn hasn't taken yet, recorded as taken.
|
|
@@ -153,7 +162,7 @@ module Samagotchi
|
|
|
153
162
|
return [] if ChildRing.mode == "off"
|
|
154
163
|
|
|
155
164
|
taken = taken_rings
|
|
156
|
-
fresh =
|
|
165
|
+
fresh = ring_files.reject { |file| taken.include?(file) }
|
|
157
166
|
by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
|
|
158
167
|
by_child.filter_map do |child_id, rings|
|
|
159
168
|
report = child_id && report_for(child_id, rings)
|
|
@@ -193,6 +202,21 @@ module Samagotchi
|
|
|
193
202
|
@taken.values.flat_map(&:rings)
|
|
194
203
|
end
|
|
195
204
|
|
|
205
|
+
# The rings in children/, and the predecessor's rings from children a
|
|
206
|
+
# continue moved here (their parent.json names this session): a ring
|
|
207
|
+
# its child wrote as the move went on. Another child's ring there is
|
|
208
|
+
# the predecessor's, left alone.
|
|
209
|
+
def ring_files
|
|
210
|
+
own = SessionInbox.find_ring_files(@session_dir)
|
|
211
|
+
return own unless @predecessor_dir
|
|
212
|
+
|
|
213
|
+
strays = SessionInbox.find_ring_files(@predecessor_dir).select do |file|
|
|
214
|
+
child_id = SessionInbox.read_ring(file)&.dig(:child_id)
|
|
215
|
+
Session.valid_id?(child_id) && Session.parent_override(child_id, state_dir: @state_dir) == @session_id
|
|
216
|
+
end
|
|
217
|
+
own + strays
|
|
218
|
+
end
|
|
219
|
+
|
|
196
220
|
# A second ring for a child taken earlier in this turn: read from where
|
|
197
221
|
# the first report left the cursor, keeping the first one's +before+.
|
|
198
222
|
def merge_taken(earlier, report)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -2470,7 +2470,8 @@ module Samagotchi
|
|
|
2470
2470
|
model_key: -> { @model_key },
|
|
2471
2471
|
state_dir: -> { session_state_dir },
|
|
2472
2472
|
scratch: -> { @scratch },
|
|
2473
|
-
llm_context: -> { @session&.llm_context }
|
|
2473
|
+
llm_context: -> { @session&.llm_context },
|
|
2474
|
+
frontend: -> { interface }
|
|
2474
2475
|
)
|
|
2475
2476
|
end
|
|
2476
2477
|
|
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
:system_prefix, :user_prefix, :assistant_prefix,
|
|
41
41
|
:model_prefix,
|
|
42
42
|
:stop_sequences, :tool_decl_format,
|
|
43
|
-
:image_template
|
|
43
|
+
:image_template, :image_check_token
|
|
44
44
|
|
|
45
45
|
def initialize(config)
|
|
46
46
|
@name = config[:name]
|
|
@@ -65,10 +65,12 @@ module Samagotchi
|
|
|
65
65
|
# How the chat template wraps an image's media marker ("%{marker}"),
|
|
66
66
|
# or nil when chi doesn't know it yet (native images are refused).
|
|
67
67
|
@image_template = config[:image_template]
|
|
68
|
+
# A token the server's chat template must contain for images to be
|
|
69
|
+
# sent (VisionSupport); defaults to the template's opening token.
|
|
70
|
+
@image_check_token = config[:image_check_token] || image_open_token
|
|
68
71
|
end
|
|
69
72
|
|
|
70
|
-
# The token the image template opens with, e.g. "<|vision_start|>"
|
|
71
|
-
# server's chat template must use it too (VisionSupport).
|
|
73
|
+
# The token the image template opens with, e.g. "<|vision_start|>".
|
|
72
74
|
def image_open_token
|
|
73
75
|
image_template&.split("%{marker}")&.first.to_s
|
|
74
76
|
end
|
|
@@ -92,7 +94,12 @@ module Samagotchi
|
|
|
92
94
|
assistant_prefix: "",
|
|
93
95
|
model_prefix: "",
|
|
94
96
|
stop_sequences: ["<turn|>", "<|tool_response>"],
|
|
95
|
-
tool_decl_format: :gemma4
|
|
97
|
+
tool_decl_format: :gemma4,
|
|
98
|
+
# The bare marker: llama.cpp's mtmd wraps Gemma 4 images in
|
|
99
|
+
# <|image>…<image|> itself, as its chat path does (V13 spike). The
|
|
100
|
+
# template writes <|image|> for an image part; Gemma 3's doesn't.
|
|
101
|
+
image_template: "%{marker}",
|
|
102
|
+
image_check_token: "<|image|>"
|
|
96
103
|
)
|
|
97
104
|
end
|
|
98
105
|
|
|
@@ -25,9 +25,11 @@ module Samagotchi
|
|
|
25
25
|
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
26
26
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
27
27
|
# (reason, label), each true when it acted on a running turn.
|
|
28
|
+
# +frontend+ returns the Engine's interface (:repl, :worker,
|
|
29
|
+
# :non_interactive), which ctx.frontend names for plugins.
|
|
28
30
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
29
31
|
:ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
|
|
30
|
-
keyword_init: true)
|
|
32
|
+
:frontend, keyword_init: true)
|
|
31
33
|
|
|
32
34
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
33
35
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -103,6 +105,16 @@ module Samagotchi
|
|
|
103
105
|
# Whether the session is a `chi scratch` one (deleted when it ends).
|
|
104
106
|
def scratch? = !!@host.scratch&.call
|
|
105
107
|
|
|
108
|
+
# ctx.frontend's values, by the Engine's interface.
|
|
109
|
+
FRONTENDS = { repl: :repl, non_interactive: :one_shot, worker: :worker }.freeze
|
|
110
|
+
|
|
111
|
+
# What runs the session, read now (the host sets it after the plugins
|
|
112
|
+
# load): :repl (the REPL, -p without --non-interactive), :one_shot
|
|
113
|
+
# (-p --non-interactive: one turn, then exit) or :worker (a session
|
|
114
|
+
# worker: the web, an attached TUI). A host without one is :worker.
|
|
115
|
+
# @return [Symbol]
|
|
116
|
+
def frontend = FRONTENDS.fetch(@host.frontend&.call, :worker)
|
|
117
|
+
|
|
106
118
|
# Whether the session is a delegate child (Session#delegate?): a task
|
|
107
119
|
# another session handed over, not the user's own.
|
|
108
120
|
def delegate? = !!saved_session&.delegate?
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -59,30 +59,45 @@ module Samagotchi
|
|
|
59
59
|
[text + prefill_for(messages, profile, prefill), images]
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
# A message's image lines: +text+ holds them all in order (what goes
|
|
63
|
+
# inside the message); +placeholders+ and +markers+ split the lines of
|
|
64
|
+
# images not sent from the sent ones' media markers, for Gemma's tool
|
|
65
|
+
# response, whose markers go after the block.
|
|
66
|
+
ImageLines = Data.define(:lines) do
|
|
67
|
+
def text = join(lines)
|
|
68
|
+
def placeholders = join(lines.reject(&:last))
|
|
69
|
+
def markers = join(lines.select(&:last))
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def join(subset) = subset.empty? ? "" : "\n#{subset.map(&:first).join("\n")}"
|
|
74
|
+
end
|
|
64
75
|
|
|
76
|
+
def self.image_lines(items, profile, images)
|
|
65
77
|
lines = items.each_with_index.map do |item, index|
|
|
66
|
-
next item.placeholder unless item.sent?
|
|
67
|
-
next ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE) unless profile.image_template
|
|
78
|
+
next [item.placeholder, false] unless item.sent?
|
|
79
|
+
next [ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE), false] unless profile.image_template
|
|
68
80
|
|
|
69
81
|
images << item.data
|
|
70
|
-
"[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
|
|
71
|
-
|
|
82
|
+
["[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
|
|
83
|
+
"#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}", true]
|
|
72
84
|
end
|
|
73
|
-
|
|
85
|
+
ImageLines.new(lines: lines)
|
|
74
86
|
end
|
|
75
87
|
|
|
76
88
|
def self.format_with_turn_markers(messages, profile, suffixes)
|
|
77
89
|
last_user = messages.rindex { |m| m[:role] == "user" } || -1
|
|
78
90
|
parts = messages.each_with_index.map do |m, index|
|
|
79
|
-
|
|
91
|
+
lines = suffixes[index]
|
|
92
|
+
content = prompt_content_for(m, profile) + (m[:role] == "tool_response" ? lines.placeholders : lines.text)
|
|
80
93
|
previous = index.positive? ? messages[index - 1][:role] : nil
|
|
81
94
|
following = messages[index + 1]&.dig(:role)
|
|
82
95
|
case m[:role]
|
|
83
96
|
when "tool_response"
|
|
84
|
-
# Inside the model's turn; closed when another role follows.
|
|
85
|
-
|
|
97
|
+
# Inside the model's turn; closed when another role follows. Its
|
|
98
|
+
# sent images go after the blocks, outside the quoted value, as
|
|
99
|
+
# Gemma's chat template puts them; placeholder lines stay inside.
|
|
100
|
+
tool_response_blocks(content, profile) + lines.markers +
|
|
86
101
|
(following && following != "tool_response" && following != "model" ? "#{profile.turn_end}\n" : "")
|
|
87
102
|
when "model"
|
|
88
103
|
content = strip_gemma_thought(content) if index < last_user && !content.include?(profile.tool_call_open)
|
|
@@ -136,7 +151,7 @@ module Samagotchi
|
|
|
136
151
|
parts = []
|
|
137
152
|
|
|
138
153
|
messages.each_with_index do |m, index|
|
|
139
|
-
content = prompt_content_for(m, profile) + suffixes[index]
|
|
154
|
+
content = prompt_content_for(m, profile) + suffixes[index].text
|
|
140
155
|
case m[:role]
|
|
141
156
|
when "system"
|
|
142
157
|
parts << "#{profile.system_prefix}#{content}<|im_end|>\n"
|
|
@@ -16,7 +16,10 @@ module Samagotchi
|
|
|
16
16
|
["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
|
|
17
17
|
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
|
|
18
18
|
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
|
|
19
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
|
|
19
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE],
|
|
20
|
+
["[[SAMAGOTCHI_LITERAL_IMAGE_SOFT]]", "<|image|>"],
|
|
21
|
+
["[[SAMAGOTCHI_LITERAL_IMAGE_START]]", "<|image>"],
|
|
22
|
+
["[[SAMAGOTCHI_LITERAL_IMAGE_END]]", "<image|>"]
|
|
20
23
|
].freeze
|
|
21
24
|
|
|
22
25
|
QWEN_TOKEN_PAIRS = [
|
|
@@ -333,7 +333,12 @@ module Samagotchi
|
|
|
333
333
|
false
|
|
334
334
|
end
|
|
335
335
|
|
|
336
|
-
def continued_words(session)
|
|
336
|
+
def continued_words(session)
|
|
337
|
+
return "" unless session.continues
|
|
338
|
+
|
|
339
|
+
moved = Array(session.moved_children).size
|
|
340
|
+
" (continues #{session.continues[0, 8]}#{"; #{moved} delegate#{"s" if moved != 1} moved" if moved.positive?})"
|
|
341
|
+
end
|
|
337
342
|
|
|
338
343
|
# One existing session, then its next reply. The cursor and baseline
|
|
339
344
|
# are taken before the message goes in, so neither an older reply nor a
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -32,6 +32,12 @@ module Samagotchi
|
|
|
32
32
|
# resume removes it. A file from before the marker says "stopped" in
|
|
33
33
|
# its status field, which reads the same.
|
|
34
34
|
STOPPED_FILE = "stopped"
|
|
35
|
+
# A moved session's parent, <session dir>/parent.json ({parent_id, from,
|
|
36
|
+
# at}): a continue moving a delegate to the chain's new link
|
|
37
|
+
# (ChildMove) writes it instead of rewriting the session file its live
|
|
38
|
+
# worker may save a stale copy of; both load paths apply it, like the
|
|
39
|
+
# stop marker. Never removed: a later move overwrites it.
|
|
40
|
+
PARENT_FILE = "parent.json"
|
|
35
41
|
# The memory a delegate child starts with (Tools::Delegate::CHILD_MEMORIES).
|
|
36
42
|
DELEGATE_MEMORY = "system/delegated"
|
|
37
43
|
|
|
@@ -130,6 +136,10 @@ module Samagotchi
|
|
|
130
136
|
# not saved.
|
|
131
137
|
attr_accessor :model_warning
|
|
132
138
|
|
|
139
|
+
# The delegates a continue moved to this new link (ChildMove;
|
|
140
|
+
# SessionManager.continue_session sets it); not saved.
|
|
141
|
+
attr_accessor :moved_children
|
|
142
|
+
|
|
133
143
|
def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
|
|
134
144
|
metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
|
|
135
145
|
first_preview: "", test_run: false, pending_question: nil,
|
|
@@ -258,6 +268,7 @@ module Samagotchi
|
|
|
258
268
|
|
|
259
269
|
session = from_h(JSON.parse(File.read(path)))
|
|
260
270
|
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
|
|
271
|
+
session.parent_id = parent_override(session.id, state_dir: state_dir) || session.parent_id
|
|
261
272
|
session
|
|
262
273
|
rescue JSON::ParserError => e
|
|
263
274
|
raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
|
|
@@ -386,6 +397,7 @@ module Samagotchi
|
|
|
386
397
|
|
|
387
398
|
session = from_h(data, messages: false)
|
|
388
399
|
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
|
|
400
|
+
session.parent_id = parent_override(session.id, state_dir: File.dirname(path)) || session.parent_id
|
|
389
401
|
session
|
|
390
402
|
rescue JSON::ParserError, KeyError, SystemCallError
|
|
391
403
|
nil
|
|
@@ -453,6 +465,29 @@ module Samagotchi
|
|
|
453
465
|
File.exist?(File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE))
|
|
454
466
|
end
|
|
455
467
|
|
|
468
|
+
# Give +session_id+ a new parent (PARENT_FILE), +from+ the one it had.
|
|
469
|
+
def self.reparent(session_id, to:, from:, state_dir: default_state_dir)
|
|
470
|
+
dir = session_dir(session_id, state_dir: state_dir)
|
|
471
|
+
FileUtils.mkdir_p(dir)
|
|
472
|
+
AtomicFile.write(File.join(dir, PARENT_FILE),
|
|
473
|
+
JSON.generate({ "parent_id" => check_id!(to), "from" => from, "at" => Time.now.iso8601(3) }))
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The parent PARENT_FILE gives the session, nil without one (or with
|
|
477
|
+
# one that doesn't hold an id).
|
|
478
|
+
# @return [String, nil]
|
|
479
|
+
def self.parent_override(session_id, state_dir: default_state_dir)
|
|
480
|
+
file = File.join(session_dir(session_id, state_dir: state_dir), PARENT_FILE)
|
|
481
|
+
# The lists read every session: most have none, so no exception each.
|
|
482
|
+
return nil unless File.exist?(file)
|
|
483
|
+
|
|
484
|
+
data = JSON.parse(File.read(file))
|
|
485
|
+
id = data.is_a?(Hash) && data["parent_id"]
|
|
486
|
+
valid_id?(id) ? id : nil
|
|
487
|
+
rescue JSON::ParserError, SystemCallError
|
|
488
|
+
nil
|
|
489
|
+
end
|
|
490
|
+
|
|
456
491
|
# An id that is not a session id: it could name a path outside the
|
|
457
492
|
# sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
|
|
458
493
|
class InvalidId < ArgumentError; end
|