samagotchi 0.159.0 → 0.161.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../events"
7
+ require_relative "../state_format"
7
8
  require_relative "card_places"
8
9
 
9
10
  module Samagotchi
@@ -62,6 +63,11 @@ module Samagotchi
62
63
  # count becomes +base_since+, and new openings add to it (#seed_v1).
63
64
  class CardStore
64
65
  CAPACITY = 20
66
+ FORMAT_VERSION = 2
67
+ FORMAT = StateFormat.new(
68
+ version: FORMAT_VERSION,
69
+ members: [StateFormat.required(:type), StateFormat.extra(:extra, in_version: FORMAT_VERSION)]
70
+ )
65
71
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
66
72
  # Capped apart from the notices.
67
73
  LLM_CONTEXT_TYPES = %i[llm_context_edited].freeze
@@ -98,6 +104,8 @@ module Samagotchi
98
104
  @mutex = Mutex.new
99
105
  @entries = []
100
106
  @turn_log = []
107
+ @file_extra = {}
108
+ @format_read_only = false
101
109
  @running_turn = nil
102
110
  @continuing = false
103
111
  @failed_turn = nil
@@ -180,13 +188,14 @@ module Samagotchi
180
188
 
181
189
  def save
182
190
  return unless @path
191
+ return if @format_read_only
183
192
 
184
193
  entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
185
194
  # A session that never showed one gets no file.
186
195
  return if entries.empty? && !File.exist?(@path)
187
196
 
188
197
  FileUtils.mkdir_p(File.dirname(@path))
189
- saved = { "version" => 2, "turn_log" => pruned_log(entries), "entries" => entries }
198
+ saved = @file_extra.merge("version" => FORMAT_VERSION, "turn_log" => pruned_log(entries), "entries" => entries)
190
199
  saved["failed_turn"] = @failed_turn if @failed_turn
191
200
  AtomicFile.write(@path, JSON.generate(saved))
192
201
  rescue StandardError
@@ -222,15 +231,34 @@ module Samagotchi
222
231
  return unless @path && File.file?(@path)
223
232
 
224
233
  data = JSON.parse(File.read(@path), symbolize_names: true)
225
- return unless data.is_a?(Hash) && data[:entries].is_a?(Array)
234
+ return unless data.is_a?(Hash)
235
+
236
+ format_data = data.transform_keys(&:to_s)
237
+ format_data["entries"] = if data[:entries].is_a?(Array)
238
+ data[:entries].map { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : entry }
239
+ else
240
+ data[:entries]
241
+ end
242
+ document = FORMAT.read(format_data, collection: "entries")
243
+ if document.version > FORMAT_VERSION
244
+ raise StateFormat::InvalidDocument,
245
+ "cards.json has format version #{document.version}, newer than supported #{FORMAT_VERSION}; update chi before writing it"
246
+ end
247
+ @file_extra = document.extra.except("version", "entries", "turns", "turn_log", "failed_turn")
248
+ return unless data[:entries].is_a?(Array)
226
249
 
227
250
  if data[:turn_log].is_a?(Array)
228
251
  @turn_log = data[:turn_log].grep(String)
229
252
  @failed_turn = data[:failed_turn] if data[:failed_turn].is_a?(String)
230
- @entries = seeded(data[:entries])
253
+ @entries = seeded(document.entries.map { |entry| entry.to_h.transform_keys(&:to_sym) })
231
254
  elsif data[:turns].is_a?(Integer)
232
255
  seed_v1(data)
233
256
  end
257
+ rescue StateFormat::InvalidDocument => e
258
+ @format_read_only = true
259
+ @turn_log = []
260
+ @entries = []
261
+ Log.warn(:bridge, "card_store_unreadable", error: e.message)
234
262
  rescue StandardError
235
263
  @turn_log = []
236
264
  @entries = []
@@ -18,13 +18,17 @@ module Samagotchi
18
18
  @capacity = [1, capacity].max
19
19
  @mutex = Monitor.new
20
20
  @events = [] # Array<{seq:, data:}>
21
+ @dropped = false
21
22
  end
22
23
 
23
24
  # Append +data+ keyed by +seq+ (drop oldest beyond capacity).
24
25
  def push(seq:, data:)
25
26
  @mutex.synchronize do
