samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -9,11 +9,13 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../answer_tail"
12
13
  require_relative "../bridge_client"
13
14
  require_relative "../bridge/bounded_queue"
14
15
  require_relative "../bridge/card_store"
15
16
  require_relative "../session"
16
17
  require_relative "../session_manager"
18
+ require_relative "../session_metrics"
17
19
  require_relative "../session_commands"
18
20
  require_relative "../steer"
19
21
  require_relative "../host_registry"
@@ -177,14 +179,18 @@ module Samagotchi
177
179
 
178
180
  # API requests at debug level (not the page and its assets): method,
179
181
  # path, status, time. Never a body or the query; only a tail=1 read
180
- # (the page's end-of-turn re-read) is marked, to tell it from a full one.
182
+ # (the page's end-of-turn re-read) and a timing=1 one (its fallback
183
+ # when the merge came up short) are marked, to tell them from a full one.
181
184
  def log_request(env, response, started)
182
185
  path = env["PATH_INFO"].to_s
183
186
  return unless path.start_with?("/api/") && Log.level?(:debug)
184
187
 
185
- tail = env["QUERY_STRING"].to_s.split("&").include?("tail=1") ? { tail: true } : {}
188
+ params = env["QUERY_STRING"].to_s.split("&")
189
+ marks = {}
190
+ marks[:tail] = true if params.include?("tail=1")
191
+ marks[:timing] = true if params.include?("timing=1")
186
192
  Log.debug(:web, "request", sid: path[%r{\A/api/sessions/([^/]+)}, 1], method: env["REQUEST_METHOD"], path: path,
187
- status: response[0], **tail,
193
+ status: response[0], **marks,
188
194
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
189
195
  end
190
196
 
@@ -518,6 +524,11 @@ module Samagotchi
518
524
  end
519
525
 
520
526
  def handle_show(req, id)
527
+ # The page's light re-reads: never the whole conversation.
528
+ return handle_cards_read(id) if req.params["cards"] == "1"
529
+ return handle_tail_read(req, id) if req.params["tail"] == "1"
530
+ return handle_timing_read(id) if req.params["timing"] == "1"
531
+
521
532
  session = @session_class.load(id, state_dir: default_state_dir)
522
533
  # Read-only preview: selecting a session never spawns a worker.
523
534
  # The worker is woken only on POST /turn (handle_turn) via
@@ -531,11 +542,6 @@ module Samagotchi
531
542
  # behind it, all at one event_seq the client streams on from.
532
543
  live = bridge_get_json(id, "snapshot")
533
544
  turn_snapshot = live && live["snapshot"]
534
- # ?cards=1: the page's re-read when a card arrives, for its rendered
535
- # body (the stream carries the card's plain text only).
536
- if req.params["cards"] == "1"
537
- return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
538
- end
539
545
  snapshot = live && live["session_state_snapshot"]
540
546
  last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
541
547
  current_turn = turn_snapshot && turn_snapshot["current_turn"]
@@ -543,8 +549,9 @@ module Samagotchi
543
549
  # The file's question only while a worker owns the session: one a
544
550
  # dead worker saved can't be answered (its turn is gone), nor a chi
545
551
  # REPL's (it shares nothing with the web).
552
+ # A question between turns (the step-limit one) is the snapshot's own.
546
553
  pending = if turn_snapshot
547
- current_turn && current_turn["pending_question"]
554
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
548
555
  elsif owner&.worker?
549
556
  session.pending_question
550
557
  end
@@ -557,19 +564,6 @@ module Samagotchi
557
564
  end
558
565
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
559
566
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
560
- # ?tail=1: the page's re-read at the end of a turn wants the final
561
- # answer's rendered markdown, the status and the timing, not the
562
- # whole history again (nor a render of every earlier answer).
563
- if req.params["tail"] == "1"
564
- return json_response(200, {
565
- tail: true,
566
- session: session_json,
567
- messages: last_assistant_for_display(raw_messages),
568
- markdown_warning: @markdown_renderer.warning,
569
- timing: timing
570
- })
571
- end
572
-
573
567
  json_response(200, {
574
568
  session: session_json,
575
569
  history: read_history(id),
@@ -585,7 +579,7 @@ module Samagotchi
585
579
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
586
580
  # Plugins' slow setup still running (chi.init): the page's init row.
587
581
  init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
588
- cards: cards_for_display(turn_snapshot, id),
582
+ cards: cards_for_display(turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)),
589
583
  # The composer's / autocomplete; the built-ins until a worker
590
584
  # names its plugins' too.
591
585
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -601,14 +595,101 @@ module Samagotchi
601
595
  error_response(404, "not_found", e.message)
602
596
  end
603
597
 
598
+ # ?cards=1: the page's re-read when a card arrives, for its rendered
599
+ # body (the stream carries the card's plain text only). The worker's
600
+ # cards from its light GET tail, else the saved ones; the session file
601
+ # is only stat'ed (the 404), never parsed.
602
+ def handle_cards_read(id)
603
+ return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
604
+
605
+ tail = live_tail(id)
606
+ json_response(200, { cards: cards_for_display(tail ? tail["cards"] : saved_cards(id)) })
607
+ end
608
+
609
+ # ?tail=1: the page's re-read at the end of a turn wants the final
610
+ # answer's rendered markdown, the status and the timing, not the whole
611
+ # history again (nor a render of every earlier answer). Live: the
612
+ # worker's GET tail alone (no session file read); else the file. The
613
+ # session part is what the page reads of it (id, status, used memory
614
+ # names). ?turn_id=: that turn's answer (a queued turn's page re-reads
615
+ # after the next one started). ?recent=1 (the page since this
616
+ # release): the timing's records are the newest turn's (#recent_timing),
617
+ # which the page merges by id; without it, the whole timing as before.
618
+ def handle_tail_read(req, id)
619
+ return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
620
+
621
+ turn_id = req.params["turn_id"].to_s.strip
622
+ turn_id = nil if turn_id.empty?
623
+ tail = live_tail(id, turn_id: turn_id)
624
+ state = tail && tail["session_state_snapshot"]
625
+ if state.is_a?(Hash)
626
+ session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]) }
627
+ messages = last_assistant_for_display([tail["answer"]].compact)
628
+ else
629
+ session = @session_class.load(id, state_dir: default_state_dir)
630
+ state = nil
631
+ session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names) }
632
+ messages = last_assistant_for_display(session.messages, turn_id: turn_id)
633
+ end
634
+ live_metrics = state && state["metrics"]
635
+ # Without recent=1: a tab opened before this release (it replaces its
636
+ # timing with the reply's). Remove this branch after the next release.
637
+ timing = req.params["recent"] == "1" ? recent_timing(id, live_metrics) : timing_payload(id, live_metrics: live_metrics)
638
+ json_response(200, {
639
+ tail: true,
640
+ session: session_json,
641
+ messages: messages,
642
+ markdown_warning: @markdown_renderer.warning,
643
+ timing: timing
644
+ })
645
+ rescue ArgumentError => e
646
+ error_response(404, "not_found", e.message)
647
+ end
648
+
649
+ # ?timing=1: the whole timing (every record, analytics.json merged with
650
+ # the live ones) and turn_count, when the page's merge comes up short.
651
+ # The worker's GET state, never its messages.
652
+ def handle_timing_read(id)
653
+ return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
654
+
655
+ state = bridge_get_json(id, "state")
656
+ json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
657
+ end
658
+
659
+ def session_exists?(id)
660
+ @session_class.exist?(id, state_dir: default_state_dir)
661
+ rescue ArgumentError
662
+ false
663
+ end
664
+
665
+ # The worker's light read (Bridge GET tail): {"session_state_snapshot",
666
+ # "answer", "cards", "event_seq", "event_id"}. A worker from before the
667
+ # route answers 404 not_found: its GET snapshot, in the same shape (the
668
+ # answer by AnswerTail). Anything else (no worker, a refused connect, a
669
+ # 500, a timeout) is nil: the caller reads the disk, with no second
670
+ # try at a worker that failed once.
671
+ # @return [Hash, nil]
672
+ def live_tail(id, turn_id: nil)
673
+ status, body = bridge_get(id, turn_id ? "tail?turn_id=#{URI.encode_www_form_component(turn_id)}" : "tail")
674
+ return body if status == 200 && body.is_a?(Hash)
675
+ return nil unless status == 404 && body.is_a?(Hash) && body["error"] == "not_found"
676
+
677
+ status, live = bridge_get(id, "snapshot")
678
+ return nil unless status == 200 && live.is_a?(Hash) && live["snapshot"].is_a?(Hash)
679
+
680
+ snap = live["snapshot"]
681
+ { "session_state_snapshot" => live["session_state_snapshot"],
682
+ "answer" => AnswerTail.find(snap["messages"], turn_id: turn_id),
683
+ "cards" => snap["cards"], "event_seq" => snap["event_seq"], "event_id" => snap["event_id"] }
684
+ end
685
+
604
686
  # The worker's last cards and between-turns notices (Bridge
