samagotchi 0.149.0 → 0.151.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +59 -24
  7. data/docs/configuration.md +6 -6
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/block-renderers.md +96 -0
  10. data/docs/internals/bridge.md +53 -9
  11. data/docs/internals/bundles.md +43 -14
  12. data/docs/internals/objectives.md +7 -2
  13. data/docs/internals/web-frontend.md +2 -2
  14. data/docs/internals/web-push.md +2 -2
  15. data/docs/internals/working-set.md +5 -2
  16. data/docs/memory.md +48 -10
  17. data/docs/plugins.md +49 -3
  18. data/docs/sessions.md +5 -4
  19. data/docs/testing.md +5 -0
  20. data/lib/samagotchi/atomic_file.rb +3 -0
  21. data/lib/samagotchi/block_renderers.rb +115 -0
  22. data/lib/samagotchi/bootstrap_command.rb +5 -2
  23. data/lib/samagotchi/bridge/card_places.rb +93 -0
  24. data/lib/samagotchi/bridge/card_store.rb +224 -82
  25. data/lib/samagotchi/bridge.rb +12 -4
  26. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  32. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  33. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  34. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  35. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  38. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  39. data/lib/samagotchi/chi_home.rb +4 -2
  40. data/lib/samagotchi/chi_shim.rb +2 -1
  41. data/lib/samagotchi/child_env.rb +10 -3
  42. data/lib/samagotchi/engine.rb +19 -1
  43. data/lib/samagotchi/git_env.rb +43 -0
  44. data/lib/samagotchi/guardrails/context.rb +2 -0
  45. data/lib/samagotchi/guardrails/targets.rb +4 -1
  46. data/lib/samagotchi/memory_bundle/asset_installer.rb +23 -3
  47. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  48. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  49. data/lib/samagotchi/memory_bundle/installed_bundle.rb +70 -6
  50. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  51. data/lib/samagotchi/memory_bundle/installer.rb +162 -52
  52. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  53. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  54. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  55. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  56. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  57. data/lib/samagotchi/memory_bundle/provenance.rb +182 -22
  58. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  59. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  61. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  62. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  63. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  64. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  65. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  67. data/lib/samagotchi/objectives_check.rb +37 -7
  68. data/lib/samagotchi/project_env.rb +3 -2
  69. data/lib/samagotchi/self_report.rb +2 -0
  70. data/lib/samagotchi/session_chain.rb +2 -7
  71. data/lib/samagotchi/session_commands.rb +15 -9
  72. data/lib/samagotchi/session_manager.rb +10 -7
  73. data/lib/samagotchi/steer.rb +17 -0
  74. data/lib/samagotchi/terminal_ui.rb +4 -4
  75. data/lib/samagotchi/testing/boundary.rb +1 -0
  76. data/lib/samagotchi/testing/minitest.rb +3 -0
  77. data/lib/samagotchi/testing/rspec.rb +3 -0
  78. data/lib/samagotchi/testing/sandbox.rb +3 -1
  79. data/lib/samagotchi/tools/delegate.rb +2 -1
  80. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  81. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  82. data/lib/samagotchi/turn_flow.rb +2 -1
  83. data/lib/samagotchi/update_command.rb +76 -28
  84. data/lib/samagotchi/update_hint.rb +4 -1
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +22 -12
  87. data/lib/samagotchi/web/auto_update.rb +8 -8
  88. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  89. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  90. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  91. data/lib/samagotchi/web/message_display.rb +3 -3
  92. data/lib/samagotchi/web/public/app.js +7 -3
  93. data/lib/samagotchi/web/public/block_frame.js +202 -0
  94. data/lib/samagotchi/web/public/block_host.js +502 -0
  95. data/lib/samagotchi/web/public/block_open.js +13 -0
  96. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  97. data/lib/samagotchi/web/public/block_source.js +37 -0
  98. data/lib/samagotchi/web/public/bubbles.js +11 -5
  99. data/lib/samagotchi/web/public/card.js +17 -10
  100. data/lib/samagotchi/web/public/cards_place.js +10 -5
  101. data/lib/samagotchi/web/public/claim_link.js +4 -2
  102. data/lib/samagotchi/web/public/details_motion.js +2 -1
  103. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  104. data/lib/samagotchi/web/public/index.html +20 -14
  105. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  106. data/lib/samagotchi/web/public/stage_model.js +5 -6
  107. data/lib/samagotchi/web/public/stage_view.js +8 -7
  108. data/lib/samagotchi/web/public/text_roots.js +6 -0
  109. data/lib/samagotchi/web/public/turn_block.js +34 -19
  110. data/lib/samagotchi/web/public/turn_html.js +4 -1
  111. data/lib/samagotchi/web/public/turn_model.js +1 -1
  112. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  113. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  114. data/lib/samagotchi/web/push/notifier.rb +7 -9
  115. data/lib/samagotchi/web/session_hub.rb +2 -1
  116. data/lib/samagotchi/web/session_show.rb +1 -1
  117. data/lib/samagotchi/web/update_ops.rb +2 -1
  118. data/lib/samagotchi/worker.rb +4 -2
  119. metadata +17 -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 "card_places"