26
27
  @events << { seq: seq, data: data }
27
- @events.shift until @events.size <= @capacity
28
+ while @events.size > @capacity
29
+ @events.shift
30
+ @dropped = true
31
+ end
28
32
  nil
29
33
  end
30
34
  end
@@ -45,6 +49,12 @@ module Samagotchi
45
49
  @mutex.synchronize { @events.first&.[](:seq) }
46
50
  end
47
51
 
52
+ # @return [Boolean] whether a push has ever evicted an event: the window
53
+ # no longer starts where the capture did.
54
+ def dropped?
55
+ @mutex.synchronize { @dropped }
56
+ end
57
+
48
58
  def empty?
49
59
  @mutex.synchronize { @events.empty? }
50
60
  end
@@ -161,16 +161,19 @@ module Samagotchi
161
161
  def replay!(io, snapshot_seq)
162
162
  from_seq = @high_water
163
163
  oldest = @ring.oldest_seq
164
- if @foreign_cursor || from_seq > snapshot_seq || (from_seq.positive? && oldest && from_seq < oldest - 1)
164
+ if @foreign_cursor || from_seq > snapshot_seq || (oldest && from_seq < oldest - 1 && (from_seq.positive? || @ring.dropped?))
165
165
  # Cursor is from another worker (epoch), ahead of the engine (a
166
166
  # worker restarted, plain cursor) or behind the
167
167
  # ring's buffered window (overflow): the server cannot replay the
168
168
  # gap, so it emits a reset marker and the client re-syncs. A cursor
169
169
  # at oldest - 1 has seen everything before the window, so it
170
- # replays. A cold
171
- # connect (from_seq == 0) replays the whole served window, and a
172
- # fully caught-up cursor (from_seq == snapshot_seq) is NOT a reset
173
- # case — it gets an empty replay and holds for live events.
170
+ # replays. A cold connect (from_seq == 0) is behind the window too
171
+ # once the ring has evicted an event (it would start mid-turn), so
172
+ # it resets; on a ring that never evicted (its first seq may be
173
+ # above 1: events before the Bridge started) it replays the whole
174
+ # window. A fully caught-up cursor (from_seq == snapshot_seq)
175
+ # is NOT a reset case — it gets an empty replay and holds for live
176
+ # events.
174
177
  emit_reset(io)
175
178
  else
176
179
  @ring.events_in_range(after_seq: from_seq, to_seq: snapshot_seq).each { |record| write_event(io, record[:data]) }
@@ -9,6 +9,7 @@ require "openssl"
9
9
  require "time"
10
10
  require_relative "atomic_file"
11
11
  require_relative "bridge_secret"
12
+ require_relative "command_intake"
12
13
  require_relative "degrade"
13
14
  require_relative "delivery"
14
15
  require_relative "session_inbox"
@@ -159,6 +160,9 @@ module Samagotchi
159
160
  @connection_threads = []
160
161
  @stopped = false
161
162
  @mutex = Monitor.new
163
+ @post_turn_mutex = Mutex.new
164
+ @enqueued_requests = {}
165
+ @enqueued_request_order = []
162
166
  @refused_log_mutex = Mutex.new
163
167
  @refused_logged_at = {}
164
168
  @open_streams = 0
@@ -766,14 +770,23 @@ module Samagotchi
766
770
  def handle_cancel(_session_id, body)
767
771
  # Optional reason from JSON body
768
772
  reason = :manual
773
+ turn_id = nil
769
774
  if body && !body.strip.empty?
770
775
  parsed = parse_json(body)
771
776
  r = parsed.is_a?(Hash) ? (fetched(parsed, "reason") || fetched(parsed, "cancellation_reason")) : nil
772
777
  reason = CANCEL_REASONS.include?(r.to_s.strip) ? r.to_s.strip.to_sym : :manual
778
+ turn_id = fetched(parsed, "turn_id").to_s.strip if parsed.is_a?(Hash)
779
+ turn_id = nil if turn_id && turn_id.empty?
773
780
  end
774
781
 
775
782
  if @engine.turn_running? && @engine.active_cancel_controller