605
- # snapshot[:cards]), else (no live worker) the ones the last worker
606
- # saved in the session's folder (CardStore.saved), a card's body as
687
+ # cards: CardStore#list), else (no live worker) the ones the last
688
+ # worker saved in the session's folder (#saved_cards), a card's body as
607
689
  # body_html: rendered markdown when the renderer is on, else the
608
690
  # escaped text in a <pre>.
609
691
  # @return [Array<Hash>]
610
- def cards_for_display(turn_snapshot, id)
611
- cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
692
+ def cards_for_display(cards)
612
693
  Array(cards).filter_map do |card|
613
694
  next unless card.is_a?(Hash)
614
695
  next card unless card["type"].to_s == "card"
@@ -979,7 +1060,8 @@ module Samagotchi
979
1060
  end
980
1061
 
981
1062
  # GET /api/events: the session list as one SSE stream per tab. The
982
- # first frame is the hub's snapshot (scoped by ?dir= as the list is),
1063
+ # first frame is the hub's snapshot (scoped by ?dir= as the list is,
1064
+ # with chi's version),
983
1065
  # then `session` for an upsert and `session_gone` for a removal, with
984
1066
  # a `: ping` while idle. Frame ids are the hub's seq, for the log's
985
1067
  # sake: there is no replay, a reconnect starts with a fresh snapshot.