7
8
 
8
9
  module Samagotchi
9
10
  class Bridge
@@ -22,12 +23,18 @@ module Samagotchi
22
23
  # reload draws the resolved card where it was.
23
24
  #
24
25
  # A card with an earlier card's id replaces it where it was. Each entry
25
- # says where it belongs as the recap does: +turns_since+, the turns
26
- # completed after it (after its own turn for a card shown during one),
27
- # and +current+ for a card of the turn running now. A card that
28
- # isn't the turn's but comes while one runs (an anytime command's, such
29
- # as /btw) goes after that turn: its prompt is already in the history.
30
- # A failed turn leaves no prompt, so its cards stay before the next one.
26
+ # holds the id of the turn it belongs to or follows (+turn_id+: the running
27
+ # turn's, else the last one's, nil before any) and lists with +turns_since+,
28
+ # the turns drawn after it, counted from the messages the page draws
29
+ # (CardPlaces, with the turn log the store keeps), and +current+ for a card
30
+ # of the turn running now. A card that isn't the turn's but comes while one
31
+ # runs (an anytime command's, such as /btw) goes after that turn: its prompt
32
+ # is already in the history. Listed against messages, an in-turn entry of a
33
+ # turn that left no opening there (failed, rolled back, stopped before the
34
+ # model) stays where it was as a between-turns one: a card or hook notice
35
+ # without its actions; its other rows (no step to sit in) aren't listed. A
36
+ # kept Continue or reminder turn never has an opening: its entries are
37
+ # +continued+ and stay rows.
31
38
  #
32
39
  # Notices (hook notices, load warnings, "asking again" rows), ✂ rows
33
40
  # and cards (with questions) are capped apart, +capacity+ each, so a
@@ -36,20 +43,21 @@ module Samagotchi
36
43
  # never pushed out: the oldest other card or answered question goes.
37
44
  #
38
45
  # With a +path+ (the Bridge's: FILE in the session's folder) the store
39
- # saves its entries and turn count there on each change, and a later
46
+ # saves its entries and turn log there on each change, and a later
40
47
  # worker's store starts from them, so a turn's cards and notices outlive
41
48
  # the worker that showed them; the web reads the file (.saved) for a
42
- # session no worker runs. Turns count on from the saved count: the
43
- # placement is relative (turns since), so only the turns after an entry
44
- # matter. The load warnings stay the worker's own (each start announces
45
- # its own), and a question still open when the worker went waits for no
46
- # one: neither is saved or seeded. Same caps as in memory. An entry from
47
- # an earlier worker lists with +earlier+: true (an attached TUI's resync
48
- # onto a new worker doesn't print those again). An earlier card shown
49
+ # session no worker runs. The load warnings stay the worker's own (each
50
+ # start announces its own), and a question still open when the worker went
51
+ # waits for no one: neither is saved or seeded. Same caps as in memory. An
52
+ # entry from an earlier worker lists with +earlier+: true (an attached TUI's
53
+ # resync onto a new worker doesn't print those again). An earlier card shown
49
54
  # during a turn (+in_turn+) comes back without its actions: they belong
50
55
  # to a turn no worker runs any more (check-in's Nudge would steer the
51
56
  # new worker's turn). A card shown between turns (github-pr's offer,
52
57
  # btw, coordinator) keeps its buttons.
58
+ #
59
+ # A file from before the log (version 1) keeps its places: each entry's
60
+ # count becomes +base_since+, and new openings add to it (#seed_v1).
53
61
  class CardStore
54
62
  CAPACITY = 20
