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
@@ -51,7 +51,7 @@ module Samagotchi
51
51
  # The events after which the file is written again: the ones that
52
52
  # change an entry or the turn count.
53
53
  SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
54
- turn_failed] + Events::TURN_KEPT).freeze
54
+ question_relay turn_failed] + Events::TURN_KEPT).freeze
55
55
 
56
56
  # What a session's saved file lists, as a store started from it lists
57
57
  # (no turn running): the web's cards for a session no worker runs.
@@ -171,6 +171,7 @@ module Samagotchi
171
171
  add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
172
172
  when :question_answered then resolve_question(event[:id], answer: event[:answer])
173
173
  when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
174
+ when :question_relay then mark_relay(event[:id], event[:relayed_to])
174
175
  end
175
176
  end
176
177
 
@@ -219,6 +220,20 @@ module Samagotchi
219
220
  entry&.merge!(Marshal.load(Marshal.dump(outcome.compact)))
220
221
  end
221
222
 
223
+ # The open question was relayed to a parent's user, or no longer is.
224
+ def mark_relay(id, relayed_to)
225
+ entry = @entries.reverse_each.find do |e|
226
+ e[:type] == :question && e.dig(:pending_question, :id).to_s == id.to_s
227
+ end
228
+ return unless entry
229
+
230
+ if relayed_to
231
+ entry[:pending_question][:relayed_to] = Marshal.load(Marshal.dump(relayed_to))
232
+ else
233
+ entry[:pending_question].delete(:relayed_to)
234
+ end
235
+ end
236
+
222
237
  def push(entry)
223
238
  @entries << entry
224
239
  notice = notice?(entry)
@@ -41,11 +41,23 @@ module Samagotchi
41
41
  # continue, ordered parts (thinking / text / tool / input / steer /
42
42
  # reminder / notice), pending_question and the last event_seq folded
43
43
  # in. A finished tool part keeps the live row's action and duration_ms
44
- # too. A notice part holds the event of one of the turn's rows
45
- # (TurnNotice: a hook's notice, an empty-answer retry, a question and
46
- # its answer), which a UI replays through its live handler.
44
+ # too, a running one the time it has run so far (elapsed_ms), so a
45
+ # join times its row from its start. A notice part holds the event
46
+ # of one of the turn's rows (TurnNotice: a hook's notice, an
47
+ # empty-answer retry, a question and its answer), which a UI replays
48
+ # through its live handler.
47
49
  def current_turn
48
- @mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
50
+ @mutex.synchronize do
51
+ next nil unless @turn
52
+
53
+ turn = Marshal.load(Marshal.dump(@turn))
54
+ now = @clock.call
55
+ Array(turn[:parts]).each do |part|
56
+ started_at = part[:kind] == "tool" && part[:status] == "running" && @tool_started_at[[part[:iteration], part[:call_index]]]
57
+ part[:elapsed_ms] = ((now - started_at) * 1000).round if started_at
58
+ end
59
+ turn
60
+ end
49
61
  end
50
62
 
51
63
  # The turn in progress as conversation messages, for a plugin's
@@ -142,7 +154,10 @@ module Samagotchi
142
154
  call[:label] = part[:label] if part[:label]
143
155
  title = part[:title] ? { title: part[:title] } : {}
144
156
  events = [{ type: :tool_call_started, **call, params: part[:params], **title }]
145
- return events if part[:status] == "running"
157
+ if part[:status] == "running"
158
+ events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
159
+ return events
160
+ end
146
161
 
147
162
  activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
148
163
  activity[:action] = part[:action] if part[:action]
@@ -268,6 +283,12 @@ module Samagotchi
268
283
  @turn[:pending_question] = event[:pending_question]&.dup
269
284
  when :question_answered, :question_cancelled
270
285
  @turn[:pending_question] = nil
