samagotchi 0.17.0 → 0.18.1

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +25 -2
  5. data/docs/cli.md +41 -9
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +59 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/memory.md +5 -3
  10. data/docs/plugins.md +3 -2
  11. data/docs/sessions.md +6 -5
  12. data/docs/sub-agent.md +42 -4
  13. data/lib/samagotchi/answer_command.rb +23 -2
  14. data/lib/samagotchi/answer_tail.rb +52 -0
  15. data/lib/samagotchi/bridge.rb +49 -8
  16. data/lib/samagotchi/bridge_client.rb +18 -3
  17. data/lib/samagotchi/bundle_command.rb +108 -2
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/client.rb +58 -10
  23. data/lib/samagotchi/commands/registry.rb +28 -0
  24. data/lib/samagotchi/config.rb +7 -1
  25. data/lib/samagotchi/continue_offer.rb +177 -0
  26. data/lib/samagotchi/engine.rb +68 -22
  27. data/lib/samagotchi/guardrail_wiring.rb +56 -3
  28. data/lib/samagotchi/guardrails/outside.rb +46 -0
  29. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  30. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  31. data/lib/samagotchi/guardrails/rules.rb +17 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  33. data/lib/samagotchi/guardrails/targets.rb +30 -13
  34. data/lib/samagotchi/guardrails.rb +2 -0
  35. data/lib/samagotchi/iteration_limit.rb +38 -0
  36. data/lib/samagotchi/kernel_loop.rb +2 -1
  37. data/lib/samagotchi/llm/backend.rb +2 -1
  38. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  39. data/lib/samagotchi/llm/http.rb +9 -3
  40. data/lib/samagotchi/llm/native_backend.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/trash.rb +81 -2
  42. data/lib/samagotchi/model_profile.rb +2 -2
  43. data/lib/samagotchi/output_formatter.rb +9 -4
  44. data/lib/samagotchi/parent_report.rb +39 -9
  45. data/lib/samagotchi/question_desk.rb +205 -46
  46. data/lib/samagotchi/reply_wait.rb +10 -4
  47. data/lib/samagotchi/send_command.rb +2 -1
  48. data/lib/samagotchi/served_model.rb +14 -4
  49. data/lib/samagotchi/session.rb +3 -2
  50. data/lib/samagotchi/session_commands.rb +3 -2
  51. data/lib/samagotchi/session_manager.rb +4 -1
  52. data/lib/samagotchi/sessions_command.rb +3 -2
  53. data/lib/samagotchi/system_prompt.rb +1 -1
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +33 -8
  55. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  56. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  57. data/lib/samagotchi/terminal_ui.rb +75 -17
  58. data/lib/samagotchi/tool_declarations.rb +4 -4
  59. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  60. data/lib/samagotchi/tools/edit.rb +6 -1
  61. data/lib/samagotchi/tools/execute.rb +102 -23
  62. data/lib/samagotchi/tools/read.rb +2 -1
  63. data/lib/samagotchi/turn_flow.rb +8 -7
  64. data/lib/samagotchi/turn_note.rb +7 -0
  65. data/lib/samagotchi/version.rb +1 -1
  66. data/lib/samagotchi/web/app.rb +187 -42
  67. data/lib/samagotchi/web/public/app.js +122 -25
  68. data/lib/samagotchi/web/public/data.js +12 -13
  69. data/lib/samagotchi/web/public/index.html +6 -1
  70. data/lib/samagotchi/web/public/notify.js +5 -2
  71. data/lib/samagotchi/web/public/question_card.js +28 -1
  72. data/lib/samagotchi/web/public/question_cards.js +74 -3
  73. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  74. data/lib/samagotchi/web/public/timing.js +41 -0
  75. data/lib/samagotchi/web/public/turn_events.js +61 -0
  76. data/lib/samagotchi/web/public/update_notice.js +15 -0
  77. data/lib/samagotchi/worker.rb +30 -58
  78. data/lib/samagotchi/worker_sidecar.rb +6 -3
  79. metadata +9 -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.
@@ -1066,6 +1148,15 @@ module Samagotchi
1066
1148
  bridge_client(session_id)&.get_json(path)
1067
1149
  end
1068
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
+
1069
1160
  # Monotonic SSE cursor for a live bridge session, else nil.
1070
1161
  def bridge_event_seq(session_id)