55
63
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
@@ -58,27 +66,40 @@ module Samagotchi
58
66
  LLM_CONTEXT_FIELDS = %i[type moment why freed_tokens tail_tokens staged text groups].freeze
59
67
  FILE = "cards.json"
60
68
  # The events after which the file is written again: the ones that
61
- # change an entry or the turn count.
62
- SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
63
- question_cancelled question_relay turn_failed] + Events::TURN_KEPT).freeze
69
+ # change an entry or the turn log.
70
+ SAVED_ON = (%i[turn_started card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
71
+ question_cancelled question_relay] + Events::TURN_END).freeze
72
+ # What only the store reads: never listed.
73
+ INTERNAL_FIELDS = %i[turn_id continued base_since past_idless during after_failed command].freeze
64
74
 
65
75
  # What a session's saved file lists, as a store started from it lists
66
76
  # (no turn running): the web's cards for a session no worker runs.
67
77
  # @param session_dir [String]
78
+ # @param messages [Array<Hash>, nil] the session's messages, for the
79
+ # places (#list)
68
80
  # @return [Array<Hash>] [] without a readable file
69
- def self.saved(session_dir)
70
- new(path: File.join(session_dir, FILE)).list
81
+ def self.saved(session_dir, messages: nil)
82
+ new(path: File.join(session_dir, FILE)).list(messages: messages)
71
83
  end
72
84
 
73
85
  # @param path [String, nil] the file the entries are saved in and
74
86
  # started from; nil keeps them in memory only
75
- def initialize(capacity: CAPACITY, path: nil)
87
+ # @param messages [#call, nil] the session's messages now (Session#messages:
88
+ # a running turn leaves them alone, a change replaces the array, so any
89
+ # thread may read them): where an opening with no id sits against an
90
+ # entry, and how far the saved log must reach. Read at an idle show and a
91
+ # turn's start and end, never for a running turn's row.
92
+ def initialize(capacity: CAPACITY, path: nil, messages: nil)
76
93
  @capacity = capacity
77
94
  @path = path
95
+ @messages = messages
78
96
  @mutex = Mutex.new
79
97
  @entries = []
80
- @turns_done = 0
81
- @running = false
98
+ @turn_log = []
99
+ @running_turn = nil
100
+ @continuing = false
101
+ @failed_turn = nil
102
+ @log_start = nil
82
103
  @iteration = nil
83
104
  @calls = 0
84
105
  seed
@@ -93,67 +114,160 @@ module Samagotchi
93
114
  nil # never break the running turn
94
115
  end
95
116
 
117
+ # @param messages [Array<Hash>, nil] the messages the page draws. Without
118
+ # them (a body re-read, a trace): no turns_since:, nothing to tell a turn
119
+ # that left nothing by.
96
120
  # @return [Array<Hash>] oldest first: the card's (or notice's) fields
97
- # plus turns_since: and current:, earlier: true for one an earlier
98
- # worker saved, and during: true for a card that
99
- # came while the running turn runs but isn't that turn's (it goes
100
- # after the running turn's prompt)
101
- def list
121
+ # plus current:, turns_since: with +messages+, earlier: true for one an
122
+ # earlier worker saved, during: true for a card that came while the
123
+ # running turn runs but isn't that turn's (it goes after the running
124
+ # turn's prompt), and after_failed: true for one shown after the last
125
+ # turn's prompt went back (the page draws that prompt at the end: it goes
126
+ # after it, as it showed live); label: for a card a command showed ("from
127
+ # /short"), for a page whose reload has lost the command's line
128
+ def list(messages: nil)
102
129
  @mutex.synchronize do
103
- @entries.map do |entry|
104
- turns = entry[:turns]
105
- current = entry[:in_turn] && @running && turns == @turns_done
106
- since = @turns_done - turns - (entry[:in_turn] && !current ? 1 : 0)
107
- listed = entry.except(:turns, :during).merge(turns_since: [since, 0].max, current: current ? true : false)
108
- listed[:during] = true if entry[:during]
109
- listed
110
- end
130
+ places = messages && !@entries.empty? ? CardPlaces.new(@turn_log, messages) : nil
131
+ @entries.filter_map { |entry| listed(entry, places) }
111
132
  end
112
133
  end
113
134
 
114
135
  private
115
136
 