286
+ when :question_relay
287
+ pending = @turn[:pending_question]
288
+ if pending && pending[:id].to_s == event[:id].to_s
289
+ relayed_to = event[:relayed_to]
290
+ @turn[:pending_question] = relayed_to ? pending.merge(relayed_to: relayed_to) : pending.except(:relayed_to)
291
+ end
271
292
  end
272
293
  parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
273
294
  end
@@ -24,6 +24,8 @@ require_relative "image_store"
24
24
  require_relative "log"
25
25
  require_relative "version"
26
26
  require_relative "worker_sidecar"
27
+ require_relative "relay_verifier"
28
+ require_relative "relay_watcher"
27
29
 
28
30
  module Samagotchi
29
31
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -192,6 +194,7 @@ module Samagotchi
192
194
  await_answers(REQUEST_GRACE_SECONDS)
193
195
  @connection_threads.each { |t| t.kill rescue nil }
194
196
  @connection_threads.clear
197
+ @mutex.synchronize { @relay_watchers&.each { |t| t.kill rescue nil } }
195
198
  @accept_thread&.join(2)
196
199
  remove_sidecar
197
200
  nil
@@ -241,6 +244,16 @@ module Samagotchi
241
244
  end
242
245
  end
243
246
 
247
+ # The worker's own way in for a session command that came as a message
248
+ # (its first prompt, an input file): queued and announced as a POST
249
+ # /command's is (#handle_command).
250
+ # @return [String] its command_id
251
+ def queue_command(line, client_id: nil)
252
+ command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
253
+ @engine.synchronize_events { queue_command_locked(command) }
254
+ command[:command_id]
255
+ end
256
+
244
257
  private
245
258
 
246
259
  # The Engine's commands: the built-ins and its plugins'.
@@ -429,6 +442,8 @@ module Samagotchi
429
442
  %w[POST command] => :handle_command,
430
443
  %w[POST exit] => :handle_exit_request,
431
444
  %w[POST recap] => :handle_recap,
445
+ %w[POST relay] => :handle_relay,
446
+ %w[POST relay/status] => :handle_relay_status,
432
447
  %w[GET state] => :handle_state,
433
448
  %w[GET stats] => :handle_stats,
434
449
  %w[GET snapshot] => :handle_snapshot
@@ -525,6 +540,38 @@ module Samagotchi
525
540
  end
526
541
  end
527
542
 
543
+ # What a parent's relay may say about this session's approval
544
+ # (BridgeClient#relay): opened and closed only change how the question
545
+ # shows (QuestionDesk#annotate); answered makes this worker ask the
546
+ # parent for the answer (RelayVerifier). No auth: a poke at opened or
547
+ # closed can at most show a wrong banner.
548
+ RELAY_ACTIONS = %w[opened closed answered].freeze
549
+ # Why a relay closed, as a parent says it; anything else is "closed".
550
+ RELAY_CLOSE_REASONS = %w[stopped closed child_gone answered_on_child].freeze
551
+
552
+ # POST /session/:id/relay {action, relay_id, question_id}. 200, 409 (no
553
+ # longer pending, or another relay's), 403 refused (a parent agent's
554
+ # allow), 422 relay_unverified, 400 a bad request.
555
+ def handle_relay(session_id, body)
556
+ parsed = parse_json(body)
557
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
558
+
559
+ action, relay_id, qid = %w[action relay_id question_id].map { |key| fetched(parsed, key).to_s }
560
+ unless RELAY_ACTIONS.include?(action) && !relay_id.empty? && !qid.empty?
561
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "action (opened, closed, answered), relay_id and question_id required" }]
562
+ end
563
+
564
+ case action
565
+ when "opened" then relay_opened(qid, relay_id)
566
+ when "closed"
567
+ reason = fetched(parsed, "reason").to_s
568
+ relay_closed(qid, relay_id, RELAY_CLOSE_REASONS.include?(reason) ? reason : "closed")
569
+ else
570
+ RelayVerifier.new(engine: @engine, state_dir: @state_dir, session_id: session_id)
571
+ .answer(relay_id: relay_id, question_id: qid)
572
+ end
573
+ end
574
+
528
575
  # Dismiss the pending question (an empty answer, as in the REPL): the
