samagotchi 0.150.0 → 0.152.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +5 -5
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +4 -0
  8. data/docs/internals/block-renderers.md +98 -0
  9. data/docs/internals/bridge.md +53 -17
  10. data/docs/internals/bundles.md +49 -14
  11. data/docs/internals/objectives.md +6 -4
  12. data/docs/internals/web-frontend.md +2 -2
  13. data/docs/memory.md +7 -2
  14. data/docs/plugins.md +91 -14
  15. data/docs/releasing.md +7 -2
  16. data/docs/sessions.md +1 -1
  17. data/lib/samagotchi/block_renderers.rb +116 -0
  18. data/lib/samagotchi/bridge/card_places.rb +93 -0
  19. data/lib/samagotchi/bridge/card_store.rb +219 -122
  20. data/lib/samagotchi/bridge.rb +12 -4
  21. data/lib/samagotchi/bundle_command/check.rb +43 -3
  22. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  23. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  24. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  25. data/lib/samagotchi/bundle_command.rb +88 -10
  26. data/lib/samagotchi/bundle_needs.rb +4 -6
  27. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  28. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  36. data/lib/samagotchi/child_env.rb +15 -0
  37. data/lib/samagotchi/context_providers.rb +42 -10
  38. data/lib/samagotchi/engine.rb +47 -6
  39. data/lib/samagotchi/extension_load.rb +4 -3
  40. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  41. data/lib/samagotchi/guardrails/approval.rb +2 -2
  42. data/lib/samagotchi/guardrails/rules.rb +1 -0
  43. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  44. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  45. data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
  46. data/lib/samagotchi/memory_bundle/builder.rb +39 -31
  47. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  48. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  49. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  50. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
  51. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
  52. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  53. data/lib/samagotchi/memory_bundle/installer.rb +91 -16
  54. data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
  55. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  56. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  57. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  58. data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
  59. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  61. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/session_chain.rb +2 -7
  65. data/lib/samagotchi/session_commands.rb +15 -9
  66. data/lib/samagotchi/session_config.rb +5 -4
  67. data/lib/samagotchi/session_manager.rb +10 -7
  68. data/lib/samagotchi/steer.rb +17 -0
  69. data/lib/samagotchi/system_one/record.rb +10 -2
  70. data/lib/samagotchi/terminal_ui.rb +4 -4
  71. data/lib/samagotchi/tools/delegate.rb +20 -2
  72. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  73. data/lib/samagotchi/turn_flow.rb +2 -3
  74. data/lib/samagotchi/update_command.rb +13 -2
  75. data/lib/samagotchi/version.rb +1 -1
  76. data/lib/samagotchi/web/app.rb +24 -13
  77. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  78. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  79. data/lib/samagotchi/web/message_display.rb +3 -3
  80. data/lib/samagotchi/web/participant_view.rb +1 -1
  81. data/lib/samagotchi/web/public/app.js +2 -0
  82. data/lib/samagotchi/web/public/block_frame.js +202 -0
  83. data/lib/samagotchi/web/public/block_host.js +502 -0
  84. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  85. data/lib/samagotchi/web/public/block_source.js +37 -0
  86. data/lib/samagotchi/web/public/bubbles.js +9 -5
  87. data/lib/samagotchi/web/public/card.js +17 -10
  88. data/lib/samagotchi/web/public/cards_place.js +2 -2
  89. data/lib/samagotchi/web/public/claim_link.js +4 -2
  90. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  91. data/lib/samagotchi/web/public/index.html +2 -0
  92. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  93. data/lib/samagotchi/web/public/text_roots.js +6 -0
  94. data/lib/samagotchi/web/public/turn_html.js +4 -1
  95. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  96. data/lib/samagotchi/web/session_hub.rb +6 -0
  97. data/lib/samagotchi/web/session_show.rb +1 -1
  98. data/lib/samagotchi/worker.rb +4 -2
  99. metadata +13 -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,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
@@ -84,6 +84,7 @@ module Samagotchi
84
84
  if manifest
85
85
  missing = check_files(manifest)
86
86
  check_source_tree(manifest, missing) unless @installed
87
+ check_keys(manifest)
87
88
  check_requires_chi(manifest)
88
89
  check_trust(manifest) unless @installed
89
90
  check_clash(manifest)
