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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +21 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +14 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +84 -4
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +11 -14
- data/lib/samagotchi/memory_bundle/installer.rb +140 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/session.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -8
- data/lib/samagotchi/session_commands.rb +2 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/app.js +4 -3
- data/lib/samagotchi/web/public/chains.js +42 -21
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +73 -11
- data/lib/samagotchi/web/public/index.html +32 -35
- data/lib/samagotchi/web/public/info_bar.js +4 -4
- data/lib/samagotchi/web/public/info_bar_model.js +18 -8
- data/lib/samagotchi/web/public/lists_view.js +2 -2
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +7 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +32 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -77,16 +77,17 @@ module Samagotchi
|
|
|
77
77
|
super
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
# What the prompt carries
|
|
81
|
-
# and removed ones (the not-loaded line, find_tools'
|
|
82
|
-
# either list has any the kit name the line gives
|
|
83
|
-
|
|
84
|
-
|
|
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#
|
|
132
|
+
# changes (Explained#tools_key is unchanged: only a kit's prompt text).
|
|
133
133
|
def reread_line(before, after)
|
|
134
|
-
if before.
|
|
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
|
-
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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, "
|
|
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,
|
|
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,
|
|
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: "
|
|
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
|
|
184
|
-
const text =
|
|
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:
|
|
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, "
|
|
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
|
|
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 =
|
|
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-
|
|
213
|
-
date ? html`<span class
|
|
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
|
|
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
|
|
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
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
175
|
-
//
|
|
176
|
-
|
|
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");
|