529
576
  # tool returns without an answer and every UI gets :question_cancelled.
530
577
  # Only the question the client saw, and only while nobody has answered
@@ -575,17 +622,21 @@ module Samagotchi
575
622
  deadline = fetched(parsed, "deadline")
576
623
  return BAD_DEADLINE unless deadline_valid?(deadline)
577
624
 
578
- command = { command_id: SecureRandom.uuid, client_id: fetched(parsed, "client_id"), line: line }
625
+ queue_command_request(session_id, line, client_id: fetched(parsed, "client_id"), deadline: deadline,
626
+ card: fetched(parsed, "card") == true)
627
+ end
628
+
629
+ # Queues +line+ (a session command) for the worker and announces its
630
+ # :command_queued, unless +deadline+ has passed. Returns [headers,
631
+ # status, body]: 202 with its command_id, or 408.
632
+ def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
633
+ command = { command_id: SecureRandom.uuid, client_id: client_id, line: line }
579
634
  # A card's action: the UIs leave its line out (the card is the echo).
580
- command[:card] = true if fetched(parsed, "card") == true
635
+ command[:card] = true if card
581
636
  queued = @engine.synchronize_events do
582
637
  next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
583
638
 
584
- @on_command.call(command)
585
- # An anytime command runs now, beside a turn (D8): the UIs show its
586
- # line here, so the cards it shows come after it.
587
- anytime = command_registry.lookup(line)&.anytime ? { anytime: true } : {}
588
- @engine.announce(type: :command_queued, **command, **anytime)
639
+ queue_command_locked(command)
589
640
  true
590
641
  end
591
642
  return deadline_passed("command") unless queued
@@ -593,6 +644,15 @@ module Samagotchi
593
644
  [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
594
645
  end
595
646
 
647
+ # With the event log held.
648
+ def queue_command_locked(command)
649
+ @on_command.call(command)
650
+ # An anytime command runs now, beside a turn (D8): the UIs show its
651
+ # line here, so the cards it shows come after it.
652
+ anytime = command_registry.lookup(command[:line])&.anytime ? { anytime: true } : {}
653
+ @engine.announce(type: :command_queued, **command, **anytime)
654
+ end
655
+
596
656
  # A client asks the worker to exit now (`/exit` in the attached TUI). The
597
657
  # worker decides with the event log held, so no POST /turn lands between
598
658
  # its check and its answer; it leaves from its loop after this reply.
@@ -651,6 +711,14 @@ module Samagotchi
651
711
  deadline = fetched(parsed, "deadline")
652
712
  return BAD_DEADLINE unless deadline_valid?(deadline)
653
713
 
714
+ # A session command sent as a message (chi send -m "/model x", chi -p,
715
+ # a UI whose command list lags) runs as the command, as typed in a
716
+ # TUI; an unknown /word stays a prompt. Not with images: those are
717
+ # for the model.
718
+ if images.empty? && @on_command && command_registry.command?(prompt.to_s)
719
+ return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
720
+ end
721
+
654
722
  enqueued_id = SecureRandom.uuid
655
723
  # Write and announce with the event log held: the worker can't emit
656
724
  # this turn's :turn_started (or merge it mid-turn) before
@@ -872,6 +940,53 @@ module Samagotchi
872
940
  headers["connection"].to_s.downcase == "close"
873
941
  end
874
942
 
943
+ # POST /session/:id/relay/status {relay_id}: what this (the parent's)
944
+ # worker's relay holds, for the child that verifies an answer
945
+ # (RelayVerifier). Read-only and no secret; 404 for an unknown id.
946
+ def handle_relay_status(_session_id, body)
947
+ parsed = parse_json(body)
948
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
949
+
950
+ relay = @engine.relay_desk.status(fetched(parsed, "relay_id").to_s)
951
+ return [{}, 404, { error: "unknown_relay" }] unless relay
952
+
953
+ [{}, 200, relay]
954
+ end
955
+
956
+ # A parent's relay card for +qid+ opened: the question says so (the
957
+ # child's card and lists show where else it can be answered).
958
+ def relay_opened(qid, relay_id)
959
+ parent_id = begin
960
+ Session.load(@session_id, state_dir: @state_dir).parent_id
961
+ rescue ArgumentError
962
+ nil
963
+ end
964
+ return [{}, 422, { error: "relay_unverified", detail: "this session has no parent" }] unless parent_id
965
+
966
+ marker = { parent_id: parent_id, parent_short: parent_id[0, 8], relay_id: relay_id }
967
+ return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: marker)
968
+
969
+ Log.info(:bridge, "relay_opened", sid: @session_id, id: qid, parent: parent_id[0, 8])
970
+ watch = RelayWatcher.start(engine: @engine, question_id: qid, relay_id: relay_id,
971
+ parent_dir: Session.session_dir(parent_id, state_dir: @state_dir))
972
+ @mutex.synchronize { @relay_watchers = (@relay_watchers || []).select(&:alive?) << watch }
973
+ [{}, 200, { status: "opened", question_id: qid }]
974
+ end
975
+
976
+ # The parent's relay card closed unanswered: the mark goes, if it is
977
+ # still that relay's.
978
+ def relay_closed(qid, relay_id, reason)
979
+ pending = @engine.pending_question
980
+ relay = pending && pending[:relayed_to]
981
+ unless pending && pending[:id].to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
982
+ return [{}, 409, { error: "question_not_pending", detail: "no question #{qid} relayed by #{relay_id}" }]
983
+ end
984
+ return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: nil, reason: reason)
985
+
986
+ Log.info(:bridge, "relay_closed", sid: @session_id, id: qid, reason: reason)
987
+ [{}, 200, { status: "closed", question_id: qid }]
988
+ end
989
+
875
990
  def write_sidecar