776
- ok = @engine.cancel_current_turn!(reason)
783
+ # With a turn_id the engine compares it to the running turn's under
784
+ # its lock; clients that send none (older ones) cancel whichever runs.
785
+ ok = turn_id ? @engine.cancel_current_turn!(reason, turn_id: turn_id) : @engine.cancel_current_turn!(reason)
786
+ if ok == :turn_mismatch
787
+ return [{}, 409, { error: "turn_mismatch", detail: "the running turn is not the one named", session_id: @session_id }]
788
+ end
789
+
777
790
  return [{}, 202, { status: "cancel_requested", session_id: @session_id, reason: reason.to_s }] if ok
778
791
 
779
792
  [{}, 409, { error: "cancel_failed", detail: "could not cancel", session_id: @session_id }]
@@ -1195,34 +1208,60 @@ module Samagotchi
1195
1208
  # A session command sent as a message (chi send -m "/model x", chi -p,
1196
1209
  # a UI whose command list lags) runs as the command, as typed in a
1197
1210
  # TUI; an unknown /word stays a prompt. Not with images: those are
1198
- # for the model.
1211
+ # for the model. Only from a sender that may send commands
1212
+ # (CommandIntake.allowed?: a person, chi answer): a delegate's, a
1213
+ # schedule's or a plugin's /word line is a prompt, its shell line
1214
+ # refused (403, nothing queued).
1199
1215
  if images.empty? && @on_command && command_registry.command?(prompt.to_s)
1200
- return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
1216
+ if CommandIntake.allowed?(client_id)
1217
+ return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
1218
+ elsif CommandIntake.shell_line?(prompt)
1219
+ Log.warn(:bridge, "shell_line_refused", client_id: client_id)
1220
+ return [{}, 403, { error: CommandIntake::REFUSED_ERROR, detail: CommandIntake::REFUSED }]
1221
+ end
1201
1222
  end
1202
1223
 
1203
- enqueued_id = SecureRandom.uuid
1224
+ enqueued_id = fetched(parsed, "enqueued_id")
1225
+ enqueued_id = SessionInbox.new_enqueued_id unless SessionInbox.valid_enqueued_id?(enqueued_id)
1226
+ enqueue_post_turn(sid: sid, prompt: prompt, client_id: client_id, no_interrupt: no_interrupt, author: author,
1227
+ anchors: anchors, images: images, delivery: delivery, deadline: deadline, pending_limit: pending_limit,
1228
+ enqueued_id: enqueued_id)
1229
+ end
1230
+
1231
+ def enqueue_post_turn(sid:, prompt:, client_id:, no_interrupt:, author:, anchors:, images:, delivery:, deadline:,
1232
+ pending_limit:, enqueued_id:)
1204
1233
  # Before it is queued: a drain after this may have taken it (#cut_for_steer).
1205
- epoch = @engine.input_epoch
1206
- # Whether a queue waits for a running turn: read with the write under
1207
- # the event log, so no :turn_started or :turn_completed falls between.
1208
- # To an idle worker the queued file simply runs as the next turn.
1209
- queued_behind_turn = false
1210
- # Write and announce with the event log held: the worker can't emit
1211
- # this turn's :turn_started (or merge it mid-turn) before
1212
- # :turn_enqueued, and a failed write announces nothing.
1213
- enqueued = @engine.synchronize_events do
1214
- raise IntakeClosed if stopped?
1215
- next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
1216
- next :too_many if pending_limit && pending_turns(author) >= pending_limit
1217
-
1218
- # The owner's input never goes into a participant's read-only
1219
- # review turn (a steer, a cut): it queues behind it as the owner's
1220
- # own turn, and the ack and :turn_enqueued say so.
1221
- delivery = Delivery::QUEUE if @accumulator.participant_turn?
1222
- queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
1223
- enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1224
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors).tap do |ok|
1225
- next unless ok
1234
+ @post_turn_mutex.synchronize do
1235
+ epoch = @engine.input_epoch
1236
+ # Whether a queue waits for a running turn: read with the write under
1237
+ # the event log, so no :turn_started or :turn_completed falls between.
1238
+ # To an idle worker the queued file simply runs as the next turn.
1239
+ queued_behind_turn = false
1240
+ # Write and announce with the event log held: the worker can't emit
1241
+ # this turn's :turn_started (or merge it mid-turn) before
1242
+ # :turn_enqueued, and a failed write announces nothing.
1243
+ enqueued = @engine.synchronize_events do
1244
+ raise IntakeClosed if stopped?
1245
+
1246
+ existing = @enqueued_requests[enqueued_id]
1247
+ if existing
1248
+ next(existing[:prompt] == prompt.to_s ? :duplicate : :conflict)
1249
+ end
1250
+ next :duplicate if @engine.enqueued_id?(enqueued_id)
1251
+ next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
1252
+ next :too_many if pending_limit && pending_turns(author) >= pending_limit
1253
+ next :duplicate unless @engine.reserve_enqueued_id(enqueued_id)
1254
+
1255
+ # The owner's input never goes into a participant's read-only
1256
+ # review turn (a steer, a cut): it queues behind it as the owner's
1257
+ # own turn, and the ack and :turn_enqueued say so.
1258
+ delivery = Delivery::QUEUE if @accumulator.participant_turn?
1259
+ queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
1260
+ unless enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1261
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors)
1262
+ @engine.forget_enqueued_id(enqueued_id)
1263
+ next false
1264
+ end
1226
1265
 