137
+ def listed(entry, places)
138
+ current = current?(entry)
139
+ shown = places && left_nothing?(entry, current, places) ? between_turns(entry) : entry
140
+ return unless shown
141
+
142
+ out = shown.except(*INTERNAL_FIELDS)
143
+ out[:turns_since] = places.turns_since(entry) if places
144
+ out[:current] = current
145
+ out[:during] = true if entry[:during]
146
+ out[:after_failed] = true if entry[:after_failed] && entry[:turn_id] == @failed_turn
147
+ out[:label] = "from #{entry[:command]}" if entry[:command]
148
+ out
149
+ end
150
+
151
+ # The turn running now's own. An entry with no anchor is none's: nil ==
152
+ # nil would make every one the running turn's while nothing runs.
153
+ def current?(entry)
154
+ entry[:in_turn] && @running_turn && entry[:turn_id] == @running_turn ? true : false
155
+ end
156
+
157
+ # An in-turn entry whose turn has no opening in the messages, and was no
158
+ # kept Continue or reminder (those never have one).
159
+ def left_nothing?(entry, current, places)
160
+ entry[:in_turn] && !current && !entry[:continued] && entry[:turn_id] && !places.opened?(entry[:turn_id])
161
+ end
162
+
163
+ # Of a turn that left nothing, what stays where it was: a card without
164
+ # its actions (their turn is gone), a hook's notice; its other rows have
165
+ # no step to sit in.
166
+ def between_turns(entry)
167
+ case entry[:type]
168
+ when :card then entry.merge(in_turn: false, **(entry.key?(:actions) ? { actions: [] } : {}))
169
+ when :hook_notice then entry.except(:iteration, :calls).merge(in_turn: false)
170
+ end
171
+ end
172
+
116
173
  def save
117
174
  return unless @path
118
175
 
119
176
  entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
120
- # A session that never showed one gets no file (the turn count
121
- # only places entries relative to each other).
177
+ # A session that never showed one gets no file.
122
178
  return if entries.empty? && !File.exist?(@path)
123
179
 
124
180
  FileUtils.mkdir_p(File.dirname(@path))
125
- AtomicFile.write(@path, JSON.generate({ "version" => 1, "turns" => @turns_done, "entries" => entries }))
181
+ saved = { "version" => 2, "turn_log" => pruned_log(entries), "entries" => entries }
182
+ saved["failed_turn"] = @failed_turn if @failed_turn
183
+ AtomicFile.write(@path, JSON.generate(saved))
126
184
  rescue StandardError
127
185
  nil # a card that can't be saved still shows live
128
186
  end
129
187
 
130
- # The entries an earlier worker saved (a missing or broken file:
131
- # none), their types as symbols again; a card that came during that
132
- # worker's last turn stays where it was, as after a failed turn.
188
+ # The log from the opening the earliest anchor counts from. Found by
189
+ # walking the messages, so a running turn's saves reuse what its start
190
+ # found (#fold forgets it there): its rows would walk them each, on the
191
+ # turn's thread. Nothing in a running turn needs an earlier start: a new
192
+ # entry anchors to it, and an evicted one only leaves the kept part longer.
193
+ def pruned_log(entries)
194
+ @log_start = log_start(entries) unless @running_turn && @log_start
195
+ @turn_log.drop(@log_start)
196
+ end
197
+
198
+ # A turn before that opening orders nothing. 0 when an entry has no anchor,
199
+ # or none does yet (such an entry counts from the log's first turn), or with
200
+ # no messages to find that opening in.
201
+ def log_start(entries)
202
+ positions = @turn_log.each_with_index.to_h
203
+ anchors = entries.map { |entry| positions[entry[:turn_id]] }
204
+ return 0 if anchors.empty? || anchors.include?(nil) || !@messages
205
+
206
+ CardPlaces.new(@turn_log, @messages.call).log_start_for(anchors.min)
207
+ end
208
+
209
+ # The entries an earlier worker saved (a missing or broken file: none),
210
+ # their types as symbols again. A card that came during that worker's
211
+ # last turn stays where it was, as after a failed turn, or after that
212
+ # turn when the worker was killed in it: the messages say which.
133
213
  def seed
134
214
  return unless @path && File.file?(@path)
135
215
 
136
216
  data = JSON.parse(File.read(@path), symbolize_names: true)
