samagotchi 0.150.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +1 -1
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +96 -0
  7. data/docs/internals/bridge.md +53 -17
  8. data/docs/internals/bundles.md +1 -0
  9. data/docs/internals/web-frontend.md +2 -2
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +43 -3
  12. data/lib/samagotchi/block_renderers.rb +115 -0
  13. data/lib/samagotchi/bridge/card_places.rb +93 -0
  14. data/lib/samagotchi/bridge/card_store.rb +219 -122
  15. data/lib/samagotchi/bridge.rb +12 -4
  16. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/engine.rb +20 -2
  19. data/lib/samagotchi/memory_bundle/asset_installer.rb +7 -1
  20. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  21. data/lib/samagotchi/memory_bundle/installed_bundle.rb +8 -5
  22. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  24. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  25. data/lib/samagotchi/memory_bundle/provenance.rb +4 -2
  26. data/lib/samagotchi/session_chain.rb +2 -7
  27. data/lib/samagotchi/session_commands.rb +15 -9
  28. data/lib/samagotchi/session_manager.rb +10 -7
  29. data/lib/samagotchi/steer.rb +17 -0
  30. data/lib/samagotchi/terminal_ui.rb +4 -4
  31. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  32. data/lib/samagotchi/turn_flow.rb +2 -3
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/app.rb +22 -12
  35. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  36. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  37. data/lib/samagotchi/web/message_display.rb +3 -3
  38. data/lib/samagotchi/web/participant_view.rb +1 -1
  39. data/lib/samagotchi/web/public/app.js +2 -0
  40. data/lib/samagotchi/web/public/block_frame.js +202 -0
  41. data/lib/samagotchi/web/public/block_host.js +502 -0
  42. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  43. data/lib/samagotchi/web/public/block_source.js +37 -0
  44. data/lib/samagotchi/web/public/bubbles.js +9 -5
  45. data/lib/samagotchi/web/public/card.js +17 -10
  46. data/lib/samagotchi/web/public/cards_place.js +2 -2
  47. data/lib/samagotchi/web/public/claim_link.js +4 -2
  48. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  49. data/lib/samagotchi/web/public/index.html +2 -0
  50. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  51. data/lib/samagotchi/web/public/text_roots.js +6 -0
  52. data/lib/samagotchi/web/public/turn_html.js +4 -1
  53. data/lib/samagotchi/web/session_show.rb +1 -1
  54. data/lib/samagotchi/worker.rb +4 -2
  55. metadata +10 -1
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../steer"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # Where a CardStore's entries sit in a session's messages: the turns the
8
+ # page draws after each. The messages count, with the rule the page and
9
+ # the recap share (Steer.turn_opening?); the store's turn log only orders
10
+ # the ids. So a turn with no opening (a reminder, a Continue, a failed or
11
+ # rolled-back one, a prompt stopped before the model) counts nothing, and
12
+ # an opening with no id (/continue no's reason, Qwen's recovery line) counts
13
+ # as it does on the page. Nothing orders such an opening against an entry, so
14
+ # an entry says how many it came after (+past_idless+, #idless_after). Pure:
15
+ # no Engine, no file.
16
+ class CardPlaces
17
+ # @param turn_log [Array<String>] the turn ids, oldest first
18
+ # @param messages [Array<Hash>] the messages the page draws
19
+ def initialize(turn_log, messages)
20
+ positions = turn_log.each_with_index.to_h
21
+ @ids = Array(messages).select { |message| Steer.turn_opening?(message) }.map { |message| message[:turn_id] || message["turn_id"] }
22
+ @openings = @ids.size
23
+ @opened = @ids.compact.to_set
24
+ @places = @ids.map { |id| positions[id] }
25
+ index_by_position(@places, turn_log.size)
26
+ @positions = positions
27
+ end
28
+
29
+ # The openings after the last one whose turn is at or before the entry's
30
+ # in the log (none: from the first one whose turn is later; none: 0),
31
+ # less the ones with no id it came after. An entry from an older file
32
+ # (+base_since+) has no id: all the log's turns are later, on top of what
33
+ # it had counted.
34
+ # @param entry [Hash] a store's entry: :turn_id, :base_since
35
+ # @return [Integer]
36
+ def turns_since(entry)
37
+ position = @positions.fetch(entry[:turn_id], -1)
38
+ at_or_before = position >= 0 ? @last_at_or_before[position] : nil
39
+ since = if at_or_before
40
+ @openings - at_or_before - 1 - [entry[:past_idless].to_i, idless_run(at_or_before)].min
41
+ else
42
+ later = @first_after[position + 1]
43
+ later ? @openings - later : 0
44
+ end
45
+ entry[:base_since].to_i + since
46
+ end
47
+
48
+ # The openings with no id right after the one +turn_id+ counts from: what
49
+ # an entry shown now has come after. A rollback can only shorten the run, so
50
+ # a count read back against it never eats a later turn.
51
+ # @return [Integer]
52
+ def idless_after(turn_id)
53
+ position = @positions.fetch(turn_id, -1)
54
+ at_or_before = position >= 0 ? @last_at_or_before[position] : nil
55
+ at_or_before ? idless_run(at_or_before) : 0
56
+ end
57
+
58
+ # Where a log may start and still read an entry at +position+ the same: at
59
+ # the opening that entry counts from, or at the entry's turn with none.
60
+ # @return [Integer] a log position
61
+ def log_start_for(position)
62
+ at_or_before = @last_at_or_before[position]
63
+ at_or_before ? @places[at_or_before] : position
64
+ end
65
+
66
+ # @return [Boolean] whether that turn has an opening in the messages
67
+ def opened?(turn_id)
68
+ @opened.include?(turn_id)
69
+ end
70
+
71
+ private
72
+
73
+ def idless_run(opening)
74
+ @ids.drop(opening + 1).take_while(&:nil?).size
75
+ end
76
+
77
+ # For each log position: the last opening at or before it, and the first
78
+ # one at or after it. One pass each, for the store's many entries.
79
+ def index_by_position(places, size)
80
+ @last_at_or_before = Array.new(size)
81
+ @first_after = Array.new(size + 1)
82
+ places.each_with_index do |place, opening|
83
+ next unless place
84
+
85
+ @last_at_or_before[place] = opening
86
+ @first_after[place] ||= opening
87
+ end
88
+ (1...size).each { |i| @last_at_or_before[i] = [@last_at_or_before[i - 1], @last_at_or_before[i]].compact.max }
89
+ (size - 1).downto(0) { |i| @first_after[i] = [@first_after[i], @first_after[i + 1]].compact.min }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -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,16 +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.
31
- # A !rollback (:turn_rolled_back) takes a kept turn's prompt out again,
32
- # and its count with it. Of a turn that left nothing, its cards and hook
33
- # notices stay as between-turns ones where it was, without their
34
- # actions; its other rows (no step to sit in) go.
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.
35
38
  #