1227
1266
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
1228
1267
  enqueued_event[:images] = images unless images.empty?
@@ -1230,26 +1269,43 @@ module Samagotchi
1230
1269
  enqueued_event[:author] = author if author
1231
1270
  @engine.announce(enqueued_event)
1232
1271
  @on_input&.call
1272
+ true
1273
+ end
1274
+ if enqueued == :duplicate
1275
+ ack = @enqueued_requests.dig(enqueued_id, :ack) ||
1276
+ { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }
1277
+ return [{}, 202, ack]
1278
+ end
1279
+ if enqueued == :conflict
1280
+ return [{}, 409, { error: "enqueued_id_conflict",
1281
+ detail: "enqueued_id was already accepted for a different prompt" }]
1233
1282
  end
1283
+ return deadline_passed("turn") if enqueued == :expired
1284
+ return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
1285
+ return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
1286
+
1287
+ # Queued (outside the event lock): a message sent as +cut+ cuts a
1288
+ # running generation that has only been thinking for long, unless a
1289
+ # plugin or a client chi doesn't know sent it. An image input never
1290
+ # merges mid-turn, so it never cuts. Nothing else cuts: a message
1291
+ # that says nothing (an older client, the web composer) goes in at
1292
+ # the running turn's next step.
1293
+ cut = nil
1294
+ cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
1295
+ # A queue to an idle worker is a turn now: the ack says next_step.
1296
+ ack_delivery = Delivery.queue?(delivery) && !queued_behind_turn ? Delivery::NEXT_STEP : delivery
1297
+ ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: ack_delivery }
1298
+ ack[:cut] = cut.to_s if cut
1299
+ ack[:cut_after] = @engine.steer_cut_after if cut == :waits
1300
+ remember_enqueued_request(enqueued_id, prompt, ack)
1301
+ [{}, 202, ack]
1234
1302
  end
1235
- return deadline_passed("turn") if enqueued == :expired
1236
- return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
1237
- return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
1238
-
1239
- # Queued (outside the event lock): a message sent as +cut+ cuts a
1240
- # running generation that has only been thinking for long, unless a
1241
- # plugin or a client chi doesn't know sent it. An image input never
1242
- # merges mid-turn, so it never cuts. Nothing else cuts: a message
1243
- # that says nothing (an older client, the web composer) goes in at
1244
- # the running turn's next step.
1245
- cut = nil
1246
- cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
1247
- # A queue to an idle worker is a turn now: the ack says next_step.
1248
- ack_delivery = Delivery.queue?(delivery) && !queued_behind_turn ? Delivery::NEXT_STEP : delivery
1249
- ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: ack_delivery }
1250
- ack[:cut] = cut.to_s if cut
1251
- ack[:cut_after] = @engine.steer_cut_after if cut == :waits
1252
- [{}, 202, ack]
1303
+ end
1304
+
1305
+ def remember_enqueued_request(id, prompt, ack)
1306
+ @enqueued_requests[id] = { prompt: prompt.to_s, ack: ack }
1307
+ @enqueued_request_order << id
1308
+ @enqueued_requests.delete(@enqueued_request_order.shift) while @enqueued_requests.size > TurnState::ENQUEUED_ID_HISTORY
1253
1309
  end