1071
1162
  bridge_client(session_id)&.event_seq
@@ -1138,9 +1229,12 @@ module Samagotchi
1138
1229
  session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir))
1139
1230
  end
1140
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).
1141
1234
  def timing_payload(session_id, live_metrics: nil)
1142
1235
  persisted = read_analytics(session_id)
1143
- source = if live_metrics.is_a?(Hash)
1236
+ live = live_metrics.is_a?(Hash)
1237
+ source = if live
1144
1238
  persisted.merge(live_metrics).merge(
1145
1239
  "started_at" => persisted["started_at"] || live_metrics["started_at"],
1146
1240
  "turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
@@ -1149,14 +1243,66 @@ module Samagotchi
1149
1243
  else
1150
1244
  persisted
1151
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:)
1152
1297
  started_at = source["started_at"]
1153
1298
  last_activity_at = source["last_activity_at"]
1154
1299
  {
1155
1300
  started_at: started_at,
1156
1301
  last_activity_at: last_activity_at,
1157
- session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live_metrics.is_a?(Hash)),
1158
- turn_records: Array(source["turn_records"]),
1159
- 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,
1160
1306
  active_turn: source["active_turn"],
1161
1307
  active_tools: Array(source["active_tools"]),
1162
1308
  # The context last seen and the token sums, for the ctx meter
@@ -1332,14 +1478,10 @@ module Samagotchi
1332
1478
  MessageParts.for_message(list[index], responses, cwd: cwd)
1333
1479
  end
1334
1480
 
1335
- # The last message messages_for_display shows as an answer, as a list of
1336
- # at most one: walked backwards so only that one is rendered.
1337
- def last_assistant_for_display(msgs)
1338
- Array(msgs).reverse_each do |m|
1339
- shown = messages_for_display([m]).first
1340
- return [shown] if shown && shown[:role] == "assistant"
1341
- end
1342
- []
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)
1343
1485
  end
1344
1486
 
1345
1487
  def parse_json(str)
@@ -1384,7 +1526,9 @@ module Samagotchi
1384
1526
  # folder in a repo, that project's name and root. A bad dir adds
1385
1527
  # nothing here: the list call answers 400 and the page says so.
1386
1528
  def index_data_attributes(req)
1387
- 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 }
1388
1532
  attrs["view"] = view_name(req.params["view"])
1389
1533
  # Always there: an empty one means no presets, not the default.
1390
1534
  attrs["annotate-presets"] = @annotate_presets
@@ -1457,7 +1601,8 @@ module Samagotchi
1457
1601
  end
1458
1602
 
1459
1603
  def each
1460
- 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)
1461
1606
  last_write = monotonic
1462
1607
  loop do
1463
1608
  event = @queue.pop([@heartbeat, EVENTS_POLL].min)
@@ -39,11 +39,12 @@ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
39
39
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
40
40
  import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
41
41
  import { createNotifications } from "./notifications.js";
42
+ import { updateNotice } from "./update_notice.js";
42
43
  import {
43
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
44
+ appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
44
45
  } from "./timing.js";
45
46
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
46
- import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
47
+ import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, startPageReply, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
47
48
 
48
49
  const $ = (s) => document.querySelector(s);
49
50
  const topStripEl = $("#topStrip");
