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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +23 -0
  5. data/docs/cli.md +7 -4
  6. data/docs/configuration.md +7 -5
  7. data/docs/guardrails.md +16 -2
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/memory.md +39 -0
  10. data/docs/plugins.md +2 -0
  11. data/docs/sessions.md +10 -4
  12. data/docs/sub-agent.md +16 -3
  13. data/docs/testing.md +47 -0
  14. data/lib/samagotchi/answer_display.rb +6 -2
  15. data/lib/samagotchi/bridge/card_store.rb +16 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
  17. data/lib/samagotchi/bridge.rb +122 -7
  18. data/lib/samagotchi/bridge_client.rb +33 -6
  19. data/lib/samagotchi/bundle_command.rb +13 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  22. data/lib/samagotchi/bundles/system/self_map.md +3 -1
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  24. data/lib/samagotchi/engine.rb +82 -18
  25. data/lib/samagotchi/kernel_loop.rb +16 -5
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  27. data/lib/samagotchi/llm/model_result.rb +12 -7
  28. data/lib/samagotchi/log_subscriber.rb +1 -0
  29. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  30. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  31. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  32. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  33. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  34. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  36. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  37. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  38. data/lib/samagotchi/parent_report.rb +8 -0
  39. data/lib/samagotchi/plugin/context.rb +14 -2
  40. data/lib/samagotchi/question_desk.rb +97 -18
  41. data/lib/samagotchi/relay.rb +88 -0
  42. data/lib/samagotchi/relay_desk.rb +73 -0
  43. data/lib/samagotchi/relay_verifier.rb +113 -0
  44. data/lib/samagotchi/relay_watcher.rb +46 -0
  45. data/lib/samagotchi/reply_wait.rb +10 -1
  46. data/lib/samagotchi/self_report.rb +49 -3
  47. data/lib/samagotchi/send_command.rb +45 -6
  48. data/lib/samagotchi/session.rb +15 -2
  49. data/lib/samagotchi/session_manager.rb +3 -2
  50. data/lib/samagotchi/session_metrics.rb +154 -48
  51. data/lib/samagotchi/sessions_command.rb +10 -4
  52. data/lib/samagotchi/system_prompt.rb +45 -4
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
  55. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
  56. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  57. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  58. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  59. data/lib/samagotchi/terminal_ui.rb +24 -0
  60. data/lib/samagotchi/tool_declarations.rb +2 -2
  61. data/lib/samagotchi/tools/builtins.rb +11 -3
  62. data/lib/samagotchi/tools/delegate_relay.rb +220 -0
  63. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  64. data/lib/samagotchi/tools/peers.rb +7 -4
  65. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  66. data/lib/samagotchi/turn_note.rb +30 -2
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +22 -1
  69. data/lib/samagotchi/web/public/app.js +60 -33
  70. data/lib/samagotchi/web/public/index.html +7 -0
  71. data/lib/samagotchi/web/public/notify.js +22 -5
  72. data/lib/samagotchi/web/public/question_card.js +45 -3
  73. data/lib/samagotchi/web/public/question_cards.js +49 -1
  74. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  75. data/lib/samagotchi/web/public/stage_model.js +8 -3
  76. data/lib/samagotchi/web/public/stage_view.js +20 -3
  77. data/lib/samagotchi/web/public/strip.js +11 -0
  78. data/lib/samagotchi/web/public/timing.js +37 -15
  79. data/lib/samagotchi/web/public/turn_events.js +22 -3
  80. data/lib/samagotchi/web/public/turn_view.js +34 -12
  81. data/lib/samagotchi/worker.rb +28 -2
  82. data/lib/samagotchi/worker_sidecar.rb +4 -1
  83. metadata +8 -1
@@ -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
- fallback = typed_key == @model_key ? nil : typed_key
464
- @kernel.sync_model_key!(@model_key, fallback: 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.snapshot, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
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 (the native loop: no text; the chat loop: its
1696
- # placeholder text) is a turn the model should know ended that way.
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
- # The placeholder is for the UIs (a new array: it must not leak
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: -> { @session&.model_name || @effective_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
- output = strip_thought_blocks(last_model_content(turn.conversation))
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), and an
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
- # Shown, not saved: an empty answer (content "" + stop, seen from
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, empty_answer: text == EMPTY_ANSWER,
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, empty_answer: false)
18
- @empty_answer = empty_answer
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: no text, or the chat loop's
39
- # placeholder (empty_answer:). A cancelled turn or one that can be
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
- !!@empty_answer || text.to_s.strip.empty?
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
- def self.update_index(scope, entry_name, byte_count, description = nil)
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
- existing_desc = extract_description(content[pattern].to_s)
23
- resolved_desc = description&.to_s&.strip || existing_desc
24
- new_line = managed_line(entry_name, scope, byte_count, resolved_desc)
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 && !@dry_run
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
- @results[file_key] = { status: "skipped", reason: "already exists" }
128
- @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
129
- # Still update the index for skipped files.
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
- all_files_in_bundle.each do |file_key|
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
- all_files_in_bundle.each do |file_key|
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
- # removed (with its index line) when it still matches what was
427
- # installed, or with --force; one the user edited is kept with a note.
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
- if @force || installed_unchanged?(target, meta, provenance.base_path(key))
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
- FileUtils.rm_f(target)
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
- # Best-effort, like update_target_index.
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
- def update_target_index(scope, file_path, file_key)
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 => _e
526
- # Silently skip index updates — they're best-effort.
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