@@ -147,6 +148,44 @@ module Samagotchi
147
148
  index && (index + 1)
148
149
  end
149
150
 
151
+ # ── the manifest's own keys ───────────────────────────────────────
152
+
153
+ # A key this chi's parser never reads is one it ignores, and a key whose
154
+ # floor is above what requires_chi admits is one an older chi would
155
+ # ignore too. The floor is the author's fix, so an installed bundle gets
156
+ # only the unknown-key line, as check_floor's call floor does today.
157
+ def check_keys(manifest)
158
+ manifest.unknown_keys.each do |key|
159
+ add(:warn, :"unknown-key", "manifest.yml", MemoryBundle::Manifest.unknown_key_line(key), line: manifest_line(key),
160
+ fix: "update chi, or remove #{key}: if it's a typo")
161
+ end
162
+ check_key_floor(manifest) unless @installed
163
+ end
164
+
165
+ # The manifest uses a key the oldest chi requires_chi admits never read.
166
+ # No plugin needed: the key is the bundle's own claim.
167
+ def check_key_floor(manifest)
168
+ key, version = manifest.floor_keys.max_by { |_, floor| Gem::Version.new(floor) }
169
+ return if key.nil?
170
+
171
+ bound = lower_bound(manifest.requires_chi)
172
+ return if bound && Gem::Version.new(version) <= bound
173
+
174
+ add(:warn, :"requires-chi-floor", "manifest.yml",
175
+ "#{manifest.requires_chi ? "requires_chi #{manifest.requires_chi.inspect} admits chi #{bound}," : "no requires_chi,"} " \
176
+ "but the manifest uses #{key_label(key)}, which needs chi #{version}",
177
+ line: manifest_line(key.split(".").first), fix: "requires_chi: \">= #{version}\"")
178
+ end
179
+
180
+ # How to name a SINCE key in a message: a shape is not a key on its own.
181
+ def key_label(key)
182
+ case key
183
+ when "files.description" then "a files: entry given as a mapping"
184
+ when "session_settings.types", "settings.types" then "#{key.split(".").first}: as a mapping of keys to types"
185
+ else "#{key}:"
186
+ end
187
+ end
188
+
150
189
  # ── files ─────────────────────────────────────────────────────────
151
190
 
152
191
  # @return [Boolean] whether a listed file is missing
@@ -340,15 +379,16 @@ module Samagotchi
340
379
  return if manifest.settings.nil? || @settings.empty?
341
380
 
342
381
  require_relative "../bundle_settings_check"
343
- record = MemoryBundle::InstalledBundle.new(name: manifest.name, settings: manifest.settings, setting_types: manifest.setting_types,
344
- session_settings: manifest.session_settings)
345
- BundleSettingsCheck.record_warnings(record, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
382
+ BundleSettingsCheck.record_warnings(manifest, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
346
383
  end
347
384
 
348
385
  def install_warnings(warnings)
349
386
  stale_reported = @findings.any? { |f| f.code == :sha }
350
387
  warnings.each do |warning|
351
388
  next if stale_reported && warning.start_with?("Checksum mismatch")
389
+ # An unknown manifest key is check_keys' row, with the fix the install
390
+ # can't offer; the same condition twice would be two rows.
391
+ next if warning.include?("doesn't know the manifest key")
352
392
  next if @too_new && warning.include?("won't load:")
353
393
  # This machine's PATH, said at install; nothing a chi update changed.
354
394
  next if @installed && warning.start_with?("needs ")
@@ -58,13 +58,17 @@ module Samagotchi
58
58
  private_class_method :in_child
59
59
 
60
60
  # What the child sends: an edited file, which the copy's own shas can't
61
- # tell, and Check's warnings and errors, naming the installed bundle's
62
- # folder rather than the copy's.
61
+ # tell, a manifest key that doesn't parse (the copy holds the mirror's
62
+ # value or none: InstalledManifest#key_problems), and Check's warnings
63
+ # and errors, naming the installed bundle's folder rather than the copy's.
63
64
  def answer_of(bundle)
64
65
  edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
65
66
  Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
66
67
  message: "changed since it was installed, so chi doesn't load it")
67
68
  end
69
+ edited += bundle.manifest.key_problems.values.map do |message|
70
+ Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
71
+ end
68
72
  edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
69
73
  installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
70
74
  edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|