samagotchi 0.15.0 → 0.17.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -56,6 +56,7 @@ require_relative "thinking"
|
|
|
56
56
|
require_relative "answer_display"
|
|
57
57
|
require_relative "edit_preview"
|
|
58
58
|
require_relative "question_desk"
|
|
59
|
+
require_relative "relay_desk"
|
|
59
60
|
require_relative "guardrail_wiring"
|
|
60
61
|
require_relative "plugin_tasks"
|
|
61
62
|
|
|
@@ -161,6 +162,8 @@ module Samagotchi
|
|
|
161
162
|
interface: -> { interface },
|
|
162
163
|
user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
|
|
163
164
|
)
|
|
165
|
+
# Built here, not on first use: the turn thread and the Bridge's read it.
|
|
166
|
+
@relay_desk = RelayDesk.new
|
|
164
167
|
@hooks = load_hooks_from_config
|
|
165
168
|
load_hooks_from_bundles
|
|
166
169
|
# The tools this session offers (the prompts' declarations and the
|
|
@@ -240,7 +243,7 @@ module Samagotchi
|
|
|
240
243
|
Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
|
|
241
244
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
242
245
|
@prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
|
|
243
|
-
thinking: -> { turn_thinking }, memories: memories,
|
|
246
|
+
thinking: -> { turn_thinking }, model: -> { model_identity }, memories: memories,
|
|
244
247
|
muted_memory_names: @muted_memory_names)
|
|
245
248
|
@session = nil
|
|
246
249
|
@session_observer = SessionObserver.new
|
|
@@ -408,6 +411,18 @@ module Samagotchi
|
|
|
408
411
|
model_ref_for(@effective_model_name)
|
|
409
412
|
end
|
|
410
413
|
|
|
414
|
+
# The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
|
|
415
|
+
# the ref, its host, the overlay key and, for a local llama.cpp host
|
|
416
|
+
# only, the model its /props names when that differs. Never a new
|
|
417
|
+
# request: only the /props answer profile resolution already cached.
|
|
418
|
+
def model_identity
|
|
419
|
+
target = @host_registry.resolve(@effective_model_name)
|
|
420
|
+
entry = target.entry
|
|
421
|
+
host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
|
|
422
|
+
SystemPrompt::ModelIdentity.new(ref: effective_model_ref, host: host, key: @model_key,
|
|
423
|
+
served: prompt_served_model(target), fallback_key: @fallback_model_key)
|
|
424
|
+
end
|
|
425
|
+
|
|
411
426
|
# +name+'s resolved ref (ModelRef#ref).
|
|
412
427
|
def model_ref_for(name)
|
|
413
428
|
@host_registry.model_ref(name).ref
|
|
@@ -460,8 +475,8 @@ module Samagotchi
|
|
|
460
475
|
def sync_model_key!
|
|
461
476
|
@model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
|
|
462
477
|
typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
|
|
463
|
-
|
|
464
|
-
@kernel.sync_model_key!(@model_key, fallback:
|
|
478
|
+
@fallback_model_key = typed_key == @model_key ? nil : typed_key
|
|
479
|
+
@kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
|
|
465
480
|
end
|
|
466
481
|
private :sync_model_key!
|
|
467
482
|
|
|
@@ -901,7 +916,7 @@ module Samagotchi
|
|
|
901
916
|
# else [nil, nil].
|
|
902
917
|
# @return [Array(String, String), Array(nil, nil)]
|
|
903
918
|
def served_model(probe: true)
|
|
904
|
-
served_model_for(@metrics.
|
|
919
|
+
served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
|
|
905
920
|
end
|
|
906
921
|
|
|
907
922
|
# Read-only snapshot of the engine's view of the current session plus the
|
|
@@ -1232,15 +1247,25 @@ module Samagotchi
|
|
|
1232
1247
|
# Open a question for the UIs and wait for its answer
|
|
1233
1248
|
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
1234
1249
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
1250
|
+
# (as a Hash or as keywords)
|
|
1251
|
+
# @param watch [#call, nil] closes the question with the reason it returns
|
|
1235
1252
|
# @return [Hash, String] the answer, or {error:, …}
|
|
1236
|
-
def open_question(fields)
|
|
1237
|
-
@question_desk.open_question(fields)
|
|
1253
|
+
def open_question(fields = nil, watch: nil, **more)
|
|
1254
|
+
@question_desk.open_question({ **fields.to_h, **more }, watch: watch)
|
|
1238
1255
|
end
|
|
1239
1256
|
|
|
1240
1257
|
# Answer the pending question (called from UI thread; QuestionDesk#answer).
|
|
1241
1258
|
# @return [Hash] normalized answer
|
|
1242
|
-
def answer_question(id:, selected:, freeform: nil, client_id: nil)
|
|
1243
|
-
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id
|
|
1259
|
+
def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
1260
|
+
@question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
|
|
1261
|
+
parent_agent: parent_agent)
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
# Mark the pending question as relayed, or clear the mark
|
|
1265
|
+
# (QuestionDesk#annotate).
|
|
1266
|
+
# @return [Boolean]
|
|
1267
|
+
def annotate_question(id, relayed_to:, reason: nil)
|
|
1268
|
+
@question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
|
|
1244
1269
|
end
|
|
1245
1270
|
|
|
1246
1271
|
def set_question_sync_handler(&block)
|
|
@@ -1293,6 +1318,31 @@ module Samagotchi
|
|
|
1293
1318
|
def cwd = engine.session&.working_directory
|
|
1294
1319
|
def state_dir = engine.peer_state_dir
|
|
1295
1320
|
def cancelled? = !!engine.active_cancel_controller&.cancelled?
|
|
1321
|
+
def relay = engine.relay_peer
|
|
1322
|
+
def model_ref = engine.effective_model_ref
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
# What the approval relay needs from the parent's Engine (Peers#relay):
|
|
1326
|
+
# its own question flow, which every UI attached to it answers.
|
|
1327
|
+
RelayPeer = Struct.new(:engine) do
|
|
1328
|
+
def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
|
|
1329
|
+
def interface = engine.interface
|
|
1330
|
+
def relay_desk = engine.relay_desk
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
# This worker's relays of its delegates' approvals (memory only).
|
|
1334
|
+
# @return [RelayDesk]
|
|
1335
|
+
attr_reader :relay_desk
|
|
1336
|
+
|
|
1337
|
+
# The relay's view of this Engine, or nil when it can't host a relay: a
|
|
1338
|
+
# worker's Engine only (its Bridge is what a child verifies against;
|
|
1339
|
+
# --non-interactive has no one to ask), never with a REPL's sync
|
|
1340
|
+
# handler (no wait loop, no watch).
|
|
1341
|
+
# @return [RelayPeer, nil]
|
|
1342
|
+
def relay_peer
|
|
1343
|
+
return nil unless interface == :worker && !@question_desk.sync_handler?
|
|
1344
|
+
|
|
1345
|
+
RelayPeer.new(self)
|
|
1296
1346
|
end
|
|
1297
1347
|
|
|
1298
1348
|
# @return [String] the state dir holding this Engine's sessions
|
|
@@ -1692,17 +1742,15 @@ module Samagotchi
|
|
|
1692
1742
|
# continue resumes from them rather than after a made-up reply.
|
|
1693
1743
|
def kept_messages(turn, result, seconds)
|
|
1694
1744
|
conversation = result.conversation if result.conversation.is_a?(Array)
|
|
1695
|
-
# Nothing visible (
|
|
1696
|
-
#
|
|
1745
|
+
# Nothing visible (no text) is a turn the model should know ended
|
|
1746
|
+
# that way: the note, and nothing made up as its answer. The note's
|
|
1747
|
+
# marker is for the UIs (the notice, and the empty steps after a
|
|
1748
|
+
# reload); it goes on the result's conversation too (the REPL keeps it).
|
|
1749
|
+
# A retry's nudge at the tail goes: this note says it all.
|
|
1697
1750
|
if result.empty_answer?
|
|
1698
|
-
|
|
1699
|
-
# into the result); the note is for the model, so it goes on the
|
|
1700
|
-
# result's conversation too.
|
|
1701
|
-
# A retry's nudge at the tail goes: this note says it all.
|
|
1702
|
-
note = TurnNote.empty
|
|
1751
|
+
note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps)
|
|
1703
1752
|
conversation&.replace(TurnNote.without_trailing(conversation))
|
|
1704
1753
|
saved = TurnNote.without_trailing(conversation || turn.session.messages)
|
|
1705
|
-
saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
|
|
1706
1754
|
conversation << note if conversation
|
|
1707
1755
|
saved + [note]
|
|
1708
1756
|
elsif result.canceled? && conversation
|
|
@@ -1901,7 +1949,7 @@ module Samagotchi
|
|
|
1901
1949
|
# @param result [LLM::ModelResult]
|
|
1902
1950
|
# @return [Hash]
|
|
1903
1951
|
def turn_summary(result)
|
|
1904
|
-
{
|
|
1952
|
+
summary = {
|
|
1905
1953
|
output: result.output.to_s,
|
|
1906
1954
|
exhausted: result.exhausted?,
|
|
1907
1955
|
resumable: result.resumable?,
|
|
@@ -1909,6 +1957,9 @@ module Samagotchi
|
|
|
1909
1957
|
tool_activity: Array(result.tool_activity).map(&:dup),
|
|
1910
1958
|
context_status: result.context_status&.dup
|
|
1911
1959
|
}
|
|
1960
|
+
# No answer: every UI shows its notice (TurnNote.empty_answer_line).
|
|
1961
|
+
summary[:empty_answer] = { retries: result.empty_retries.to_i } if result.empty_answer?
|
|
1962
|
+
summary
|
|
1912
1963
|
end
|
|
1913
1964
|
|
|
1914
1965
|
# ── Session messages API ───────────────────────────────────────────────
|
|
@@ -2087,7 +2138,8 @@ module Samagotchi
|
|
|
2087
2138
|
ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
|
|
2088
2139
|
ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2089
2140
|
},
|
|
2090
|
-
model_name: -> {
|
|
2141
|
+
model_name: -> { effective_model_ref },
|
|
2142
|
+
model_key: -> { @model_key },
|
|
2091
2143
|
state_dir: -> { session_state_dir },
|
|
2092
2144
|
scratch: -> { @scratch }
|
|
2093
2145
|
)
|
|
@@ -2282,6 +2334,18 @@ module Samagotchi
|
|
|
2282
2334
|
[nil, nil]
|
|
2283
2335
|
end
|
|
2284
2336
|
|
|
2337
|
+
# What a local native host's cached /props names, when it isn't the
|
|
2338
|
+
# model asked for; nil for a chat or remote host, or before any probe.
|
|
2339
|
+
def prompt_served_model(target)
|
|
2340
|
+
entry = target.entry
|
|
2341
|
+
return nil if entry.nil? || entry.chat? || entry.remote? || !target.client.respond_to?(:cached_server_props)
|
|
2342
|
+
|
|
2343
|
+
served = ServedModel.from_props(target.client.cached_server_props(model: target.bare_model))
|
|
2344
|
+
served if ServedModel.differs?(target.bare_model, served)
|
|
2345
|
+
rescue StandardError
|
|
2346
|
+
nil
|
|
2347
|
+
end
|
|
2348
|
+
|
|
2285
2349
|
# ── Prompt profile ─────────────────────────────────────────────────────────
|
|
2286
2350
|
|
|
2287
2351
|
def resolve_profile
|
|
@@ -219,7 +219,7 @@ module Samagotchi
|
|
|
219
219
|
# the prefill, and what the caller gave.
|
|
220
220
|
Turn = Struct.new(:conversation, :context, :empty_retry, :tool_activity, :buffer, :qwen_attempts, :qwen_partial,
|
|
221
221
|
:prefill, :pending_tool_calls, :model_name, :pending_input, :on_stream_event,
|
|
222
|
-
:cancel_controller, :cap, :emit, :iteration, keyword_init: true)
|
|
222
|
+
:cancel_controller, :cap, :emit, :iteration, :empty_steps, :ended_empty, keyword_init: true)
|
|
223
223
|
# One request: the prompt and its images as sent, the images' token
|
|
224
224
|
# estimate, and the window it was measured against.
|
|
225
225
|
Request = Struct.new(:prompt, :images, :image_tokens, :window, keyword_init: true)
|
|
@@ -234,7 +234,7 @@ module Samagotchi
|
|
|
234
234
|
conversation = prepare_conversation(messages)
|
|
235
235
|
Turn.new(
|
|
236
236
|
conversation: conversation, context: ContextStatus.new(conversation: conversation),
|
|
237
|
-
empty_retry: EmptyAnswerRetry.new, tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
|
|
237
|
+
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", qwen_attempts: 0, qwen_partial: nil,
|
|
238
238
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
239
239
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
240
240
|
# prompt starts with what the server already has cached.
|
|
@@ -418,11 +418,18 @@ module Samagotchi
|
|
|
418
418
|
# The empty generation goes (its thinking would be sent again and
|
|
419
419
|
# prime the same loop); an empty answer that will be retried is no
|
|
420
420
|
# answer site, so a plugin's steer joins the retry.
|
|
421
|
-
turn.conversation.pop if retry_empty
|
|
421
|
+
turn.empty_steps << turn.conversation.pop if retry_empty
|
|
422
422
|
# Queued steering keeps the turn going: the model answers the
|
|
423
423
|
# injected message instead of stopping here.
|
|
424
424
|
return :next if inject_pending_input!(turn, answer: retry_empty ? nil : answer)
|
|
425
425
|
|
|
426
|
+
# The last one goes too: earlier thinking is stripped from the prompt,
|
|
427
|
+
# so it would be an empty assistant turn there. The UIs draw it from
|
|
428
|
+
# the result's empty_steps.
|
|
429
|
+
if empty && !retry_empty
|
|
430
|
+
turn.empty_steps << turn.conversation.pop
|
|
431
|
+
turn.ended_empty = true
|
|
432
|
+
end
|
|
426
433
|
if retry_empty
|
|
427
434
|
turn.empty_retry.nudge!(turn.conversation, TurnNote.empty_retry,
|
|
428
435
|
emit: turn.emit, iteration: turn.iteration, finish_reason: generation.finish_reason,
|
|
@@ -452,7 +459,9 @@ module Samagotchi
|
|
|
452
459
|
# then the answer shows only its text.
|
|
453
460
|
def finish(turn)
|
|
454
461
|
exhausted = turn.pending_tool_calls && tool_response_turn?(turn.conversation.last)
|
|
455
|
-
|
|
462
|
+
# An empty answer left the conversation: an earlier model message
|
|
463
|
+
# isn't this turn's answer.
|
|
464
|
+
output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
|
|
456
465
|
output = parser.strip_tool_calls(output) if exhausted
|
|
457
466
|
LLM::ModelResult.new(
|
|
458
467
|
text: PromptLiteralGuard.restore(output, profile: @profile).to_s,
|
|
@@ -460,7 +469,9 @@ module Samagotchi
|
|
|
460
469
|
exhausted: exhausted,
|
|
461
470
|
pending_tool_calls: turn.pending_tool_calls,
|
|
462
471
|
tool_activity: turn.tool_activity,
|
|
463
|
-
context_status: turn.context.display
|
|
472
|
+
context_status: turn.context.display,
|
|
473
|
+
empty_steps: turn.empty_steps,
|
|
474
|
+
empty_retries: turn.empty_retry.attempts
|
|
464
475
|
)
|
|
465
476
|
end
|
|
466
477
|
|
|
@@ -195,8 +195,9 @@ module Samagotchi
|
|
|
195
195
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
196
196
|
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
197
197
|
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
198
|
-
# (the live row's params line and label, never sent back),
|
|
199
|
-
# edit/write result's tool_diffs (never sent)
|
|
198
|
+
# (the live row's params line and label, never sent back), an
|
|
199
|
+
# edit/write result's tool_diffs (never sent), and an empty-answer
|
|
200
|
+
# note's marker (TurnNote.empty, never sent).
|
|
200
201
|
def plain(conversation)
|
|
201
202
|
conversation.map do |entry|
|
|
202
203
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -208,6 +209,8 @@ module Samagotchi
|
|
|
208
209
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
209
210
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
210
211
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
212
|
+
marker = TurnNote.empty_answer(entry)
|
|
213
|
+
message[TurnNote::EMPTY_ANSWER] = marker if marker
|
|
211
214
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
212
215
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
213
216
|
message
|
|
@@ -302,11 +305,12 @@ module Samagotchi
|
|
|
302
305
|
@pending_input = pending_input
|
|
303
306
|
@tool_activity = []
|
|
304
307
|
@empty_retry = EmptyAnswerRetry.new
|
|
308
|
+
# The empty generations, never saved: the UIs draw the turn's
|
|
309
|
+
# steps from them (ModelResult#empty_steps).
|
|
310
|
+
@empty_steps = []
|
|
305
311
|
@context = ContextStatus.new(conversation: conversation)
|
|
306
312
|
end
|
|
307
313
|
|
|
308
|
-
EMPTY_ANSWER = "(the model returned an empty answer)"
|
|
309
|
-
|
|
310
314
|
def call(max_iterations:, cap:)
|
|
311
315
|
last_text = ""
|
|
312
316
|
exhausted = true
|
|
@@ -340,11 +344,13 @@ module Samagotchi
|
|
|
340
344
|
# plugin's steer joins the retry instead of being dropped, and
|
|
341
345
|
# queued input (a user's line, a steer) goes in place of the nudge.
|
|
342
346
|
next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
|
|
347
|
+
|
|
348
|
+
# An empty answer (content "" + stop, seen from a remote host)
|
|
349
|
+
# ends the turn with no text: the UIs show their notice.
|
|
350
|
+
@empty_steps << with_thinking({ role: "model", content: "" }, response) if empty
|
|
343
351
|
next if retry_empty && nudge_empty_answer(iteration, response)
|
|
344
352
|
|
|
345
|
-
|
|
346
|
-
# a remote host) would otherwise end the turn with nothing.
|
|
347
|
-
last_text = EMPTY_ANSWER if empty
|
|
353
|
+
last_text = "" if empty
|
|
348
354
|
exhausted = false
|
|
349
355
|
break
|
|
350
356
|
end
|
|
@@ -573,7 +579,7 @@ module Samagotchi
|
|
|
573
579
|
|
|
574
580
|
def result(text, exhausted:)
|
|
575
581
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
576
|
-
tool_activity: @tool_activity,
|
|
582
|
+
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
577
583
|
context_status: @context.display)
|
|
578
584
|
end
|
|
579
585
|
|
|
@@ -9,13 +9,19 @@ module Samagotchi
|
|
|
9
9
|
# .canceled?, .cancellation_reason, .exhausted?, .resumable?,
|
|
10
10
|
# .empty_answer?, plus the native loop's .tool_activity, .context_status
|
|
11
11
|
# and .pending_tool_calls, which the interactive renderer needs.
|
|
12
|
+
#
|
|
13
|
+
# A turn with no answer also carries .empty_steps (its empty
|
|
14
|
+
# generations as model messages, which the loops keep out of the
|
|
15
|
+
# conversation) and .empty_retries (the retries spent): the Engine saves
|
|
16
|
+
# them on the turn's note for the UIs (TurnNote.empty).
|
|
12
17
|
class ModelResult
|
|
13
18
|
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
14
|
-
:tool_activity, :context_status, :pending_tool_calls
|
|
19
|
+
:tool_activity, :context_status, :pending_tool_calls, :empty_steps, :empty_retries
|
|
15
20
|
|
|
16
21
|
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
17
|
-
tool_activity: [], context_status: nil, pending_tool_calls: false,
|
|
18
|
-
@
|
|
22
|
+
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_steps: [], empty_retries: 0)
|
|
23
|
+
@empty_steps = Array(empty_steps)
|
|
24
|
+
@empty_retries = empty_retries.to_i
|
|
19
25
|
@text = text
|
|
20
26
|
@conversation = conversation
|
|
21
27
|
@canceled = canceled
|
|
@@ -35,13 +41,12 @@ module Samagotchi
|
|
|
35
41
|
!!canceled
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
# The turn ended with nothing visible
|
|
39
|
-
#
|
|
40
|
-
# continued is not an empty answer.
|
|
44
|
+
# The turn ended with nothing visible (no text). A cancelled turn or
|
|
45
|
+
# one that can be continued is not an empty answer.
|
|
41
46
|
def empty_answer?
|
|
42
47
|
return false if canceled? || resumable?
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
text.to_s.strip.empty?
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def exhausted?
|
|
@@ -27,6 +27,7 @@ module Samagotchi
|
|
|
27
27
|
question_requested: [],
|
|
28
28
|
question_answered: %i[id],
|
|
29
29
|
question_cancelled: %i[id reason],
|
|
30
|
+
question_relay: %i[id reason],
|
|
30
31
|
generation_cancelled: %i[iteration],
|
|
31
32
|
empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars stopped_by]
|
|
32
33
|
}.freeze
|
|
@@ -67,6 +67,8 @@ module Samagotchi
|
|
|
67
67
|
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
|
|
68
68
|
end
|
|
69
69
|
candidates = allow.map { |k| candidates_by_basename[k] }
|
|
70
|
+
else
|
|
71
|
+
candidates = leave_out_owned(candidates, resolved_name)
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
# Compute checksums and detect placeholders
|
|
@@ -250,6 +252,26 @@ module Samagotchi
|
|
|
250
252
|
|
|
251
253
|
private
|
|
252
254
|
|
|
255
|
+
# Without an allowlist, a memory another installed bundle owns (its
|
|
256
|
+
# record lists it) isn't the user's to share: left out, one line
|
|
257
|
+
# each. The bundle being built (same name) keeps its own.
|
|
258
|
+
def leave_out_owned(candidates, name)
|
|
259
|
+
require_relative "provenance"
|
|
260
|
+
owners = candidates.to_h do |path|
|
|
261
|
+
[path, Provenance.claimants(File.basename(path), scope: @scope, except: name)]
|
|
262
|
+
end
|
|
263
|
+
left_out = owners.reject { |_, names| names.empty? }
|
|
264
|
+
left_out.each do |path, names|
|
|
265
|
+
@warnings << "Left out #{File.basename(path)}: installed by bundle #{names.join(", ")} (name it to include it)"
|
|
266
|
+
end
|
|
267
|
+
kept = candidates - left_out.keys
|
|
268
|
+
if kept.empty?
|
|
269
|
+
raise BuildError, "No memories to build in #{@scope} scope: every one belongs to an installed bundle " \
|
|
270
|
+
"(#{left_out.keys.map { |p| File.basename(p) }.join(", ")}); name the ones to include"
|
|
271
|
+
end
|
|
272
|
+
kept
|
|
273
|
+
end
|
|
274
|
+
|
|
253
275
|
def normalize_scope(val)
|
|
254
276
|
return nil if val.nil? || val.to_s.strip.empty?
|
|
255
277
|
v = val.to_s.strip.downcase
|
|
@@ -10,18 +10,22 @@ module Samagotchi
|
|
|
10
10
|
module IndexUpdater
|
|
11
11
|
MEMORY_INDEX = "index"
|
|
12
12
|
LOCK_FILE = ".index.lock"
|
|
13
|
-
|
|
13
|
+
# @param source [String, false, nil] the bundle the memory came from
|
|
14
|
+
# ("· from <bundle>"): a name sets it, false drops it, nil keeps the
|
|
15
|
+
# line's (memory_write and write/edit pass nil)
|
|
16
|
+
def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
|
|
14
17
|
index_path = index_path_for(scope)
|
|
15
18
|
return true unless index_path
|
|
16
19
|
locked_write(File.dirname(index_path)) do |content|
|
|
17
|
-
new_line = managed_line(entry_name, scope, byte_count, description)
|
|
20
|
+
new_line = managed_line(entry_name, scope, byte_count, description, source || nil)
|
|
18
21
|
next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
|
|
19
22
|
|
|
20
23
|
pattern = managed_pattern(entry_name)
|
|
21
24
|
if content.match?(pattern)
|
|
22
|
-
|
|
23
|
-
resolved_desc = description&.to_s&.strip ||
|
|
24
|
-
|
|
25
|
+
existing = content[pattern].to_s.chomp
|
|
26
|
+
resolved_desc = description&.to_s&.strip || extract_description(existing)
|
|
27
|
+
resolved_source = source.nil? ? extract_source(existing) : (source || nil)
|
|
28
|
+
new_line = managed_line(entry_name, scope, byte_count, resolved_desc, resolved_source)
|
|
25
29
|
next content.sub(pattern) { new_line + $1 }
|
|
26
30
|
end
|
|
27
31
|
content.end_with?("\n") ? "#{content}#{new_line}\n" : "#{content}\n#{new_line}\n"
|
|
@@ -57,8 +61,9 @@ module Samagotchi
|
|
|
57
61
|
Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
|
|
58
62
|
end
|
|
59
63
|
end
|
|
60
|
-
def self.managed_line(name, scope, byte_count, description)
|
|
64
|
+
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
61
65
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
66
|
+
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
62
67
|
desc = description&.to_s&.strip
|
|
63
68
|
line += " — #{desc}" unless desc.to_s.empty?
|
|
64
69
|
line
|
|
@@ -70,6 +75,12 @@ module Samagotchi
|
|
|
70
75
|
$1
|
|
71
76
|
end
|
|
72
77
|
end
|
|
78
|
+
# The bundle a managed line names ("· from <bundle>", before any
|
|
79
|
+
# " — description"), or nil.
|
|
80
|
+
def self.extract_source(line)
|
|
81
|
+
line.to_s.chomp.split(" — ", 2).first[/ · from (\S+)/, 1]
|
|
82
|
+
end
|
|
83
|
+
|
|
73
84
|
def self.auto_index_header
|
|
74
85
|
"# Memory Index\n\n" \
|
|
75
86
|
"Managed entries below are auto-maintained by memory_write " \
|
|
@@ -9,6 +9,7 @@ require_relative "source"
|
|
|
9
9
|
require_relative "placeholder"
|
|
10
10
|
require_relative "index_updater"
|
|
11
11
|
require_relative "merger"
|
|
12
|
+
require_relative "trash"
|
|
12
13
|
require_relative "../version"
|
|
13
14
|
require_relative "../bundle_needs"
|
|
14
15
|
|
|
@@ -41,8 +42,13 @@ module Samagotchi
|
|
|
41
42
|
@warnings = []
|
|
42
43
|
@placeholder_warnings = []
|
|
43
44
|
@conflicts = {}
|
|
45
|
+
@trash = Trash.new(name)
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# Where an upgrade moved the files the bundle no longer ships (nil:
|
|
49
|
+
# none moved).
|
|
50
|
+
def trash_dir = @trash.dir
|
|
51
|
+
|
|
46
52
|
def run
|
|
47
53
|
normalized_dir = nil
|
|
48
54
|
source_owned = false
|
|
@@ -73,14 +79,24 @@ module Samagotchi
|
|
|
73
79
|
existing_provenance = provenance.read
|
|
74
80
|
# A plain install over an installed bundle: summary points at upgrade.
|
|
75
81
|
@reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
|
|
82
|
+
# The bundle owns (records, upgrades, removes) only the files it
|
|
83
|
+
# wrote: these, from an earlier install, and the ones written now.
|
|
84
|
+
# A same-name file that was already there is the user's: skipped
|
|
85
|
+
# and never recorded.
|
|
86
|
+
owned_before = existing_provenance && existing_provenance[:files].is_a?(Hash) ? existing_provenance[:files].keys.map(&:to_s) : []
|
|
87
|
+
owned = []
|
|
76
88
|
|
|
77
89
|
Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
|
|
78
90
|
file_key = File.basename(file_path)
|
|
79
91
|
all_files_in_bundle << file_key
|
|
80
92
|
target_path = File.join(target_dir, file_key)
|
|
81
93
|
|
|
82
|
-
if @upgrade && existing_provenance && !@force &&
|
|
94
|
+
if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
|
|
95
|
+
# Not the bundle's (the user's, or another bundle's): no merge.
|
|
96
|
+
skip_existing(file_key, file_path, target_path, target_scope)
|
|
97
|
+
elsif @upgrade && existing_provenance && !@force && !@dry_run
|
|
83
98
|
# 3-way merge path for upgrades
|
|
99
|
+
owned << file_key
|
|
84
100
|
base_path = provenance.base_path(file_key)
|
|
85
101
|
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
86
102
|
case classification
|
|
@@ -120,16 +136,13 @@ module Samagotchi
|
|
|
120
136
|
else
|
|
121
137
|
@results[file_key] = { status: "would_install" }
|
|
122
138
|
end
|
|
123
|
-
elsif File.exist?(target_path) && !@force && FileUtils.identical?(file_path, target_path)
|
|
124
|
-
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
125
|
-
update_target_index(target_scope, target_path, file_key)
|
|
126
139
|
elsif File.exist?(target_path) && !@force
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
update_target_index(target_scope, target_path, file_key)
|
|
140
|
+
# A re-install keeps what an earlier install wrote.
|
|
141
|
+
owned << file_key if owned_before.include?(file_key)
|
|
142
|
+
skip_existing(file_key, file_path, target_path, target_scope, owned: owned_before.include?(file_key))
|
|
131
143
|
else
|
|
132
144
|
FileUtils.cp(file_path, target_path) unless @dry_run
|
|
145
|
+
owned << file_key
|
|
133
146
|
@results[file_key] = { status: "installed" }
|
|
134
147
|
# Update index for newly installed files.
|
|
135
148
|
update_target_index(target_scope, target_path, file_key) unless @dry_run
|
|
@@ -302,7 +315,7 @@ module Samagotchi
|
|
|
302
315
|
if manifest && !@dry_run
|
|
303
316
|
provenance_files = {}
|
|
304
317
|
if @upgrade && existing_provenance
|
|
305
|
-
|
|
318
|
+
owned.each do |file_key|
|
|
306
319
|
target_path = File.join(target_dir, file_key)
|
|
307
320
|
next unless File.exist?(target_path)
|
|
308
321
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
@@ -338,7 +351,7 @@ module Samagotchi
|
|
|
338
351
|
end
|
|
339
352
|
end
|
|
340
353
|
else
|
|
341
|
-
|
|
354
|
+
owned.each do |file_key|
|
|
342
355
|
target_path = File.join(target_dir, file_key)
|
|
343
356
|
next unless File.exist?(target_path)
|
|
344
357
|
|
|
@@ -408,7 +421,7 @@ module Samagotchi
|
|
|
408
421
|
lines << "Updated: #{updated.join(', ')}" unless updated.empty?
|
|
409
422
|
lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
|
|
410
423
|
lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
|
|
411
|
-
lines << "Removed (no longer in the bundle): #{removed.join(', ')}" unless removed.empty?
|
|
424
|
+
lines << "Removed (no longer in the bundle): #{removed.join(', ')}#{trash_dir ? " (moved to #{trash_dir})" : ""}" unless removed.empty?
|
|
412
425
|
lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
|
|
413
426
|
lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
|
|
414
427
|
lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
|
|
@@ -422,9 +435,27 @@ module Samagotchi
|
|
|
422
435
|
|
|
423
436
|
private
|
|
424
437
|
|
|
438
|
+
# A file already in the target dir that the install doesn't write: the
|
|
439
|
+
# same bytes are "already up to date", others are skipped with a
|
|
440
|
+
# --force hint (a dry run: would_skip). Its index line is refreshed
|
|
441
|
+
# (not on a dry run).
|
|
442
|
+
def skip_existing(file_key, file_path, target_path, scope, owned: false)
|
|
443
|
+
if FileUtils.identical?(file_path, target_path)
|
|
444
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
445
|
+
elsif @dry_run
|
|
446
|
+
@results[file_key] = { status: "would_skip" }
|
|
447
|
+
return
|
|
448
|
+
else
|
|
449
|
+
@results[file_key] = { status: "skipped", reason: "already exists" }
|
|
450
|
+
@warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
|
|
451
|
+
end
|
|
452
|
+
update_target_index(scope, target_path, file_key, owned: owned) unless @dry_run
|
|
453
|
+
end
|
|
454
|
+
|
|
425
455
|
# A file the previous version installed that this one doesn't ship is
|
|
426
|
-
#
|
|
427
|
-
# installed, or with --force; one the user edited is
|
|
456
|
+
# moved to the trash (with its index line removed) when it still
|
|
457
|
+
# matches what was installed, or with --force; one the user edited is
|
|
458
|
+
# kept with a note.
|
|
428
459
|
def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
|
|
429
460
|
previous_files.each do |old_key, meta|
|
|
430
461
|
key = old_key.to_s
|
|
@@ -432,10 +463,14 @@ module Samagotchi
|
|
|
432
463
|
target = File.join(target_dir, key)
|
|
433
464
|
next unless File.exist?(target)
|
|
434
465
|
|
|
435
|
-
|
|
466
|
+
others = Provenance.claimants(key, scope: scope, except: @name)
|
|
467
|
+
if others.any?
|
|
468
|
+
@results[key] = { status: "kept_shared", reason: "bundle #{others.join(", ")} has it too" }
|
|
469
|
+
@warnings << "Kept #{key}: no longer in the bundle, but bundle #{others.join(", ")} has it too"
|
|
470
|
+
elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
|
|
436
471
|
@results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
|
|
437
472
|
next if @dry_run
|
|
438
|
-
|
|
473
|
+
@trash.move(target)
|
|
439
474
|
remove_target_index(scope, key)
|
|
440
475
|
else
|
|
441
476
|
@results[key] = { status: "kept_pruned", reason: "local edits preserved" }
|
|
@@ -452,11 +487,12 @@ module Samagotchi
|
|
|
452
487
|
File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
|
|
453
488
|
end
|
|
454
489
|
|
|
490
|
+
# Not fatal, like update_target_index: a warning says so.
|
|
455
491
|
def remove_target_index(scope, file_key)
|
|
456
492
|
IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
|
|
457
493
|
IndexUpdater.remove_index(scope, file_key) # legacy "name.md" line
|
|
458
|
-
rescue StandardError
|
|
459
|
-
#
|
|
494
|
+
rescue StandardError => e
|
|
495
|
+
@warnings << "index.md: line for #{file_key.delete_suffix(".md")} not removed (#{e.message})"
|
|
460
496
|
end
|
|
461
497
|
|
|
462
498
|
# One warning per need not found on this PATH (read-only, so dry-run
|
|
@@ -514,16 +550,19 @@ module Samagotchi
|
|
|
514
550
|
dest
|
|
515
551
|
end
|
|
516
552
|
|
|
517
|
-
|
|
553
|
+
# +owned+: the bundle wrote (owns) the file, so its line says
|
|
554
|
+
# "· from <bundle>"; a skipped file's line keeps whatever it had.
|
|
555
|
+
def update_target_index(scope, file_path, file_key, owned: true)
|
|
518
556
|
byte_count = File.exist?(file_path) ? File.size(file_path) : 0
|
|
519
557
|
entry_name = file_key.delete_suffix(".md")
|
|
520
558
|
begin
|
|
521
559
|
# Index lines use the entry name (as memory_write does); drop the
|
|
522
560
|
# legacy "name.md" line older installs wrote.
|
|
523
561
|
IndexUpdater.remove_index(scope, file_key) unless entry_name == file_key
|
|
524
|
-
IndexUpdater.update_index(scope, entry_name, byte_count)
|
|
525
|
-
rescue =>
|
|
526
|
-
#
|
|
562
|
+
IndexUpdater.update_index(scope, entry_name, byte_count, source: owned ? @name : nil)
|
|
563
|
+
rescue StandardError => e
|
|
564
|
+
# Not fatal (the file is in place): a warning says so.
|
|
565
|
+
@warnings << "index.md: line for #{entry_name} not updated (#{e.message})"
|
|
527
566
|
end
|
|
528
567
|
end
|
|
529
568
|
|