36
39
  # Notices (hook notices, load warnings, "asking again" rows), ✂ rows
37
40
  # and cards (with questions) are capped apart, +capacity+ each, so a
@@ -40,29 +43,21 @@ module Samagotchi
40
43
  # never pushed out: the oldest other card or answered question goes.
41
44
  #
42
45
  # With a +path+ (the Bridge's: FILE in the session's folder) the store
43
- # 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
44
47
  # worker's store starts from them, so a turn's cards and notices outlive
45
48
  # the worker that showed them; the web reads the file (.saved) for a
46
- # session no worker runs. Turns count on from the saved count: the
47
- # placement is relative (turns since), so only the turns after an entry
48
- # matter. The load warnings stay the worker's own (each start announces
49
- # its own), and a question still open when the worker went waits for no
50
- # one: neither is saved or seeded. Same caps as in memory. An entry from
51
- # an earlier worker lists with +earlier+: true (an attached TUI's resync
52
- # 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
53
54
  # during a turn (+in_turn+) comes back without its actions: they belong
54
55
  # to a turn no worker runs any more (check-in's Nudge would steer the
55
56
  # new worker's turn). A card shown between turns (github-pr's offer,
56
57
  # btw, coordinator) keeps its buttons.
57
58
  #
58
- # A turn its worker was killed in never ended here, yet the session keeps
59
- # its prompt (TurnCheckpoint, written before :turn_started) and it ends
60
- # canceled. So +killed_turn_stays+ (a worker's) saves that a turn runs, and
61
- # a store started from such a file counts that turn. A turn that ended
62
- # (a graceful stop's cancel too) saved that it no longer runs. Started
63
- # from the same file again, the count is the same: nothing is written
64
- # until the next event. A REPL keeps no running turn in the session: a kill
65
- # takes the prompt along, so its store saves no such thing.
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).
66
61
  class CardStore