876
991
  # version: the chi this worker runs (chi update reports older ones).
877
992
  WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
@@ -180,6 +180,33 @@ module Samagotchi
180
180
  post("cancel", { reason: reason }, read_body: true)
181
181
  end
182
182
 
183
+ # How long the approval relay's calls wait for a reply: a parent's Stop
184
+ # isn't held for READ_TIMEOUT by a child that doesn't answer.
185
+ RELAY_TIMEOUT = 5
186
+ # How long a child waits for its parent's word on a relay (#relay_status).
187
+ RELAY_STATUS_TIMEOUT = 2
188
+
189
+ # POST /session/:id/relay on a child's worker: a parent's relay of the
190
+ # child's approval opened, closed or was answered (the child asks the
191
+ # parent which answer, #relay_status). 200 answered, 409 no longer
192
+ # pending, 403 refused (a parent agent's allow), 422 relay_unverified.
193
+ # @param action [String] "opened", "closed" or "answered"
194
+ # @return [Response]
195
+ # @raise [SystemCallError] the worker is gone or didn't answer in time
196
+ def relay(action:, relay_id:, question_id:, read_timeout: RELAY_TIMEOUT, **extra)
197
+ post("relay", { action: action, relay_id: relay_id, question_id: question_id, **extra },
198
+ read_body: true, read_timeout: read_timeout)
199
+ end
200
+
201
+ # POST /session/:id/relay/status on a parent's worker: what its relay
202
+ # +relay_id+ holds (child_id, child_question_id, state, answer, by); 404
203
+ # for one it doesn't know. A POST with a body: routes match exact paths.
204
+ # @return [Response]
205
+ # @raise [SystemCallError]
206
+ def relay_status(relay_id, read_timeout: RELAY_STATUS_TIMEOUT)
207
+ post("relay/status", { relay_id: relay_id }, read_body: true, read_timeout: read_timeout)
208
+ end
209
+
183
210
  # GET /session/:id/<path> as JSON.
184
211
  # @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