@@ -101,6 +102,15 @@ function findInTurnRoots(selector) {
101
102
  let current = emptySessionState();
102
103
  let turnRunning = false;
103
104
  let liveTurnTimingEl = null;
105
+ // The running turn's id (turn_started's turn_id, the id of its timing
106
+ // record), so a turn-end re-read finishes that turn's line with that
107
+ // turn's record, whatever started since.
108
+ let liveTurnId = null;
109
+ // The turns this page saw end: the records of its last full render, +1 at
110
+ // each turn's end, never fewer than a merge brought. The live line numbers
111
+ // the next one, so a turn queued behind another reads its own number while
112
+ // the other's re-read is still out.
113
+ let turnsEnded = 0;
104
114
  // What the running turn waits on, after its live timing line: a provider
105
115
  // retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
106
116
  // it streams again.
@@ -169,6 +179,7 @@ async function refresh() {
169
179
  function openSessionEvents() {
170
180
  eventsControl = openEvents({
171
181
  snapshot: (data) => {
182
+ noticeUpdate(data.version);
172
183
  notifications.noticeSessions("snapshot", data);
173
184
  allSessions = applySessionEvent(allSessions, "snapshot", data);
174
185
  render();
@@ -199,6 +210,17 @@ function openSessionEvents() {
199
210
  });
200
211
  }
201
212
 
213
+ // chi web was upgraded while this tab stayed open (a snapshot on the
214
+ // reconnect names another version than the one that served the page):
215
+ // a toast offers the reload, never forces it.
216
+ let updateShownFor = null;
217
+ function noticeUpdate(served) {
218
+ const text = updateNotice(document.body.dataset.version, served, updateShownFor);
219
+ if (!text) return;
220
+ updateShownFor = served;
221
+ showToast(text, { label: "Reload", run: () => location.reload() }, { sticky: true });
222
+ }
223
+
202
224
  // The selected session's card changed: its owner and, without a live
203
225
  // stream, its status show in the info bar; a worker whose bridge is up
204
226
  // (a turn another client sent woke one, or a replacement after a kill)
@@ -284,7 +306,7 @@ function cardHtml(s, { deletable = false } = {}) {
284
306
  const ctx = cardCtxText(s.ctx_pct);
285
307
  const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
286
308
  const waiting = waitingBadge(s);
287
- const waitingHtml = waiting ? `<span class="attn ${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
309
+ const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
288
310
  const dotWord = waiting ? waiting.title : s.status;
289
311
  return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
290
312
  }
@@ -605,6 +627,7 @@ function clearSelection() {
605
627
  resetSessionState();
606
628
  clearTimingInterval();
607
629
  liveTurnTimingEl = null;
630
+ liveTurnId = null;
608
631
  setTurnRunning(false);
609
632
  closeStream();
610
633
  setMarkdownWarning(null);
@@ -658,6 +681,7 @@ function resetTurnView() {
658
681
  closeStream();
659
682
  clearTimingInterval();
660
683
  liveTurnTimingEl = null;
684
+ liveTurnId = null;
661
685
  questionCards.removeQuestion();
662
686
  questionCards.forgetContinue();
663
687
  unmatchedMerge = false;
@@ -715,6 +739,8 @@ function renderSessionView(data) {
715
739
  current.dir = s.working_directory;
716
740
  current.owner = s.owner || null;
717
741
  current.timing = normalizeTiming(data.timing);
742
+ turnsEnded = current.timing.turnRecords.length;
743
+ const activeTurnId = current.timing.activeTurn?.id || null;
718
744
  // The saved fill, so a reloaded or stopped session shows it before its
719
745
  // next turn (a running turn's replay below streams over it).
720
746
  const savedPct = savedCtxPct(data.timing?.context);
@@ -767,7 +793,10 @@ function renderSessionView(data) {
767
793
  replay.forEach((event) => streamHandlers[event.type]?.(event));
768
794
  if (!data.current_turn) {
769
795
  setTurnRunning(current.status === "running");
770
- if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at);
796
+ if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at, activeTurnId);
797
+ } else if (turnRunning) {
798
+ // The replayed turn_started carries no id: the running turn's record's.
799
+ liveTurnId = activeTurnId;
771
800
  }
772
801
  turnDom.flush();
773
802
  snapshotCards.current.forEach((card) => upsertCard(card));
@@ -818,13 +847,18 @@ let turnEndRead = Promise.resolve();
818
847
  let displayLanded = null;
819
848
  async function readTurnEnd() {
820
849
  if (!selected) return;
850
+ const id = selected;
851
+ // The line and the turn this read is for, before the await: a turn
852
+ // queued behind this one may start (a new live line) before it lands.
853
+ const line = { el: liveTurnTimingEl, turnId: liveTurnId };
821
854
  try {
822
- const d = await getSession(selected, { tail: true });
855
+ const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
856
+ if (id !== selected) return;
823
857
  if (d.session) {
824
858
  if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
825
859
  if (d.session.status) current.status = d.session.status;
826
- applyTiming(d.timing);
827
- finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
860
+ if (!(await mergeTurnTiming(id, d.timing))) return;
861
+ finishTurnLine(line);
828
862
  setMarkdownWarning(d.markdown_warning);
829
863
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
830
864
  updateInfoBar();
@@ -913,6 +947,9 @@ function emptyAnswerEl(emptyAnswer) {
913
947
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
914
948
  function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
915
949
  setTurnRunning(false);
950
+ // Every ended turn has a record (completed, canceled, failed); a worker
951
+ // gone mid-turn is re-rendered (resync), which counts again.
952
+ if (kind !== "gone") turnsEnded += 1;
916
953
  // A completed turn with no answer: its notice where the answer goes
917
954
  // (the turn view places it), else after the turn; before the timing.
918
955
  const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
@@ -1356,11 +1393,12 @@ function clearTimingInterval() {
1356
1393
 
1357
1394
  let liveTurnNumber = null;
1358
1395
 
1359
- function startLiveTurnTiming(startedAt = new Date().toISOString()) {
1396
+ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
1360
1397
  clearTimingInterval();
1361
- current.timing.activeTurn = { started_at: startedAt };
1362
- // The turn records hold the finished turns, so this one comes next.
1363
- liveTurnNumber = current.timing.turnRecords.length + 1;
1398
+ current.timing.activeTurn = turnId ? { id: turnId, started_at: startedAt } : { started_at: startedAt };
1399
+ liveTurnId = turnId || null;
1400
+ // The turns ended so far, so this one comes next.
1401
+ liveTurnNumber = turnsEnded + 1;
1364
1402
  liveTurnNote = "";
1365
1403
  liveTurnTimingEl = document.createElement("div");
1366
1404
  liveTurnTimingEl.className = "turn-timing live";
@@ -1410,11 +1448,46 @@ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
1410
1448
  updateInfoBar();
1411
1449
  }
1412
1450
 
1413
- function applyTiming(data) {
1414
- current.timing = normalizeTiming(data);
1415
- if (turnRunning && current.timing.activeTurn?.started_at) {
1416
- startLiveTurnTiming(current.timing.activeTurn.started_at);
1451
+ // A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
1452
+ // and turn_count) merged into the page's (mergeTiming); when the merge comes
1453
+ // up short, the whole timing (?timing=1). Never (re)starts live timing: a
1454
+ // turn that started meanwhile keeps its line and its own active turn.
1455
+ // @return false when the session changed meanwhile
1456
+ async function mergeTurnTiming(id, data) {
1457
+ const keepActive = () => (turnRunning ? current.timing.activeTurn : null);
1458
+ const merged = mergeTiming(current.timing, data);
1459
+ current.timing = { ...merged.timing, activeTurn: keepActive() };
1460
+ if (merged.short) {
1461
+ try {
1462
+ const full = await getSession(id, { timing: true });
1463
+ if (id !== selected) return false;
1464
+ current.timing = { ...normalizeTiming(full.timing), activeTurn: keepActive() };
1465
+ } catch (_) {
1466
+ if (id !== selected) return false;
1467
+ }
1468
+ }
1469
+ turnsEnded = Math.max(turnsEnded, current.timing.turnRecords.length);
1470
+ return true;
1471
+ }
1472
+
1473
+ // Finish the timing line a turn-end read captured (+line+: {el, turnId})
1474
+ // with that turn's record: its place in the records and its duration (a
1475
+ // canceled one says so). No id (an older worker's events): the last
1476
+ // record. No record: the line stays as the turn's end left it. The ticker
1477
+ // stops only if that line is still the live one.
1478
+ function finishTurnLine(line) {
1479
+ const records = current.timing.turnRecords;
1480
+ const record = line.turnId ? records.find((r) => r.id === line.turnId) : records[records.length - 1];
1481
+ if (line.el === liveTurnTimingEl) {
1482
+ current.timing.activeTurn = null;
1483
+ clearTimingInterval();
1417
1484
  }
1485
+ const duration = Number(record?.duration_ms);
1486
+ if (line.el && record && Number.isFinite(duration)) {
1487
+ line.el.classList.remove("live");
1488
+ line.el.textContent = turnTimingText(records.indexOf(record) + 1, duration, { canceled: record.status === "canceled" });
1489
+ }
1490
+ updateInfoBar();
1418
1491
  }
1419
1492
 
1420
1493
  function updateFirstPeek() {
@@ -1538,7 +1611,7 @@ const streamHandlers = {
1538
1611
  clearTimingInterval();
1539
1612
  liveTurnTimingEl = null;
1540
1613
  turnDom.turnStarted({ ...data, promptBubble });
1541
- startLiveTurnTiming(data.started_at);
1614
+ startLiveTurnTiming(data.started_at, data.turn_id || null);
1542
1615
  }),
1543
1616
  turn_completed: async (data) => {
1544
1617
  endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
@@ -1563,7 +1636,7 @@ const streamHandlers = {
1563
1636
  try {
1564
1637
  await turnEndRead;
1565
1638
  if (!selected || !data.display) return;
1566
- const d = await getSession(selected, { tail: true });
1639
+ const d = await getSession(selected, { tail: true, recent: true });
1567
1640
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1568
1641
  } catch (_) {
1569
1642
  } finally {
@@ -1620,7 +1693,9 @@ const streamHandlers = {
1620
1693
  updateInfoBar();
1621
1694
  }
1622
1695
  if (view.resync) await resync();
1623
- const queued = view.anytime && commandBubble(view.commandId);
1696
+ // A card's action that isn't anytime (the step-limit question's
1697
+ // /continue) has no bubble: nothing to remove.
1698
+ const queued = view.anytime ? commandBubble(view.commandId) : null;
1624
1699
  if (view.hidden) queued?.remove();
1625
1700
  else if (queued) drawCommandBubble(queued, view);
1626
1701
  else addCommandBubble(view);
@@ -1700,11 +1775,13 @@ const streamHandlers = {
1700
1775
 
1701
1776
  async function refreshTimingFromSession() {
1702
1777
  if (!selected) return;
1778
+ const id = selected;
1779
+ const line = { el: liveTurnTimingEl, turnId: liveTurnId };
1703
1780
  try {
1704
- const data = await getSession(selected, { tail: true });
1781
+ const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
1782
+ if (id !== selected) return;
1705
1783
  if (data.session?.status) current.status = data.session.status;
1706
- applyTiming(data.timing);
1707
- finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
1784
+ if (await mergeTurnTiming(id, data.timing)) finishTurnLine(line);
1708
1785
  } catch (_) {}
1709
1786
  }
1710
1787
 
@@ -1745,6 +1822,13 @@ async function handleSendTurn() {
1745
1822
  // An image-only send's text is its placeholders: not for ↑.
1746
1823
  const typed = promptEl.value.trim() !== "";
1747
1824
  if (!selected) {
1825
+ const local = startPageReply(prompt, { images: chips.length });
1826
+ if (local) {
1827
+ promptEl.value = "";
1828
+ fitPrompt();
1829
+ showNotice(local);
1830
+ return;
1831
+ }
1748
1832
  actionBtn.disabled = true;
1749
1833
  try {
1750
1834
  const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
@@ -1758,8 +1842,18 @@ async function handleSendTurn() {
1758
1842
  }
1759
1843
  }
1760
1844
  // A session command by the session's list (the new one's too: select
1761
- // read it); an unknown /word is a prompt, as in a terminal.
1845
+ // read it); an unknown /word is a prompt, as in a terminal — but a typo
1846
+ // of a command (/modle) gets the hint, not a turn.
1762
1847
  if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
1848
+ if (!chips.length) {
1849
+ const typo = unknownCommandHint(prompt, current.commands);
1850
+ if (typo) {
1851
+ addCommandBubble({ label: null, line: prompt, text: typo });
1852
+ promptEl.value = "";
1853
+ fitPrompt();
1854
+ return;
1855
+ }
1856
+ }
1763
1857
  const hint = historyEl.querySelector(".hint");
1764
1858
  if (hint) hint.remove();
1765
1859
  // Sending moves a finished turn out of the stage at once (else the echo
@@ -2190,17 +2284,20 @@ async function setArchived(id, archive, { undo = true } = {}) {
2190
2284
  showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2191
2285
  }
2192
2286
 
2287
+ // sticky: stays until its action or its ✕ (else gone after 6 s).
2193
2288
  let toastTimer = null;
2194
- function showToast(text, action = null) {
2289
+ function showToast(text, action = null, { sticky = false } = {}) {
2195
2290
  const el = $("#toast");
2196
- el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}`;
2291
+ el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2292
+ (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
2197
2293
  el.hidden = false;
2198
2294
  el.querySelector(".toast-action")?.addEventListener("click", () => {
2199
2295
  el.hidden = true;
2200
2296
  action.run();
2201
2297
  });
2298
+ el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
2202
2299
  clearTimeout(toastTimer);
2203
- toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2300
+ if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2204
2301
  }
2205
2302
 
2206
2303
  $("#includeArchived").addEventListener("change", () => {