@@ -1059,12 +1141,22 @@ module Samagotchi
1059
1141
  port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
1060
1142
  end
1061
1143
 
1062
- # Read the live Engine state over the bridge (raw GET /session/:id/state).
1144
+ # GET a read path of the session's live Bridge (+path+: "state",
1145
+ # "snapshot", "stats", ...).
1063
1146
  # @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
1064
1147
  def bridge_get_json(session_id, path)
1065
1148
  bridge_client(session_id)&.get_json(path)
1066
1149
  end
1067
1150
 
1151
+ # GET a read path of the session's live Bridge with its status
1152
+ # (BridgeClient#get).
1153
+ # @return [Array(Integer, Object)] [status, parsed body]; [nil, nil]
1154
+ # with no live bridge or no reply
1155
+ def bridge_get(session_id, path)
1156
+ client = bridge_client(session_id)
1157
+ client ? client.get(path) : [nil, nil]
1158
+ end
1159
+
1068
1160
  # Monotonic SSE cursor for a live bridge session, else nil.
1069
1161
  def bridge_event_seq(session_id)
1070
1162
  bridge_client(session_id)&.event_seq
@@ -1137,9 +1229,12 @@ module Samagotchi
1137
1229
  session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
1138
1230
  end
1139
1231
 
1232
+ # The whole timing: analytics.json's records merged with a live
1233
+ # worker's (by id), and turn_count, the finished turns (#turn_count).
1140
1234
  def timing_payload(session_id, live_metrics: nil)
1141
1235
  persisted = read_analytics(session_id)