137
- return unless data.is_a?(Hash) && data[:turns].is_a?(Integer) && data[:entries].is_a?(Array)
217
+ return unless data.is_a?(Hash) && data[:entries].is_a?(Array)
218
+
219
+ if data[:turn_log].is_a?(Array)
220
+ @turn_log = data[:turn_log].grep(String)
221
+ @failed_turn = data[:failed_turn] if data[:failed_turn].is_a?(String)
222
+ @entries = seeded(data[:entries])
223
+ elsif data[:turns].is_a?(Integer)
224
+ seed_v1(data)
225
+ end
226
+ rescue StandardError
227
+ @turn_log = []
228
+ @entries = []
229
+ end
230
+
231
+ # A file from before the turn log: each entry's place by the count it was
232
+ # saved with, killed turn included (nothing in it can say where a drifted
233
+ # card was). New openings add to it. The next save keeps +base_since+.
234
+ # Can go a retention period after the release that wrote version 2.
235
+ def seed_v1(data)
236
+ killed_in_turn = data[:running] == true
237
+ turns_done = data[:turns] + (killed_in_turn ? 1 : 0)
238
+ olds = data[:entries].select { |entry| entry.is_a?(Hash) && entry[:turns].is_a?(Integer) }
239
+ @entries = seeded(olds.map do |entry|
240
+ turns = entry[:during] && killed_in_turn ? turns_done : entry[:turns]
241
+ entry.except(:turns).merge(base_since: [turns_done - turns - (entry[:in_turn] ? 1 : 0), 0].max)
242
+ end)
243
+ end
138
244
 
139
- @turns_done = data[:turns]
140
- @entries = data[:entries].filter_map do |entry|
141
- next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
245
+ def seeded(entries)
246
+ entries.filter_map do |entry|
247
+ next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && sound_place?(entry)
142
248
 
143
- entry = entry.merge(type: entry[:type].to_sym, earlier: true)
249
+ entry = entry.except(:during).merge(type: entry[:type].to_sym, earlier: true)
144
250
  entry = entry.merge(actions: []) if entry[:type] == :card && entry[:in_turn] && entry.key?(:actions)
145
251
  entry unless entry[:type] == :guardrail_warning || open_question?(entry)
146
252
  end
147
- settle_during(after_turn: false)
148
- rescue StandardError
149
- @turns_done = 0
150
- @entries = []
253
+ end
254
+
255
+ # A hand-edited or foreign file's entry goes, rather than break every later
256
+ # listing of the session.
257
+ def sound_place?(entry)
258
+ return false unless entry[:turn_id].nil? || entry[:turn_id].is_a?(String)
259
+
260
+ entry.values_at(:base_since, :past_idless).compact.all? { |count| count.is_a?(Integer) && !count.negative? }
151
261
  end
152
262
 
153
263
  def fold(event)
154
264
  case event[:type]
155
265
  when :turn_started
156
- @running = true
266
+ @turn_log << event[:turn_id] if event[:turn_id]
267
+ @running_turn = event[:turn_id]
268
+ @continuing = event[:continue] ? true : false
269
+ @failed_turn = nil
270
+ @log_start = nil
157
271
  @iteration = nil
158
272
  @calls = 0
159
273
  when :generation_started
@@ -162,58 +276,74 @@ module Samagotchi
162
276
  when :tool_call_started
163
277
  @iteration = event[:iteration]
164
278
  @calls = event[:call_index].to_i
165
- # The turns that stay in the conversation (a failed one whose work
166
- # stayed too); a failed one rolled back leaves none.
167
- when *Events::TURN_END
168
- @running = false
169
- kept = Events.kept_turn?(event)
170
- @turns_done += 1 if kept
171
- settle_during(after_turn: kept)
279
+ when *Events::TURN_END then end_turn(event)
172
280
  when :card then add_card(event)
173
281
  when :hook_notice then add_notice(event)
174
282
  # What failed to load (guardrails, plugins): before the first turn
175
- # this worker runs, where it showed live.
283
+ # this worker runs, where it showed live. The Engine announces it inside
284
+ # that turn: it anchors before the turn.
176
285
  when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
177
- .compact.merge(in_turn: false, turns: @turns_done))
178
- when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running
179
- when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
180
- when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @running
286
+ .compact.merge(in_turn: false, turn_id: @running_turn ? @turn_log[-2] : @turn_log.last))
287
+ when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running_turn
288
+ when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running_turn
289
+ when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @running_turn
181
290
  when :question_requested