1254
1310
 
1255
1311
  # POST /session/:id/turn/dequeue {enqueued_id, client_id?, deadline?}:
@@ -1281,7 +1337,10 @@ module Samagotchi
1281
1337
  next [:expired, nil] if expired?("dequeue_expired", deadline, sid: session_id, enqueued_id: id)
1282
1338
 
1283
1339
  SessionInbox.take_queued_input(session_dir, id).tap do |status, _|
1284
- @engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id) if status == :removed
1340
+ if status == :removed
1341
+ @engine.drop_enqueued_id(id)
1342
+ @engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id)
1343
+ end
1285
1344
  end
1286
1345
  end
1287
1346
  dequeue_reply(taken, id, input)
@@ -8,6 +8,7 @@ require_relative "degrade"
8
8
  require_relative "worker_sidecar"
9
9
  require_relative "bridge_secret"
10
10
  require_relative "delivery"
11
+ require_relative "session_inbox"
11
12
  require_relative "chi_invocation"
12
13
  require_relative "bridge_client/stream_open"
13
14
  require_relative "bridge_client/sse_parser"
@@ -197,9 +198,10 @@ module Samagotchi
197
198
  # many of their turns may be running or waiting (429 too_many_turns past it)
198
199
  # @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
199
200
  # turn's draft: their own body keys ("verify", "revises")
200
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
201
- anchors: nil)
202
- body = { session_id: @session_id, prompt: prompt, client_id: client_id }
201
+ def post_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil,
202
+ pending_limit: nil, anchors: nil)
203
+ body = { session_id: @session_id, prompt: prompt, client_id: client_id,
204
+ enqueued_id: enqueued_id || SessionInbox.new_enqueued_id }
203
205
  body[:no_interrupt] = true if no_interrupt
204
206
  body[:author] = author if author
205
207
  body[:pending_limit] = pending_limit if author && pending_limit
@@ -246,10 +248,11 @@ module Samagotchi
246
248
  end
247
249
 
248
250
  # POST /session/:id/cancel. 202 = requested (body names the reason the
249
- # Bridge took), 409 = no active turn.
251
+ # Bridge took), 409 = no active turn, or (error "turn_mismatch") not the
252
+ # +turn_id+ named: nothing was cancelled.
250
253
  # @return [Response]
251
- def cancel(reason:)
252
- post("cancel", { reason: reason }, read_body: true)
254
+ def cancel(reason:, turn_id: nil)
255
+ post("cancel", { reason: reason, turn_id: turn_id }.compact, read_body: true)
253
256
  end
254
257
 
255
258
  # POST /session/:id/tasks/stop: stop a background task this session's
@@ -46,7 +46,8 @@ module Samagotchi
46
46
  session = Session.load(recipient.id, state_dir: state_dir)
47
47
  prompts = user_texts(session.messages)
48
48
  branch = GitHead.branch(session.working_directory)
49
- sources = ContextSources.attached(session.id, project_root: session.project_root, state_dir: state_dir)
49
+ sources = ContextSources.attached(session.id, project_root: session.project_root, project_id: session.project_id,
50
+ state_dir: state_dir)
50
51
  .map { |attached| [attached.name, attached.source.hint] }
51
52
  ScopeCard.new(id: session.id, project: recipient.project && File.basename(recipient.project),
52
53
  folder: folder(session.working_directory, recipient.project), branch: branch,
@@ -249,13 +249,20 @@ module Samagotchi
249
249
  # +decisions+, prints a line per recipient and the summary, and logs it.
250
250
  def deliver_all(broadcast, decisions, cards)
251
251
  ok = true
252
+ # Two links of one chain are one session: the note goes once (Head).
253
+ got = {}
252
254
  lines = decisions.map do |d|
253
255
  next [d, "skipped", d.verdict.reason] unless d.deliver?
254
256
 
257
+ head = SessionInbox::Head.resolve(:note, d.recipient.id, state_dir: @state_dir)
258
+ next [d, "skipped", "#{head.id[0, 8]}, its chain's newest link, got it already"] if got[head.id]
259
+
255
260
  result = NoteDelivery.deliver(d.recipient.id, text: body(broadcast.text, d.verdict), source: SOURCE,
256
- state_dir: @state_dir)
261
+ state_dir: @state_dir, resolved: head)
257
262
  next [d, "skipped", OwnerLock::TUI_BUSY] unless result.delivered?