67
62
  CAPACITY = 20
68
63
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
@@ -71,30 +66,40 @@ module Samagotchi
71
66
  LLM_CONTEXT_FIELDS = %i[type moment why freed_tokens tail_tokens staged text groups].freeze
72
67
  FILE = "cards.json"
73
68
  # The events after which the file is written again: the ones that
74
- # change an entry or the turn count.
69
+ # change an entry or the turn log.
75
70
  SAVED_ON = (%i[turn_started card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
76
- question_cancelled question_relay turn_failed turn_rolled_back] + Events::TURN_KEPT).freeze
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
77
74
 
78
75
  # What a session's saved file lists, as a store started from it lists
79
76
  # (no turn running): the web's cards for a session no worker runs.
80
77
  # @param session_dir [String]
78
+ # @param messages [Array<Hash>, nil] the session's messages, for the
79
+ # places (#list)
81
80
  # @return [Array<Hash>] [] without a readable file
82
- def self.saved(session_dir)
83
- 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)
84
83
  end
85
84
 
86
85
  # @param path [String, nil] the file the entries are saved in and
87
86
  # started from; nil keeps them in memory only
88
- # @param killed_turn_stays [Boolean] the session keeps a running turn's
89
- # prompt on disk (a worker's TurnCheckpoint): save that a turn runs
90
- def initialize(capacity: CAPACITY, path: nil, killed_turn_stays: false)
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)
91
93
  @capacity = capacity
92
94
  @path = path
93
- @killed_turn_stays = killed_turn_stays
95
+ @messages = messages
94
96
  @mutex = Mutex.new
95
97
  @entries = []
96
- @turns_done = 0
97
- @running = false
98
+ @turn_log = []
99
+ @running_turn = nil
100
+ @continuing = false
101
+ @failed_turn = nil
102
+ @log_start = nil
98
103
  @iteration = nil
99
104
  @calls = 0
100
105
  seed
@@ -109,72 +114,160 @@ module Samagotchi
109
114
  nil # never break the running turn
110
115
  end
111
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.
112
120
  # @return [Array<Hash>] oldest first: the card's (or notice's) fields
113
- # plus turns_since: and current:, earlier: true for one an earlier
114
- # worker saved, and during: true for a card that
115
- # came while the running turn runs but isn't that turn's (it goes
116
- # after the running turn's prompt)
117
- 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)
118
129
  @mutex.synchronize do
119
- @entries.map do |entry|
120
- turns = entry[:turns]
121
- current = entry[:in_turn] && @running && turns == @turns_done
122
- since = @turns_done - turns - (entry[:in_turn] && !current ? 1 : 0)
123
- listed = entry.except(:turns, :during).merge(turns_since: [since, 0].max, current: current ? true : false)
124
- listed[:during] = true if entry[:during]
125
- listed
126
- end
130
+ places = messages && !@entries.empty? ? CardPlaces.new(@turn_log, messages) : nil
131
+ @entries.filter_map { |entry| listed(entry, places) }
127
132
  end
128
133
  end