182
- add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
291
+ add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running_turn
183
292
  when :question_answered then resolve_question(event[:id], answer: event[:answer])
184
293
  when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
185
294
  when :question_relay then mark_relay(event[:id], event[:relayed_to])
186
295
  end
187
296
  end
188
297
 
298
+ # The running turn ended. A :during card now reads as after it when its
299
+ # opening is in the messages, and as before the next turn when not. A
300
+ # failed Continue or wake left nothing either, opening or not.
301
+ def end_turn(event)
302
+ settle_past_idless
303
+ @entries.each do |entry|
304
+ entry.delete(:during)
305
+ entry.delete(:continued) if entry[:turn_id] == @running_turn && !Events.kept_turn?(event)
306
+ end
307
+ @failed_turn = Events.kept_turn?(event) ? nil : @running_turn
308
+ @running_turn = nil
309
+ end
310
+
311
+ # What the running turn showed that isn't its own (an anytime command's
312
+ # card) goes after that turn, so after any opening with no id inside it. The
313
+ # end event's messages hold the turn; its own rows count such a line as usual.
314
+ def settle_past_idless
315
+ shown = @entries.select { |entry| entry[:turn_id] == @running_turn && !entry[:in_turn] }
316
+ return if shown.empty? || !@messages
317
+
318
+ run = CardPlaces.new(@turn_log, @messages.call).idless_after(@running_turn)
319
+ shown.each { |entry| entry[:past_idless] = run if run.positive? }
320
+ end
321
+
322
+ # The entry's anchor: the running turn, else the last one.
323
+ def anchor = @running_turn || @turn_log.last
324
+
189
325
  def add_card(event)
190
- card = event.slice(:type, :id, :source, :title, :body, :level, :actions)
326
+ card = event.slice(:type, :id, :source, :title, :body, :level, :actions, :command)
191
327
  index = @entries.index { |entry| entry[:type] == :card && entry[:id] == card[:id] }
192
328
  if index
193
- # Replaced where it was: the first one's place stays.
329
+ # Replaced where it was: the first one's place stays, and whose it is
330
+ # (a command's answer from another thread; a turn's proposal its own
331
+ # button's command replaced, which nobody typed).
194
332
  old = @entries[index]
195
- @entries[index] = card.merge(updated: true, **old.slice(:in_turn, :turns, :during))
333
+ @entries[index] = card.except(:command).merge(updated: true, **old.slice(:in_turn, :turn_id, :during, :continued, :base_since,
334
+ :past_idless, :after_failed, :command))
196
335
  else
197
- entry = card.merge(in_turn: event[:in_turn] ? true : false, turns: @turns_done)
198
- entry[:during] = true if @running && !event[:in_turn]
336
+ entry = card.merge(in_turn: event[:in_turn] ? true : false, turn_id: anchor)
337
+ entry[:during] = true if @running_turn && !event[:in_turn]
338
+ entry[:continued] = true if @running_turn && event[:in_turn] && @continuing
199
339
  push(entry)
200
340
  end
201
341
  end
202
342
 
203
- # The turn a :during card came in has ended: it counts as after that
204
- # turn when the turn is in the history.
205
- def settle_during(after_turn:)
206
- @entries.each do |entry|
207
- next unless entry.delete(:during)
208
-
209
- entry[:turns] = @turns_done if after_turn
210
- end
211
- end
212
-
213
343
  def add_notice(event)
214
344
  notice = event.slice(:type, :hook, :text, :level, :fallback_for)
215
- if event[:between_turns] || !@running
216
- push(notice.merge(in_turn: false, turns: @turns_done))
345
+ if event[:between_turns] || !@running_turn
346
+ push(notice.merge(in_turn: false, turn_id: anchor))
217
347
  else
218
348
  add_turn_row(notice)
219
349
  end
@@ -221,7 +351,9 @@ module Samagotchi
221
351
 
222
352
  # A row of the running turn's current step.
223
353
  def add_turn_row(row)
224
- push(row.merge(in_turn: true, turns: @turns_done, iteration: @iteration, calls: @calls).compact)
354
+ entry = row.merge(in_turn: true, turn_id: @running_turn, iteration: @iteration, calls: @calls).compact
355
+ entry[:continued] = true if @continuing
356
+ push(entry)
225
357
  end
226
358
 
227
359
  def resolve_question(id, **outcome)
@@ -246,6 +378,8 @@ module Samagotchi
246
378
  end