1142
- source = if live_metrics.is_a?(Hash)
1236
+ live = live_metrics.is_a?(Hash)
1237
+ source = if live
1143
1238
  persisted.merge(live_metrics).merge(
1144
1239
  "started_at" => persisted["started_at"] || live_metrics["started_at"],
1145
1240
  "turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
@@ -1148,14 +1243,66 @@ module Samagotchi
1148
1243
  else
1149
1244
  persisted
1150
1245
  end
1246
+ turns = Array(source["turn_records"])
1247
+ timing_from(source, turns, Array(source["tool_records"]), live: live,
1248
+ turn_count: (live && turn_count(live_metrics)) || turns.size)
1249
+ end
1250
+
1251
+ # The timing a ?tail=1&recent=1 read sends: a live worker's recent
1252
+ # records as its metrics hold them (the newest finished turn's and
1253
+ # the unsaved ones, the running turn's finished calls), no
1254
+ # analytics.json read; with no worker the disk's, trimmed to the
1255
+ # newest turn. turn_count tells the page whether its merge missed one
1256
+ # (it then reads ?timing=1).
1257
+ def recent_timing(session_id, live_metrics)
1258
+ if live_metrics.is_a?(Hash)
1259
+ count = turn_count(live_metrics)
1260
+ # A metrics hash without the count: the whole timing.
1261
+ return timing_payload(session_id, live_metrics: live_metrics) unless count
1262
+
1263
+ turns = Array(live_metrics["turn_records"])
1264
+ tools = Array(live_metrics["tool_records"])
1265
+ # An older worker's metrics hold every record (before they held the
1266
+ # recent ones only, never more than LIVE_RECORDS_CAP turns).
1267
+ turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
1268
+ return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
1269
+ end
1270
+
1271
+ persisted = read_analytics(session_id)
1272
+ all = Array(persisted["turn_records"])
1273
+ turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
1274
+ timing_from(persisted, turns, tools, live: false, turn_count: all.size)
1275
+ end
1276
+
1277
+ # The finished turns a live worker counts: its metrics' turns (the
1278
+ # loaded history, its own and the running one) less the running one.
1279
+ # @return [Integer, nil]
1280
+ def turn_count(metrics)
1281
+ turns = metrics["turns"]
1282
+ return nil unless turns.is_a?(Integer)
1283
+
1284
+ [turns - (metrics["active_turn"] ? 1 : 0), 0].max
1285
+ end
1286
+
1287
+ # The newest turn record and its tool records (and the running turn's
1288
+ # finished calls).
1289
+ def newest_turn_records(turns, tools, active_turn)
1290
+ kept = turns.last(1)
1291
+ ids = kept.filter_map { |r| r["id"] if r.is_a?(Hash) }
1292
+ ids << active_turn["id"] if active_turn.is_a?(Hash) && active_turn["id"]
1293
+ [kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
1294
+ end
1295
+
1296
+ def timing_from(source, turn_records, tool_records, live:, turn_count:)
1151
1297
  started_at = source["started_at"]
1152
1298
  last_activity_at = source["last_activity_at"]
1153
1299
  {
1154
1300
  started_at: started_at,
1155
1301
  last_activity_at: last_activity_at,
1156
- session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live_metrics.is_a?(Hash)),
1157
- turn_records: Array(source["turn_records"]),
1158
- tool_records: Array(source["tool_records"]),
1302
+ session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
1303
+ turn_records: turn_records,
1304
+ tool_records: tool_records,
1305
+ turn_count: turn_count,
1159
1306
  active_turn: source["active_turn"],
1160
1307
  active_tools: Array(source["active_tools"]),
1161
1308
  # The context last seen and the token sums, for the ctx meter
@@ -1331,14 +1478,10 @@ module Samagotchi
1331
1478
  MessageParts.for_message(list[index], responses, cwd: cwd)
1332
1479
  end
1333
1480
 
1334
- # The last message messages_for_display shows as an answer, as a list of
1335
- # at most one: walked backwards so only that one is rendered.
1336
- def last_assistant_for_display(msgs)
1337
- Array(msgs).reverse_each do |m|
1338
- shown = messages_for_display([m]).first
1339
- return [shown] if shown && shown[:role] == "assistant"
1340
- end
1341
- []
1481
+ # The last message messages_for_display shows as an answer
1482
+ # (AnswerTail), as a list of at most one: only that one is rendered.
1483
+ def last_assistant_for_display(msgs, turn_id: nil)
1484
+ messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact)
1342
1485
  end
1343
1486
 
1344
1487
  def parse_json(str)
@@ -1383,7 +1526,9 @@ module Samagotchi
1383
1526
  # folder in a repo, that project's name and root. A bad dir adds
1384
1527
  # nothing here: the list call answers 400 and the page says so.
1385
1528
  def index_data_attributes(req)
1386
- attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
1529
+ # version: the chi that served the page; the events snapshot names
1530
+ # the one serving now (an upgraded chi web after a reconnect).
1531
+ attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd), "version" => Samagotchi::VERSION }
1387
1532
  attrs["view"] = view_name(req.params["view"])
1388
1533
  # Always there: an empty one means no presets, not the default.
1389
1534
  attrs["annotate-presets"] = @annotate_presets
@@ -1456,7 +1601,8 @@ module Samagotchi
1456
1601
  end
1457
1602
 
1458
1603
  def each
1459
- yield frame(nil, "snapshot", sessions: @snapshot)
1604
+ # version: the page compares it with the one it was served by.
1605
+ yield frame(nil, "snapshot", sessions: @snapshot, version: Samagotchi::VERSION)
1460
1606
  last_write = monotonic
1461
1607
  loop do
1462
1608
  event = @queue.pop([@heartbeat, EVENTS_POLL].min)