129
134
 
130
135
  private
131
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
+
132
173
  def save
133
174
  return unless @path
134
175
 
135
176
  entries = @entries.reject { |entry| entry[:type] == :guardrail_warning }
136
- # A session that never showed one gets no file (the turn count
137
- # only places entries relative to each other).
177
+ # A session that never showed one gets no file.
138
178
  return if entries.empty? && !File.exist?(@path)
139
179
 
140
180
  FileUtils.mkdir_p(File.dirname(@path))
141
- saved = { "version" => 1, "turns" => @turns_done, "entries" => entries }
142
- saved["running"] = true if @killed_turn_stays && @running
181
+ saved = { "version" => 2, "turn_log" => pruned_log(entries), "entries" => entries }
182
+ saved["failed_turn"] = @failed_turn if @failed_turn
143
183
  AtomicFile.write(@path, JSON.generate(saved))
144
184
  rescue StandardError
145
185
  nil # a card that can't be saved still shows live
146
186
  end
147
187
 
148
- # The entries an earlier worker saved (a missing or broken file:
149
- # none), their types as symbols again; a card that came during that
150
- # worker's last turn stays where it was, as after a failed turn, or
151
- # after that turn when the worker was killed in it. A file from before
152
- # "running" has none: no turn counted.
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.
153
213
  def seed
154
214
  return unless @path && File.file?(@path)
155
215
 
156
216
  data = JSON.parse(File.read(@path), symbolize_names: true)
157
- 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)
158
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)
159
236
  killed_in_turn = data[:running] == true
160
- @turns_done = data[:turns] + (killed_in_turn ? 1 : 0)
161
- @entries = data[:entries].filter_map do |entry|
162
- next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
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
163
244
 
164
- entry = entry.merge(type: entry[:type].to_sym, earlier: true)
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)
248
+
249
+ entry = entry.except(:during).merge(type: entry[:type].to_sym, earlier: true)
165
250
  entry = entry.merge(actions: []) if entry[:type] == :card && entry[:in_turn] && entry.key?(:actions)
166
251
  entry unless entry[:type] == :guardrail_warning || open_question?(entry)
167
252
  end
168
- settle_during(after_turn: killed_in_turn)
169
- rescue StandardError
170
- @turns_done = 0
171
- @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? }
172
261
  end
173
262
 
174
263
  def fold(event)
175
264
  case event[:type]
176
265
  when :turn_started
177
- @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
178
271
  @iteration = nil
179
272
  @calls = 0
180
273
  when :generation_started
@@ -183,82 +276,74 @@ module Samagotchi
183
276
  when :tool_call_started
184
277
  @iteration = event[:iteration]
185
278
  @calls = event[:call_index].to_i
186
- # The turns that stay in the conversation (a failed one whose work
187
- # stayed too); a failed one rolled back leaves none.
188
- when *Events::TURN_END
189
- @running = false
190
- kept = Events.kept_turn?(event)
191
- @turns_done += 1 if kept
192
- settle_during(after_turn: kept)
193
- unstep_turn_left_nothing unless kept
194
- when :turn_rolled_back then take_turn_back
279
+ when *Events::TURN_END then end_turn(event)
195
280
  when :card then add_card(event)
196
281
  when :hook_notice then add_notice(event)
197
282
  # What failed to load (guardrails, plugins): before the first turn
198
- # 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.
199
285
  when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
200
- .compact.merge(in_turn: false, turns: @turns_done))
201
- when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running
202
- when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
203
- 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
204
290
  when :question_requested
205
- 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
206
292
  when :question_answered then resolve_question(event[:id], answer: event[:answer])
207
293
  when :question_cancelled then resolve_question(event[:id], cancelled: true, reason: event[:reason])
208
294
  when :question_relay then mark_relay(event[:id], event[:relayed_to])
209
295
  end
210
296
  end
211
297
 