247
379
 
248
380
  def push(entry)
381
+ entry[:after_failed] = true if @failed_turn && !entry[:in_turn]
382
+ note_past_idless(entry) unless @running_turn
249
383
  @entries << entry
250
384
  cap = cap_class(entry)
251
385
  kind = @entries.select { |e| cap_class(e) == cap }
@@ -255,6 +389,14 @@ module Samagotchi
255
389
  @entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
256
390
  end
257
391
 
392
+ # Shown between turns: below the openings with no id already there.
393
+ def note_past_idless(entry)
394
+ return unless @messages
395
+
396
+ run = CardPlaces.new(@turn_log, @messages.call).idless_after(entry[:turn_id])
397
+ entry[:past_idless] = run if run.positive?
398
+ end
399
+
258
400
  # Which cap +entry+ counts against: :notice, :llm_context or :card.
259
401
  def cap_class(entry)
260
402
  return :llm_context if LLM_CONTEXT_TYPES.include?(entry[:type])
@@ -136,7 +136,7 @@ module Samagotchi
136
136
  @accumulator = TurnAccumulator.new
137
137
  # Saved in the session's folder: a later worker, and the web without
138
138
  # one, show the cards where they were.
139
- @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE))
139
+ @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE), messages: -> { @engine.session&.messages })
140
140
  @pending_card = PendingCard.new(session_dir)
141
141
  @heartbeat_interval = heartbeat_interval
142
142
  @epoch = SecureRandom.hex(4)
@@ -315,7 +315,7 @@ module Samagotchi
315
315
  guardrail_warning: @engine.guardrail_warning,
316
316
  plugin_warning: @engine.plugin_warning,
317
317
  init_tasks: @engine.init_tasks,
318
- cards: @cards.list,
318
+ cards: cards_listing,
319
319
  commands: command_registry.listing,
320
320
  chi_version: Samagotchi::VERSION,
321
321
  event_seq: seq,
@@ -339,7 +339,8 @@ module Samagotchi
339
339
  # What the page re-reads at a turn's end and when a card arrives: the
340
340
  # session state, the last answer (Engine#last_answer_message: one
341
341
  # message, not the conversation) and the cards, as one step of the event
342
- # log (one event_seq). Light whatever the session's length: GET tail.
342
+ # log (one event_seq). Light for a long session: GET tail copies no messages,
343
+ # and placing the cards only walks them (about 10 ms at 40k messages).
343
344
  # The session's folder (working_directory) rides along: what the web
344
345
  # checks the answer's file refs against without reading the session file.
345
346
  # @return [Hash] {session_id:, working_directory:, session_state_snapshot:, answer:, cards:, event_seq:, event_id:}
@@ -350,7 +351,7 @@ module Samagotchi
350
351
  state = @engine.session_state_snapshot.merge(event_seq: seq, event_id: event_id(seq))
351
352
  { session_id: @session_id, working_directory: @engine.session&.working_directory,
352
353
  session_state_snapshot: state, answer: @engine.last_answer_message(turn_id: turn_id),
353
- cards: @cards.list, event_seq: seq, event_id: event_id(seq) }
354
+ cards: cards_listing, event_seq: seq, event_id: event_id(seq) }
354
355
  end
355
356
  end
356
357
 
@@ -372,6 +373,13 @@ module Samagotchi
372
373
 
373
374
  private
374
375
 
376
+ # The cards, placed against the messages a snapshot's page draws (the
377
+ # session's own, which a running turn leaves alone). No copy: GET tail
378
+ # stays light.
379
+ def cards_listing
380
+ @cards.list(messages: @engine.session&.messages)
381
+ end
382
+
375
383
  # The turn in progress (TurnAccumulator#current_turn) with the running
376
384
  # generation's phase as generation: {thinking_only:, age:}
377
385
  # (Engine#generation_snapshot), so a page joining mid-generation can
@@ -124,7 +124,7 @@ module Samagotchi
124
124
  end
125
125
 
126
126
  # The listing's placement fields: nothing to place in a one-turn run.
127
- LISTING_FIELDS = %w[turns_since current earlier].freeze
127
+ LISTING_FIELDS = %w[turns_since current earlier after_failed label].freeze
128
128
 
129
129
  # The saved cards as the web reads them (CardStore.saved), with
130
130
  # string keys and values, as the JSON trace prints them.