185
212
  def get_json(path)
@@ -283,11 +310,11 @@ module Samagotchi
283
310
  # the same one.
284
311
  def deadline = (Time.now.to_f + (@read_timeout * DEADLINE_SHARE)).round(3)
285
312
 
286
- def post(path, payload, read_body:)
313
+ def post(path, payload, read_body:, read_timeout: @read_timeout)
287
314
  sock = TCPSocket.new(@host, @port)
288
315
  json_body = JSON.generate(payload)
289
316
  sock.write("POST /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nContent-Type: application/json\r\nContent-Length: #{json_body.bytesize}\r\nConnection: close\r\n\r\n#{json_body}")
290
- reply = read_reply(sock, path, whole: read_body)
317
+ reply = read_reply(sock, path, whole: read_body, timeout: read_timeout)
291
318
  body = read_body ? reply.split("\r\n\r\n", 2)[1] : nil
292
319
  Response.new(status: reply[STATUS_LINE, 1].to_i, body: body)
293
320
  ensure
@@ -295,17 +322,17 @@ module Samagotchi
295
322
  end
296
323
 
297
324
  # The reply up to the Bridge's close (or its first line only), within
298
- # @read_timeout in all.
325
+ # +timeout+ seconds in all (@read_timeout by default).
299
326
  # @raise [Errno::ETIMEDOUT]
300
- def read_reply(sock, path, whole: true)
301
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @read_timeout
327
+ def read_reply(sock, path, whole: true, timeout: @read_timeout)
328
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
302
329
  reply = +""
303
330
  loop do
304
331
  break if !whole && reply.include?("\n")
305
332
 
306
333
  left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
307
334
  unless left.positive? && sock.wait_readable(left)
308
- raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{@read_timeout}s"
335
+ raise Errno::ETIMEDOUT, "bridge #{path}: no reply within #{timeout}s"
309
336
  end
310
337
 
311
338
  reply << sock.readpartial(16_384)
@@ -36,7 +36,7 @@ module Samagotchi
36
36
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
37
37
  Build local memories and installed hooks (and plugin) into a shareable bundle (dir or zip).
38
38
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
39
- FILES... optional allowlist of *.md basenames to include (default: all).
39
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
40
40
  TEXT
41
41
 
42
42
  # Each subcommand's --help (and only --help: -h and help are taken as
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
58
58
  Build local memories and installed hooks into a shareable bundle (dir or zip).
59
59
  --scope selects source dir (default: system). --out inferred from extension; default <name>.zip.