258
263
 
264
+ got[result.id] = true
265
+
259
266
  [d, "delivered", "#{d.verdict.reason}#{"; waits for its next start" if result.status == :waits}"]
260
267
  rescue SessionInbox::NoteRejected, SystemCallError => e
261
268
  ok = false
@@ -333,7 +340,9 @@ module Samagotchi
333
340
  return 1
334
341
  end
335
342
  ids = sessions.map { |given| resolve_session(given) }
336
- results = ids.compact.uniq.map { |id| deliver_anyway(record, id) }
343
+ # Two links of one chain are one session: the note goes once (Head).
344
+ results = ids.compact.map { |id| SessionInbox::Head.resolve(:note, id, state_dir: @state_dir) }
345
+ .uniq(&:id).map { |head| deliver_anyway(record, head) }
337
346
  unless results.empty?
338
347
  begin
339
348
  @log.append_correction(id: record.id, at: @now, sessions: results)
@@ -359,22 +368,29 @@ module Samagotchi
359
368
  nil
360
369
  end
361
370
 
362
- # @param id [String] a full session id (#resolve_session)
371
+ # @param head [SessionInbox::Resolved] the session named (+asked+, a full
372
+ # id: #resolve_session) and the link that gets it (a continued one's
373
+ # chain's newest)
363
374
  # @return [Broadcast::TriageLog::Delivery]
364
- def deliver_anyway(record, id)
365
- if record.delivered?(id)
375
+ def deliver_anyway(record, head)
376
+ id = head.asked
377
+ if record.delivered?(id) || record.delivered?(head.id)
366
378
  @stdout.puts(line_for(id, "skipped", "it got #{record.id} already"))
367
379
  return Broadcast::TriageLog::Delivery.new(session: id, result: "had it")
368
380
  end
369
381
 
370
- result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir)
382
+ result = NoteDelivery.deliver(id, text: passed_on_body(record), source: SOURCE, state_dir: @state_dir,
383
+ resolved: head)
371
384
  unless result.delivered?
372
385
  @stdout.puts(line_for(id, "skipped", OwnerLock::TUI_BUSY))
373
386
  return Broadcast::TriageLog::Delivery.new(session: id, result: "refused")
374
387
  end
375
388
 
376
- @stdout.puts(line_for(id, "delivered", "by hand#{"; waits for its next start" if result.status == :waits}"))
377
- Broadcast::TriageLog::Delivery.new(session: id, result: "delivered")
389
+ how = "by hand#{"; to #{result.id[0, 8]}, its chain's newest link" if result.continued?}" \
390
+ "#{"; waits for its next start" if result.status == :waits}"
391
+ @stdout.puts(line_for(id, "delivered", how))
392
+ # Logged under the link that has it, so the next deliver finds it.
393
+ Broadcast::TriageLog::Delivery.new(session: result.id, result: "delivered")
378
394
  rescue SessionInbox::NoteRejected, SystemCallError => e
379
395
  @stdout.puts(line_for(id, "failed", e.message))
380
396
  Broadcast::TriageLog::Delivery.new(session: id, result: "failed")
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "cli/flags"
4
4
  require_relative "cli/exit"
5
+ require_relative "live_versions"
5
6
  require_relative "memory_bundle"
6
7
 
7
8
  module Samagotchi
@@ -17,6 +18,7 @@ module Samagotchi
17
18
  Source is the path/URL to the bundle (dir/zip/tar.gz/git).
18
19
  --force overwrites existing entries (one that differs is copied to the trash first); default skips them.
19
20
  A bundle whose requires_chi this chi doesn't meet is refused; --force installs it anyway.
21
+ So is a memory, plugin or script whose bytes disagree with its manifest sha256; --force installs it too.
20
22
 
21
23
  upgrade <source|name> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