212
- def add_card(event)
213
- card = event.slice(:type, :id, :source, :title, :body, :level, :actions)
214
- index = @entries.index { |entry| entry[:type] == :card && entry[:id] == card[:id] }
215
- if index
216
- # Replaced where it was: the first one's place stays.
217
- old = @entries[index]
218
- @entries[index] = card.merge(updated: true, **old.slice(:in_turn, :turns, :during))
219
- else
220
- entry = card.merge(in_turn: event[:in_turn] ? true : false, turns: @turns_done)
221
- entry[:during] = true if @running && !event[:in_turn]
222
- push(entry)
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)
223
306
  end
307
+ @failed_turn = Events.kept_turn?(event) ? nil : @running_turn
308
+ @running_turn = nil
224
309
  end
225
310
 
226
- # The turn a :during card came in has ended: it counts as after that
227
- # turn when the turn is in the history.
228
- def settle_during(after_turn:)
229
- @entries.each do |entry|
230
- next unless entry.delete(:during)
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
231
317
 
232
- entry[:turns] = @turns_done if after_turn
233
- end
318
+ run = CardPlaces.new(@turn_log, @messages.call).idless_after(@running_turn)
319
+ shown.each { |entry| entry[:past_idless] = run if run.positive? }
234
320
  end
235
321
 
236
- # The last kept turn's prompt left the conversation. An entry from
237
- # after it (a card shown before the !rollback) stays before the next.
238
- def take_turn_back
239
- @turns_done = [@turns_done - 1, 0].max
240
- unstep_turn_left_nothing
241
- @entries.each { |entry| entry[:turns] = [entry[:turns], @turns_done].min }
242
- end
322
+ # The entry's anchor: the running turn, else the last one.
323
+ def anchor = @running_turn || @turn_log.last
243
324
 
244
- # The turn that would be the next one's place has no prompt in the
245
- # history: what it showed isn't a row of a turn any more (a later
246
- # turn would read it as its own, +current+).
247
- def unstep_turn_left_nothing
248
- @entries = @entries.filter_map do |entry|
249
- next entry unless entry[:in_turn] && entry[:turns] == @turns_done
250
-
251
- case entry[:type]
252
- when :card then entry.merge(in_turn: false, **(entry.key?(:actions) ? { actions: [] } : {}))
253
- when :hook_notice then entry.except(:iteration, :calls).merge(in_turn: false)
254
- end
325
+ def add_card(event)
326
+ card = event.slice(:type, :id, :source, :title, :body, :level, :actions, :command)
327
+ index = @entries.index { |entry| entry[:type] == :card && entry[:id] == card[:id] }
328
+ if index
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).
332
+ old = @entries[index]
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))
335
+ else
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
339
+ push(entry)
255
340
  end
256
341
  end
257
342
 
258
343
  def add_notice(event)
259
344
  notice = event.slice(:type, :hook, :text, :level, :fallback_for)
260
- if event[:between_turns] || !@running
261
- 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))
262
347
  else
263
348
  add_turn_row(notice)
264
349
  end
@@ -266,7 +351,9 @@ module Samagotchi
266
351
 
267
352
  # A row of the running turn's current step.
268
353
  def add_turn_row(row)
269
- 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)
270
357
  end
271
358
 
272
359
  def resolve_question(id, **outcome)
@@ -291,6 +378,8 @@ module Samagotchi
291
378
  end
292
379
 
293
380
  def push(entry)
381
+ entry[:after_failed] = true if @failed_turn && !entry[:in_turn]
382
+ note_past_idless(entry) unless @running_turn
294
383
  @entries << entry
295
384
  cap = cap_class(entry)
296
385
  kind = @entries.select { |e| cap_class(e) == cap }
@@ -300,6 +389,14 @@ module Samagotchi
300
389
  @entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
301
390
  end
302
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
+
303
400
  # Which cap +entry+ counts against: :notice, :llm_context or :card.
304
401
  def cap_class(entry)
305
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), killed_turn_stays: true)
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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.150.0
3
+ version: 0.151.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: