samagotchi 0.74.0 → 0.76.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +21 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +48 -1
  13. data/docs/plugins.md +2 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +7 -5
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +140 -9
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session.rb +1 -1
  39. data/lib/samagotchi/session_chain.rb +2 -8
  40. data/lib/samagotchi/session_commands.rb +2 -0
  41. data/lib/samagotchi/session_manager.rb +1 -2
  42. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  43. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  44. data/lib/samagotchi/session_metrics.rb +118 -83
  45. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  46. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  47. data/lib/samagotchi/toolkit.rb +9 -8
  48. data/lib/samagotchi/toolkit_command.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +1 -5
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +3 -0
  52. data/lib/samagotchi/web/public/app.js +4 -3
  53. data/lib/samagotchi/web/public/chains.js +42 -21
  54. data/lib/samagotchi/web/public/composer.js +9 -5
  55. data/lib/samagotchi/web/public/ctx.js +73 -11
  56. data/lib/samagotchi/web/public/index.html +32 -35
  57. data/lib/samagotchi/web/public/info_bar.js +4 -4
  58. data/lib/samagotchi/web/public/info_bar_model.js +18 -8
  59. data/lib/samagotchi/web/public/lists_view.js +2 -2
  60. data/lib/samagotchi/web/public/question_cards.js +1 -1
  61. data/lib/samagotchi/web/public/session_state.js +7 -0
  62. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  63. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  64. data/lib/samagotchi/web/public/timing.js +11 -2
  65. metadata +4 -1
@@ -435,17 +435,61 @@ module Samagotchi
435
435
  # /stats' cost: the reported part and the estimated one, each when present.
436
436
  # +price_host+ names the host whose hosts.<name>.models prices the
437
437
  # estimate came from (Engine#stats_snapshot; nil from an older worker).
438
- def stats_cost_text(tokens, price_host: nil)
438
+ # +per_model+: the session ran on several models (model_segments), each
439
+ # priced from its own host's prices.
440
+ def stats_cost_text(tokens, price_host: nil, per_model: false)
439
441
  reported = cost_text(tokens[:cost_sum])
440
442
  estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
441
443
  return nil if reported.empty? && estimated.empty?
442
444
  return "#{reported} (this session only)" if estimated.empty?
443
445
 
444
- source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
446
+ source = if per_model then "each model's host prices"
447
+ elsif price_host.to_s.empty? then "configured prices"
448
+ else "hosts.#{price_host}.models prices"
449
+ end
445
450
  estimate = "#{estimated} from #{source}"
446
451
  "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
447
452
  end
448
453
 
454
+ # ── A session's rows per model (the snapshot's model_segments) ──────
455
+ # The words the web's ctx tooltip shares (ctx.js; spec/shared/
456
+ # labels_matrix.json model_segments). Rows come symbol- or
457
+ # string-keyed (the attached TUI's as JSON).
458
+
459
+ # A row's model: its ref; the rows folded past the cap ("3 earlier
460
+ # segments", told by folded_segments); a session's turns from before
461
+ # turns named their model ("earlier turns").
462
+ def model_segment_label(row)
463
+ folded = segment_value(row, :folded_segments).to_i
464
+ return "#{folded} earlier segments" if folded.positive?
465
+
466
+ ref = segment_value(row, :model_ref).to_s
467
+ ref.empty? ? "earlier turns" : ref
468
+ end
469
+
470
+ # "6.0k in / 40 out": the row's prompt and completion sums.
471
+ def model_segment_tokens_text(row)
472
+ count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
473
+ "#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
474
+ end
475
+
476
+ # "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
477
+ def model_segment_cost_text(row)
478
+ [cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
479
+ .reject(&:empty?).join(" + ")
480
+ end
481
+
482
+ # "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
483
+ # row's model prefilled (its cost an estimate from the configured
484
+ # price, left out without one); "" for none.
485
+ def model_segment_prefill_text(row)
486
+ tokens = segment_value(row, :prefill_tokens).to_i
487
+ return "" unless tokens.positive?
488
+
489
+ cost = cost_text(segment_value(row, :prefill_cost_estimate))
490
+ "switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
491
+ end
492
+
449
493
  private
450
494
 
451
495
  # The recap shown on return: dim, one "recap>" block, noting how many
@@ -574,12 +618,17 @@ module Samagotchi
574
618
  lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
575
619
  end
576
620
  lines << "iterations: #{snapshot[:iterations_total]}"
577
- # Summed over every request: each prompt is sent in full again.
578
- lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
579
- "#{token_breakdown_text(tokens)}"
580
- speed = stats_speed_text(tokens)
621
+ segments = Array(snapshot[:model_segments])
622
+ if segments.size >= 2
623
+ lines.concat(models_block_lines(segments, snapshot[:turns], tokens, token_src_label))
624
+ else
625
+ # Summed over every request: each prompt is sent in full again.
626
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
627
+ "#{token_breakdown_text(tokens)}"
628
+ end
629
+ speed = stats_speed_text(tokens, last_model: segments.size >= 2 ? last_speed_label(segments) : nil)
581
630
  lines << "speed: #{speed}" if speed
582
- cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
631
+ cost = stats_cost_text(tokens, price_host: snapshot[:price_host], per_model: segments.size >= 2)
583
632
  lines << "cost: #{cost}" if cost
584
633
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
585
634
  lines << "cancellations: #{snapshot[:cancellations]}"
@@ -682,19 +731,87 @@ module Samagotchi
682
731
 
683
732
  # "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
684
733
  # before a generation had a speed. The prompt (prefill) speed only when
685
- # the server reported it.
686
- def stats_speed_text(tokens)
734
+ # the server reported it. +last_model+: the model the last speed came
735
+ # from, named when the session ran on several.
736
+ def stats_speed_text(tokens, last_model: nil)
687
737
  last = tokens[:last_decode_tps]
688
738
  return nil unless last
689
739
 
690
740
  source = tokens[:tps_source].to_s
691
741
  text = "#{speed_text(last, source)} out"
692
742
  text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
693
- text << " (last, #{source})"
743
+ text << " (last, #{source}#{", #{last_model}" if last_model})"
694
744
  text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
695
745
  text
696
746
  end
697
747
 
748
+ def segment_value(row, key)
749
+ row[key] || row[key.to_s]
750
+ end
751
+
752
+ # /stats' models: block, in place of the tokens line: a row per
753
+ # segment (model, when, turns, tokens, its average speed and cost), a
754
+ # switch's prefill under the row of the model switched to, and the
755
+ # total (the tokens line's sums and breakdown).
756
+ def models_block_lines(segments, turns, tokens, token_src_label)
757
+ dated = segment_dates_differ?(segments)
758
+ cells = segments.map do |row|
759
+ speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
760
+ [model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
761
+ [model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
762
+ end
763
+ total = ["total", "", turns_text(turns),
764
+ "#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
765
+ "#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
766
+ "(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
767
+ widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
768
+ row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
769
+ lines = []
770
+ segments.each_with_index do |row, i|
771
+ lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
772
+ prefill = model_segment_prefill_text(row)
773
+ lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
774
+ end
775
+ lines << "#{" " * 18}#{row_text.call(total)}"
776
+ end
777
+
778
+ def turns_text(count)
779
+ "#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
780
+ end
781
+
782
+ # "10:02–10:41", "10:43–now" (a row with a turn running); with the
783
+ # day ("Oct 16 10:02") when the rows span more than one day.
784
+ def segment_span_text(row, dated:)
785
+ started = segment_time(segment_value(row, :started_at))
786
+ ended = segment_time(segment_value(row, :ended_at))
787
+ format_time = lambda { |time|
788
+ if time
789
+ time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
790
+ else
791
+ "?"
792
+ end
793
+ }
794
+ "#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
795
+ end
796
+
797
+ def segment_dates_differ?(segments)
798
+ days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
799
+ .filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
800
+ days.uniq.size > 1
801
+ end
802
+
803
+ def segment_time(stamp)
804
+ stamp ? Time.iso8601(stamp.to_s).localtime : nil
805
+ rescue ArgumentError
806
+ nil
807
+ end
808
+
809
+ # The row the session's last speed came from: the newest with one.
810
+ def last_speed_label(segments)
811
+ row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
812
+ row && model_segment_label(row)
813
+ end
814
+
698
815
  def format_elapsed_duration(duration_ms)
699
816
  duration_ms = duration_ms.to_f
700
817
  return "" if duration_ms.negative?
@@ -89,7 +89,9 @@ module Samagotchi
89
89
  # hint). Other events change nothing.
90
90
  def take_event(event)
91
91
  case event[:type]
92
- when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
92
+ # usage nil (a /model whose window didn't resolve) clears it.
93
+ when :context_status
94
+ update(context: event[:usage] ? { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] } : nil)
93
95
  when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
94
96
  when :generation_completed
95
97
  update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
@@ -77,16 +77,17 @@ module Samagotchi
77
77
  super
78
78
  end
79
79
 
80
- # What the prompt carries of it: the declared names, the reachable
81
- # and removed ones (the not-loaded line, find_tools' chi line), when
82
- # either list has any the kit name the line gives, and the kit's
83
- # prompt text. Two equal keys build the same prompt (/toolkit's "no
84
- # change", its cost).
85
- def prompt_key
86
- [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name,
87
- resolved.prompt]
80
+ # What the prompt carries before the kit's prompt text: the declared
81
+ # names, reachable and removed ones (the not-loaded line, find_tools'
82
+ # chi line), and, when either list has any, the kit name the line gives.
83
+ def tools_key
84
+ [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
88
85
  end
89
86
 
87
+ # What the prompt carries of it, including the kit's prompt text. Two
88
+ # equal keys build the same prompt (/toolkit's "no change", its cost).
89
+ def prompt_key = [*tools_key, resolved.prompt]
90
+
90
91
  # What the UIs show (the snapshot, command_ran): nil when it declares
91
92
  # every tool on full (full, full +delegate), which has no chip.
92
93
  # +left_out+ is +reachable+ and +removed+ together; +prompt+ (the
@@ -129,9 +129,9 @@ module Samagotchi
129
129
 
130
130
  # What the confirm says is read again: from the tools on (the front of
131
131
  # every request), or from the kit's prompt text on when only that
132
- # changes (Explained#prompt_key's last part: a kit on full's tools).
132
+ # changes (Explained#tools_key is unchanged: only a kit's prompt text).
133
133
  def reread_line(before, after)
134
- if before.prompt_key[0...-1] == after.prompt_key[0...-1]
134
+ if before.tools_key == after.tools_key
135
135
  "The kit's prompt text is in the system prompt: the prompt is read again from it on."
136
136
  else
137
137
  "The tools are the front of every request: the whole prompt is read again from them on."
@@ -144,11 +144,7 @@ module Samagotchi
144
144
 
145
145
  # The XDG dirs set in +env+, made absolute (nothing for an unset one).
146
146
  def xdg_absolute(env = ENV)
147
- set = ->(key) { !env.fetch(key, "").to_s.strip.empty? }
148
- dirs = {}
149
- dirs["XDG_STATE_HOME"] = Paths.state_home(env: env) if set.call("XDG_STATE_HOME")
150
- dirs["XDG_CONFIG_HOME"] = Paths.config_home(env: env) if set.call("XDG_CONFIG_HOME")
151
- dirs
147
+ Paths.absolute_xdg(env: env)
152
148
  end
153
149
 
154
150
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.74.0"
4
+ VERSION = "0.76.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1794,6 +1794,9 @@ module Samagotchi
1794
1794
  # before the next turn streams (live over saved).
1795
1795
  context: source["context"],
1796
1796
  tokens: source["tokens"],
1797
+ # The tokens, cost and speeds per model (SessionMetrics'
1798
+ # model_segments; none with one model, or from an older file).
1799
+ model_segments: Array(source["model_segments"]),
1797
1800
  # The memory indexes the session's prompt holds (the ctx tooltip).
1798
1801
  memory_index: source["memory_index"]
1799
1802
  }
@@ -1475,7 +1475,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1475
1475
  // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1476
1476
  // image refused (bad_image, too_large, bad_images) says why below.
1477
1477
  if (e.code === "owned_by_tui") {
1478
- if (sentTo !== selected) return alert(e.message);
1478
+ if (sentTo !== selected) return showToast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
1479
1479
  current.owner = "tui";
1480
1480
  updateComposerMode();
1481
1481
  return;
@@ -1485,7 +1485,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1485
1485
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1486
1486
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1487
1487
  if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1488
- alert(e.message);
1488
+ showToast(cantText("send", e), null, { sticky: true });
1489
1489
  } finally {
1490
1490
  actionBtn.disabled = current.owner === "tui";
1491
1491
  promptEl.focus();
@@ -1502,13 +1502,14 @@ const composerCommands = createComposerCommands({
1502
1502
  refreshHistory: () => refreshHistory(),
1503
1503
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1504
1504
  // As handleSendTurn says a turn's failure after a switch.
1505
- failedElsewhere: (line, text) => alert(`${line}: ${text}`),
1505
+ failedElsewhere: (line, text, id) => showToast(cantText(`run ${line} in ${id.slice(0, 8)}`, text), null, { sticky: true }),
1506
1506
  ownedByTui: () => {
1507
1507
  current.owner = "tui";
1508
1508
  updateComposerMode();
1509
1509
  },
1510
1510
  composer: {
1511
1511
  busy: () => { actionBtn.disabled = true; },
1512
+ text: () => promptEl.value,
1512
1513
  clear: () => clearComposer(),
1513
1514
  done: () => {
1514
1515
  actionBtn.disabled = current.owner === "tui";
@@ -1,9 +1,9 @@
1
1
  // Session chains on the page (docs/sessions.md, Session chains): a session
2
- // that continues an earlier one (its summary's `continues`), "the next day of
2
+ // that continues an earlier one (its summary's `continues`), "the next run of
3
3
  // the same routine". The chain is derived from the list, never stored: back
4
4
  // through `continues`, forward through the session whose `continues` names
5
5
  // this one. The lists fold a chain into its latest listed link's card (a
6
- // `day N` chip, its earlier links in a popover); the session page links the
6
+ // `#N` chip, its earlier links in a popover); the session page links the
7
7
  // previous and the next link. Pure: lists_view.js places the chip and rows
8
8
  // (lit-html templates) and app.js the toast's markup, and they bind them.
9
9
  import { normalize, previewForCard, relativeTime } from "./format.js";
@@ -33,7 +33,7 @@ export function isOpener(text) {
33
33
 
34
34
  // A link's card title (format.js previewForCard), display only: a link
35
35
  // whose title is the opener, as every link's first message is once sent,
36
- // reads its chain's first link's title instead, so the days of a chain
36
+ // reads its chain's first link's title instead, so the links of a chain
37
37
  // keep its name. Its own title when the first link isn't in +byId+ (id →
38
38
  // session; deleted, or a walk a missing link cut short) or is the opener too.
39
39
  export function chainTitle(s, byId) {
@@ -112,13 +112,32 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
112
112
  return { prev, next };
113
113
  }
114
114
 
115
- // A link's day as the carry note writes it: the local YYYY-MM-DD of its
116
- // start; "" when it has none.
117
- export function chainDate(iso) {
115
+ const pad = (n) => String(n).padStart(2, "0");
116
+ const localDate = (iso) => {
118
117
  const d = new Date(iso || "");
119
- if (Number.isNaN(d.getTime())) return "";
120
- const pad = (n) => String(n).padStart(2, "0");
121
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
118
+ return Number.isNaN(d.getTime()) ? null : d;
119
+ };
120
+
121
+ // A link's day as the carry note writes its date: the local YYYY-MM-DD of
122
+ // its start; "" when it has none.
123
+ export function chainDate(iso) {
124
+ const d = localDate(iso);
125
+ return d ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : "";
126
+ }
127
+
128
+ // A link's start as a local HH:MM; "" when it has none.
129
+ export function chainClock(iso) {
130
+ const d = localDate(iso);
131
+ return d ? `${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
132
+ }
133
+
134
+ // A link's start as a chain list shows it: its date (chainDate), with its
135
+ // HH:MM when one of +otherIsos+ (the chain's other links' starts) is on the
136
+ // same local day, so two links of one day tell apart. "" when it has none.
137
+ export function chainStamp(iso, otherIsos = []) {
138
+ const date = chainDate(iso);
139
+ if (!date) return "";
140
+ return otherIsos.some((o) => chainDate(o) === date) ? `${date} ${chainClock(iso)}` : date;
122
141
  }
123
142
 
124
143
  // The families (sessions_list.js families) with the chains folded in: a
@@ -129,7 +148,7 @@ export function chainDate(iso) {
129
148
  // index). +all+: every session the page holds, archived ones too (the links
130
149
  // the list leaves out). The same array when no chain is in it.
131
150
  // @param fold false: every family stays (each link's card carries its
132
- // chip, "day 1 of 3"; folded false), for a search or the archived ones
151
+ // chip, "#1 of 3"; folded false), for a search or the archived ones
133
152
  export function foldChains(fams, all, { fold = true } = {}) {
134
153
  if (!all.some((s) => s.continues)) return fams;
135
154
  const nexts = nextsOf(all);
@@ -160,15 +179,15 @@ export function allViewChains(fams, all, { query = "", includeArchived = false }
160
179
  }
161
180
 
162
181
  // The links a family's chain list shows: the chain but its head, newest
163
- // first, each {session, day} (day: its 1-based place in the chain).
182
+ // first, each {session, n} (n: its 1-based place in the chain).
164
183
  function chainOthers(f) {
165
184
  const links = f?.chain?.links || [];
166
- return links.map((session, i) => ({ session, day: i + 1 }))
185
+ return links.map((session, i) => ({ session, n: i + 1 }))
167
186
  .filter(({ session }) => session.id !== f.head.id)
168
187
  .reverse();
169
188
  }
170
189
 
171
- // The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
190
+ // The chip in a chain's card: "#3" (the head's place; "#2 of 3" when
172
191
  // later links aren't listed), with a live dot / "waiting" when one of the
173
192
  // other links has a worker / waits on the user (typing in an archived link
174
193
  // brings it back, but it stays folded here). nothing for a family in no
@@ -180,12 +199,12 @@ export function chainChipTemplate(f, { open = false } = {}) {
180
199
  const others = chainOthers(f);
181
200
  const live = others.filter(({ session: s }) => s.owner === "worker").length;
182
201
  const waiting = others.filter(({ session: s }) => waitingOn(s)).length;
183
- const day = at + 1;
184
- const text = day === links.length ? `day ${day}` : `day ${day} of ${links.length}`;
202
+ const n = at + 1;
203
+ const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
185
204
  const earlier = at;
186
205
  const later = links.length - 1 - at;
187
206
  const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
188
- const title = `Session chain: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
207
+ const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
189
208
  const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
190
209
  const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
191
210
  return html`<button type="button" class=${`chain-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
@@ -193,24 +212,26 @@ export function chainChipTemplate(f, { open = false } = {}) {
193
212
  }
194
213
 
195
214
  // The chain's list (the chip's popover): one row per other link, newest
196
- // first: status dot, "day N", its date, its recap's first sentence (else
215
+ // first: status dot, "#N", its date (with its HH:MM when another link of
216
+ // the chain, the head too, started the same day: chainStamp), its recap's first sentence (else
197
217
  // its preview), the waiting / stopped badge, archived. A row's click opens
198
218
  // that link (families_view.js's popover binds .family-row[data-id]).
199
219
  // @param activeId the open session's id (its row is .active)
200
220
  export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
201
221
  const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
202
- const rows = chainOthers(f).map(({ session: s, day }) => {
222
+ const links = f?.chain?.links || [];
223
+ const rows = chainOthers(f).map(({ session: s, n }) => {
203
224
  const cls = ["family-row", "chain-row"];
204
225
  if (activeId && s.id === activeId) cls.push("active");
205
226
  if (s.archived) cls.push("archived");
206
227
  const waiting = waitingBadge(s, { inFamily: true });
207
228
  const stopped = waiting ? null : stoppedBadge(s);
208
- const date = chainDate(s.created_at);
229
+ const date = chainStamp(s.created_at, links.filter((l) => l !== s).map((l) => l.created_at));
209
230
  const ago = relativeTime(s.updated_at || s.created_at || "", now);
210
231
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
211
232
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
212
- title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">day ${day}</span>${
213
- date ? html`<span class="cr-date">${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
233
+ title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-n">#${n}</span>${
234
+ date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
214
235
  rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
215
236
  });
216
237
  return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
@@ -60,9 +60,10 @@ export function routeSessionLine(prompt, { commands = [], images = 0, delivery =
60
60
  // followWokenWorker() the command woke the worker: follow its stream
61
61
  // refreshHistory()
62
62
  // failed(line, text) a failed command's bubble
63
- // failedElsewhere(line, text) a command to a session no longer open failed
63
+ // failedElsewhere(line, text, id) a command to session id, no longer open, failed
64
64
  // ownedByTui() a plain terminal holds the session
65
- // composer: {busy(), clear(), done()} the composer's send button and text
65
+ // composer: {busy(), text(), clear(), done()} the composer's send button
66
+ // and text (text(): what it holds now)
66
67
  export function createComposerCommands(deps) {
67
68
  const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, failedElsewhere, ownedByTui,
68
69
  composer } = deps;
@@ -72,14 +73,17 @@ export function createComposerCommands(deps) {
72
73
  // worker, owner and history are left alone then.
73
74
  async function send(line) {
74
75
  const id = session();
76
+ // The composer is shared: a draft typed in a session opened meanwhile
77
+ // stays when the answer comes; only the text sent goes.
78
+ const sent = composer.text();
75
79
  composer.busy();
76
80
  try {
77
81
  await sendCommand(id, line, { clientId });
78
- composer.clear();
82
+ if (composer.text() === sent) composer.clear();
79
83
  refreshHistory();
80
84
  if (session() === id) followWokenWorker();
81
85
  } catch (e) {
82
- if (session() !== id) return failedElsewhere(line, e.message);
86
+ if (session() !== id) return failedElsewhere(line, e.message, id);
83
87
  if (e.code === "owned_by_tui") {
84
88
  ownedByTui();
85
89
  return;
@@ -103,7 +107,7 @@ export function createComposerCommands(deps) {
103
107
  return true;
104
108
  } catch (e) {
105
109
  if (session() === id) failed(line, e.message);
106
- else failedElsewhere(line, e.message);
110
+ else failedElsewhere(line, e.message, id);
107
111
  return false;
108
112
  }
109
113
  }
@@ -133,14 +133,23 @@ export function ctxBarText(pct, windowSource = null) {
133
133
  }
134
134
 
135
135
  // The info bar's / the card's ctx tooltip: the window line under the title
136
- // when there's a number (tokensTipText), or why there's none — "No request
137
- // in this session yet" — when the page has no percent (before any request;
138
- // the page isn't told context.status is off, and the window always resolves,
139
- // so "?" never means 0).
140
- export function ctxBarTip(tokens, memoryIndex, window) {
141
- if (num(window?.pct) === null) return "No request in this session yet";
136
+ // when there's a number (tokensTipText), or why there's none when the page
137
+ // has no percent: "No request in this session yet" before any request (the
138
+ // page isn't told context.status is off, so "?" never means 0); after one,
139
+ // "Context window unknown" over the session's tokens and per-model rows with
140
+ // no window line (a window lookup that failed; a model list without a context
141
+ // length still falls back to context.window_tokens or chi's default). +window.afterSwitch+ (the info
142
+ // bar's; a /model whose new window didn't resolve, no request since:
143
+ // Engine#announce_switched_context! sent usage null) adds "until the next
144
+ // request", which asks for it again.
145
+ export function ctxBarTip(tokens, memoryIndex, window, segments = null) {
146
+ if (num(window?.pct) === null) {
147
+ if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) return "No request in this session yet";
148
+ const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
149
+ return tokensTipText(tokens, why, memoryIndex, null, segments);
150
+ }
142
151
 
143
- return tokensTipText(tokens, undefined, memoryIndex, window);
152
+ return tokensTipText(tokens, undefined, memoryIndex, window, segments);
144
153
  }
145
154
 
146
155
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
@@ -166,14 +175,67 @@ export function ctxWindowText(window) {
166
175
  return `context: ${used}${countK(tokens)} tokens${sourceText}`;
167
176
  }
168
177
 
178
+ // ── A session's rows per model (the timing's modelSegments) ──────────────
179
+ // The words /stats' models: block shares (Formatting#model_segment_*;
180
+ // spec/shared/labels_matrix.json model_segments).
181
+
182
+ // A row's model: its ref; the rows folded past the cap ("3 earlier
183
+ // segments", told by folded_segments); a session's turns from before turns
184
+ // named their model ("earlier turns").
185
+ export function modelSegmentLabel(row) {
186
+ const folded = num(row?.folded_segments);
187
+ if (folded !== null && folded > 0) return `${folded} earlier segments`;
188
+ return row?.model_ref ? String(row.model_ref) : "earlier turns";
189
+ }
190
+
191
+ // "6.0k in / 40 out".
192
+ export function modelSegmentTokensText(row) {
193
+ return `${countK(num(row?.prompt_sum) || 0)} in / ${countK(num(row?.completion_sum) || 0)} out`;
194
+ }
195
+
196
+ // "$0.42", "~$0.12", "$0.42 + ~$0.12"; "" for none.
197
+ export function modelSegmentCostText(row) {
198
+ return [costText(row?.cost_sum), costText(row?.cost_estimate_sum, { estimate: true })].filter(Boolean).join(" + ");
199
+ }
200
+
201
+ // "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
202
+ // row's model prefilled (its cost an estimate from the configured price,
203
+ // left out without one); "" for none.
204
+ export function modelSegmentPrefillText(row) {
205
+ const tokens = num(row?.prefill_tokens) || 0;
206
+ if (tokens <= 0) return "";
207
+ const cost = costText(row?.prefill_cost_estimate);
208
+ return `switch prefill ${countK(tokens)} uncached prompt${cost ? ` ≈${cost}` : ""}`;
209
+ }
210
+
211
+ // The tooltip's rows per model, with ≥ 2 (one model: none): "model ·
212
+ // turns · in / out · cost", a switch's prefill under its row.
213
+ function modelSegmentLines(segments) {
214
+ if (!Array.isArray(segments) || segments.length < 2) return [];
215
+ const lines = ["per model:"];
216
+ for (const row of segments) {
217
+ const turns = num(row.turns) || 0;
218
+ const parts = [modelSegmentLabel(row), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
219
+ const cost = modelSegmentCostText(row);
220
+ if (cost) parts.push(cost);
221
+ lines.push(` ${parts.join(" · ")}`);
222
+ const prefill = modelSegmentPrefillText(row);
223
+ if (prefill) lines.push(` └ ${prefill}`);
224
+ }
225
+ return lines;
226
+ }
227
+
169
228
  // The ctx tooltip (info bar and card): +title+, then the session's tokens
170
229
  // (in summed over every request, cached with its share, out with the
171
230
  // reasoning part) and its cost, when it has any (an estimated part with
172
231
  // "~", costLine), and the memory indexes
173
232
  // its prompt holds (+memoryIndex+, the snapshot's memory_index); with
174
- // +window+ (ctxWindowText's) the window line under the title. A
175
- // delegated session keeps its own metrics, so these are this session's only.
176
- export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null) {
233
+ // +window+ (ctxWindowText's) the window line under the title; with
234
+ // +segments+ (the timing's modelSegments, ≥ 2) the rows per model above the
235
+ // totals. A delegated session keeps its own metrics, so these are this
236
+ // session's only.
237
+ export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
238
+ segments = null) {
177
239
  const memory = memoryIndexText(memoryIndex);
178
240
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
179
241
  const windowText = ctxWindowText(window);
@@ -186,7 +248,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
186
248
  const parts = [`in ${count(prompt)}`];
187
249
  if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
188
250
  parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
189
- const lines = [title, `tokens: ${parts.join(" · ")}`];
251
+ const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
190
252
  const cost = costLine(tokens);
191
253
  if (cost) lines.push(cost);
192
254
  lines.push("this session only, all requests");