22
24
  Upgrade a bundle (3-way: a file you didn't edit updates; one you edited takes a text
@@ -24,6 +26,7 @@ module Samagotchi
24
26
  A bare NAME upgrades the bundle installed under that name from the source it came from.
25
27
  --dry-run shows what would change. --agent launches interactive session on conflict.
26
28
  A bundle whose requires_chi this chi doesn't meet is refused; --force installs it anyway.
29
+ So is a file whose bytes disagree with its manifest sha256; --force installs it too.
27
30
 
28
31
  uninstall <bundle> [--scope system|project] [--force]
29
32
  Remove a bundle and its index entries (--force moves a locally edited file to the trash).
@@ -73,8 +76,8 @@ module Samagotchi
73
76
  Install a memory bundle from a directory, zip, tar archive, or git URL,
74
77
  or a bundle shipped with chi by name (see: chi bundle list).
75
78
  --force overwrites existing entries (one that differs is copied to the trash first); default skips them.
76
- It also installs a bundle whose requires_chi this chi doesn't meet: without --force that install is
77
- refused, and the bundle is not written anywhere.
79
+ It also installs a bundle whose requires_chi this chi doesn't meet and a file whose bytes disagree with
80
+ its manifest sha256: without --force both are refused, and the bundle is not written anywhere.
78
81
  install --dev PATH
79
82
  Install the bundle in the local directory PATH as a link to it: its plugin, rules and scripts
80
83
  are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
@@ -83,8 +86,9 @@ module Samagotchi
83
86
  TEXT
84
87
  UPGRADE_HELP = <<~TEXT
85
88
  Usage: chi bundle upgrade <source|name> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
86
- --force takes the bundle's file over an edited one, and installs a bundle whose requires_chi this chi
87
- doesn't meet (without it such a bundle is refused).
89
+ --force takes the bundle's file over an edited one, installs a bundle whose requires_chi this chi
90
+ doesn't meet, and installs a file whose bytes disagree with its manifest sha256 (without it each is
91
+ refused, nothing written).
88
92
  TEXT
89
93
  UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
90
94
  BUILD_HELP = <<~TEXT
@@ -246,7 +250,12 @@ module Samagotchi
246
250
  end
247
251
  end
248
252
  installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
249
- run_installer(installer, failed: "Upgrade") do |manifest|
253
+ # The record and the manifest as they were/are, for the hint below: it
254
+ # reads which plugin the run touched.
255
+ before = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).record
256
+ manifest = nil
257
+ status = run_installer(installer, failed: "Upgrade") do |run_manifest|
258
+ manifest = run_manifest
250
259
  print_plugin(manifest, plugin: !dry_run)
251
260
  next dry_run_done if dry_run
252
261
 
@@ -256,6 +265,8 @@ module Samagotchi
256
265
 
257
266
  print_provenance(bundle_name, manifest)
258
267
  end
268
+ restart_hint(before, manifest, installer) if !dry_run && status != 1
269
+ status
259
270
  end
260
271
 
261
272
  # The source normalized once (a git clone, a zip or tar extract) and the
@@ -266,6 +277,7 @@ module Samagotchi
266
277
  # @return [Array(MemoryBundle::PreparedSource, String), nil]
267
278
  def prepare_or_fail(expanded_source, word)
268
279
  prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
280
+ Samagotchi::MemoryBundle::Manifest::HooksRetired.refuse_source_files(prepared.dir)
269
281
  named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
270
282
  rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
271
283
  Samagotchi::MemoryBundle::Manifest::ValidationError => e
@@ -299,7 +311,10 @@ module Samagotchi
299
311
  end
300
312
 
301
313
  def installer_for(source, name, scope:, force:, **)
302
- Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true, **)
314
+ # A bundle the user installs: a memory, plugin or script whose bytes
315
+ # disagree with its manifest sha is refused, not warned (--force past it).
316
+ Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true,
317
+ refuse_mismatch: true, **)
303
318
  end
304
319
 
305
320
  # Runs +installer+, prints its summary, then yields the manifest.
@@ -339,6 +354,41 @@ module Samagotchi
339
354
  0
340
355
  end
341
356
 