60
- FILES... optional allowlist of *.md basenames to include (default: all).
60
+ FILES... optional allowlist of *.md basenames to include (default: all but installed bundles' ones).
61
61
 
62
62
  Examples:
63
63
  chi bundle build --scope system
@@ -272,6 +272,7 @@ module Samagotchi
272
272
  begin
273
273
  uninstaller.run
274
274
  @stdout.puts "Uninstalled bundle '#{bundle_name}'"
275
+ puts_trashed(uninstaller.trashed_files, uninstaller.trash_dir)
275
276
  @stdout.puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
276
277
  hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
277
278
  @stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
@@ -311,9 +312,13 @@ module Samagotchi
311
312
  if result.gone
312
313
  @stdout.puts "Uninstalled bundle '#{name}'"
313
314
  @stdout.puts "Removed: #{result.removed.join(", ")}" unless result.removed.empty?
315
+ result.trash.each_value { |files, dir| puts_trashed(files, dir) }
316
+ result.warnings.each { |w| @stdout.puts w }
314
317
  return 0
315
318
  end
316
319
  @stdout.puts "Removed from #{name}: #{result.removed.join(", ")}" unless result.removed.empty?
320
+ result.trash.each_value { |files, dir| puts_trashed(files, dir) }
321
+ result.warnings.each { |w| @stdout.puts w }
317
322
  result.blocked.each { |member, why| @stdout.puts "Kept #{member}: #{why}" }
318
323
  @stderr.puts "Uninstall failed: #{name} stays installed until #{result.blocked.keys.join(", ")} goes " \
319
324
  "(chi bundle uninstall #{name} --force)"
@@ -323,6 +328,11 @@ module Samagotchi
323
328
  1
324
329
  end
325
330
 
331
+ # Memory files an uninstall moved to the trash (MemoryBundle::Trash).
332
+ def puts_trashed(files, dir)
333
+ @stdout.puts "Moved to the trash: #{files.join(", ")} (#{dir})" if dir && !files.empty?
334
+ end
335
+
326
336
  def status(rest)
327
337
  name = rest.find { |a| !a.start_with?("--") }
328
338
  require "json"
@@ -557,6 +567,7 @@ module Samagotchi
557
567
  @stdout.puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
558
568
  @stdout.puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
559
569
  @stdout.puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
570
+ builder.warnings.each { |w| @stdout.puts w }
560
571
  unless result[:placeholder_warnings].empty?
561
572
  @stdout.puts "Placeholders:"
562
573
  result[:placeholder_warnings].each { |w| @stdout.puts " #{w}" }
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.15.0
3
+ version: 0.17.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
8
+ self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
9
  config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
10
- memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
10
+ memory_guide.md: sha256:e535e31fcf29b4b32c270042851ecdd5f412ef2e3c56b0076963e3e1f1a08ef8
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -16,7 +16,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
16
16
  | `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
17
17
 
18
18
  - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
19
- - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
19
+ - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
20
20
 
21
21
  ## Tools you have
22
22
 
@@ -85,13 +85,13 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
85
85
 
86
86
  | Command | Purpose |
87
87
  |---------|---------|
88
- | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips. Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
88
+ | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
89
89
  | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
90
- | `uninstall <bundle> [--force]` | Removes bundle files (skips locally edited files unless `--force`) and `index.md` lines, deletes provenance dir. |
90
+ | `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
91
91
  | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
92
92
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
93
93
  | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
94
- | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
94
+ | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
95
95
 
96
96
  **Scope resolution for install/build:**
97
97
  - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`Installer#resolve_target_dir`).
@@ -119,7 +119,7 @@ chi bundle build --scope system --out updated.zip
119
119
  ### Provenance internals (for debugging)
120
120
 
121
121
  - Each installed bundle is recorded at `<system dir>/.bundles/<name>/manifest.json` + `bases/<file>.md` snapshots (`Provenance`). Used only for upgrade `Merger` and `status`/`diff`. Build does **not** write provenance.
122
- - `index.md` is best-effort — failures are swallowed (`Installer#update_target_index`).
122
+ - A bundle records only the files it wrote. `index.md` updates aren't fatal: a failed one is a warning in the install/uninstall output.
123
123
 
124
124
  ## Best practices for the agent
125
125
 
@@ -3,6 +3,7 @@
3
3
  ## Start here
4
4
  - `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
5
5
  memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
6
+ Its `model` row is this session's model (live after `/model`), `model key` my overlay key.
6
7
  - My current session id is in the system prompt; resume with `chi --resume <id>`.
7
8
  - My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
8
9
  next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
@@ -25,7 +26,8 @@
25
26
  - `delegate` hands a task to a child session that runs in parallel and returns only its final
26
27
  reply (`delegate_result` waits for it; `session:` sends a follow-up to a child). A child is a
27
28
  normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
28
- with `chi --attach <id>`; see `docs/sessions.md` "Delegating".
29
+ with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
30
+ approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
29
31
  - Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
30
32
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
31
33
  cards, side answers (`ask_model`),
@@ -41,7 +41,8 @@ module Samagotchi
41
41
  used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
42
42
  end
43
43
 
44
- attr_reader :limit
44
+ # @return [Integer] the budget, and the attempts spent this turn
45
+ attr_reader :limit, :attempts
45
46
 
46
47
  def initialize(limit: self.class.limit)
47
48
  @limit = limit