357
+ # A run wrote or took away a file (Installer#results' words).
358
+ CHANGED_WORDS = %i[installed updated merged removed restored].freeze
359
+
360
+ # Only a bundle's plugin stays in a worker's memory: its rules reload by
361
+ # their stamp (GuardrailWiring#rules_stamp), its scripts run from the files
362
+ # the install replaced, and chi no longer loads class-file hooks. So an
363
+ # upgrade or uninstall that changed the plugin -- or brought one in --
364
+ # leaves the old code in every session already up: one line, after the
365
+ # result, with how many sessions and the command for each. Nothing when the
366
+ # plugin is unchanged or there never was one, nothing with no worker that
367
+ # can be restarted.
368
+ # @param before [InstalledBundle, nil] the bundle's record as it was before
369
+ # the run (an uninstall deletes it, so take it first)
370
+ # @param manifest [MemoryBundle::Manifest, nil] the manifest this run
371
+ # installed; nil for an uninstall
372
+ # @param installer [Installer, nil] the run, for what it wrote to the plugin
373
+ def restart_hint(before, manifest = nil, installer = nil)
374
+ return if before.nil? || before.error?
375
+
376
+ plugin_files = [before.plugin&.file, manifest&.plugin&.dig(:file)].compact.uniq
377
+ changed = if installer
378
+ plugin_files.any? { |file| CHANGED_WORDS.include?(installer.results[file]&.word) }
379
+ else
380
+ !before.plugin.nil?
381
+ end
382
+ return unless changed
383
+
384
+ ids = Samagotchi::LiveVersions.workers.select(&:restart).map { |worker| worker.session_id.to_s[0, 8] }
385
+ return if ids.empty?
386
+
387
+ commands = ids.map { |id| "chi sessions restart #{id}" }
388
+ @stdout.puts "#{ids.size} session#{"s" unless ids.size == 1} " \
389
+ "#{ids.size == 1 ? "runs" : "run"} the old code: #{commands.join(", ")}"
390
+ end
391
+
342
392
  # An upgrade that kept edited files: launch an agent to merge them
343
393
  # (--agent, or yes on a terminal), or say how to take the bundle's.
344
394
  # An upgrade that kept edited files exits 2, as a usage error does
@@ -426,6 +476,7 @@ module Samagotchi
426
476
  @stdout.puts uninstaller.trash_copy_line if uninstaller.trash_copy_line
427
477
  @stdout.puts "Removed: #{uninstaller.removed_files.join(", ")}" unless uninstaller.removed_files.empty?
428
478
  uninstaller.warnings.each { |w| @stdout.puts w }
479
+ restart_hint(record)
429
480
  0
430
481
  rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
431
482
  @stderr.puts "Uninstall failed: #{e.message}"; 1
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.159.0
3
+ version: 0.161.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
8
  self_map.md: sha256:750822b3488b302c6e32638e6224f270d8e06b50ea85cd6e0bb5a6ff12eed1d8
9
9
  config_modification_protocol.md: sha256:44cf7bdac8e526805bff36b76b54fefecd41db30ca525c2ed661d78fe9155dd8
10
- memory_guide.md: sha256:91e8fd248a155afad6b825097d9ee0a0215ba0b79092c8ab25501c0fc19dedfc
10
+ memory_guide.md: sha256:bdd34f34063df1b4ae74f7cda7c31080986d155e535ee4526d673c70edac8292
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -13,7 +13,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
13
13
  | Scope | Path | Use for |
14
14
  |-------|------|---------|
15
15
  | `system` | `$XDG_CONFIG_HOME/samagotchi/memories/`, default `~/.config/samagotchi/memories/` (`MemoryPaths.system_dir`) | User-wide preferences, identity, cross-project knowledge |
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 |
16
+ | `project` | `<system dir>/projects/<key>/` (`MemoryPaths.project_dir`: a git project's `<basename>_<hash>`, `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`; a marked project's id from its `.chi/project.yml`, `<basename>-<8 hex>`, kept when the folder moves) | 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 that folder (not the root: in a linked worktree, work stays in the worktree). A separate clone is a different project. Outside a repository, a folder marked by `chi project init` (its `.chi/project.yml`) is the project for it and every subfolder; outside both, the root is the working directory.
19
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).
@@ -122,7 +122,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
122
122
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and an installed bundle's plugin, rules and scripts. 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. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
123
123
 
124
124
  **Scope resolution for install/build:**
125
- - 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 (`MemoryPaths.scope_dir`).
125
+ - 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/<key>` of the project (`MemoryPaths.scope_dir`).
126
126
 
127
127
  **Example flows:**
128
128
  ```bash