samagotchi 0.44.0 → 0.46.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 +120 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +43 -12
- data/docs/configuration.md +102 -5
- data/docs/context.md +2 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +9 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +43 -12
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +74 -3
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +33 -3
- data/lib/samagotchi/session_commands.rb +47 -21
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +79 -11
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +97 -12
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -11
- data/lib/samagotchi/web/public/app.js +30 -18
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +62 -22
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/index.html +3 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +19 -6
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +172 -21
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +4 -1
|
@@ -60,6 +60,9 @@ module Samagotchi
|
|
|
60
60
|
REMINDER_PENDING_POLL_INTERVAL = 0.5
|
|
61
61
|
# What a command sent during a turn gets, as from a worker (Worker::BUSY_OUTPUT).
|
|
62
62
|
COMMAND_BUSY = "busy: wait for the turn to end"
|
|
63
|
+
ROLLBACK_BUSY = "busy: Ctrl-C the turn first, then !rollback"
|
|
64
|
+
# A command typed during a turn that runs after it (/model X, !cmd).
|
|
65
|
+
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
63
66
|
# /detach is the attached terminal's; the REPL owns its session.
|
|
64
67
|
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
65
68
|
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
@@ -569,8 +572,10 @@ module Samagotchi
|
|
|
569
572
|
# Not an answer to a continue offer either.
|
|
570
573
|
next detach_note if local == :detach
|
|
571
574
|
|
|
572
|
-
# /stats and /recap run; the offer stays open.
|
|
573
|
-
|
|
575
|
+
# /stats and /recap run; the offer stays open. So does a session
|
|
576
|
+
# command (one queued during the turn: /model X, !cmd); /continue
|
|
577
|
+
# answers it.
|
|
578
|
+
if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local) && !offer_leaves_open?(input)
|
|
574
579
|
answer_continue_offer(session, input)
|
|
575
580
|
else
|
|
576
581
|
# The ? read left no echo: show what ran.
|
|
@@ -714,6 +719,13 @@ module Samagotchi
|
|
|
714
719
|
|
|
715
720
|
# An answer at the ? prompt of a continue offer. A valid one closes the
|
|
716
721
|
# offer's choices and leaves one line; an invalid one gets its error.
|
|
722
|
+
# Whether +input+ is a session command that runs past a continue offer,
|
|
723
|
+
# leaving it open: any but /continue (the worker's S2).
|
|
724
|
+
def offer_leaves_open?(input)
|
|
725
|
+
entry = command_registry.lookup(input)
|
|
726
|
+
!entry.nil? && entry.id != :continue
|
|
727
|
+
end
|
|
728
|
+
|
|
717
729
|
def answer_continue_offer(session, input)
|
|
718
730
|
answer = @commands.continue_answer(input)
|
|
719
731
|
if answer.decision == :invalid
|
|
@@ -743,7 +755,7 @@ module Samagotchi
|
|
|
743
755
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
744
756
|
# an anytime command's cards print as it shows them (btw's
|
|
745
757
|
# "thinking…" before the answer).
|
|
746
|
-
command = if command_registry.
|
|
758
|
+
command = if command_registry.anytime?(input)
|
|
747
759
|
@engine.running_anytime { @commands.run(input) }
|
|
748
760
|
else
|
|
749
761
|
@commands.run(input)
|
|
@@ -1031,26 +1043,58 @@ module Samagotchi
|
|
|
1031
1043
|
# While a turn runs, a line submitted at the open prompt steers it: it
|
|
1032
1044
|
# merges at the next iteration boundary (Kernel), and one that comes after
|
|
1033
1045
|
# the last runs as the next turn. Reminder turns too.
|
|
1046
|
+
#
|
|
1047
|
+
# A command that waits for the turn's end (/model X, !cmd) and every line
|
|
1048
|
+
# after it wait in @after_turn, in the order typed (D1), with the image
|
|
1049
|
+
# lines (which never steer): once the turn ends they come back after the
|
|
1050
|
+
# steering it didn't merge (typed before them), ahead of anything sent
|
|
1051
|
+
# after Ctrl-C.
|
|
1034
1052
|
def with_steering(&)
|
|
1035
1053
|
return yield unless @repl_input
|
|
1036
1054
|
|
|
1037
|
-
@
|
|
1055
|
+
@after_turn = []
|
|
1056
|
+
@command_waits = false
|
|
1057
|
+
@repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain + lines_after_turn }, &)
|
|
1058
|
+
end
|
|
1059
|
+
|
|
1060
|
+
# The lines that waited for the turn's end, taken from @after_turn. After
|
|
1061
|
+
# a canceled or failed turn (Ctrl-C raises through here; a failure too)
|
|
1062
|
+
# a !cmd among them is dropped (D4): running it would close the
|
|
1063
|
+
# !rollback the turn left.
|
|
1064
|
+
def lines_after_turn
|
|
1065
|
+
lines = @after_turn.slice!(0..)
|
|
1066
|
+
ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
|
|
1067
|
+
elsif $! then "failed"
|
|
1068
|
+
end
|
|
1069
|
+
return lines unless ended
|
|
1070
|
+
|
|
1071
|
+
lines.reject do |line|
|
|
1072
|
+
next false unless command_registry.lookup(line)&.id == :shell
|
|
1073
|
+
|
|
1074
|
+
@surface.commit("turn #{ended}: #{line.strip} not run; send it again")
|
|
1075
|
+
true
|
|
1076
|
+
end
|
|
1038
1077
|
end
|
|
1039
1078
|
|
|
1040
1079
|
# On the reader thread, from ReplInput: takes a line for the running turn.
|
|
1041
1080
|
# A line sent after Ctrl-C waits for the next turn (the kernel would not
|
|
1042
1081
|
# merge it into the cancelled one). Ctrl-D or exit ends the REPL after
|
|
1043
1082
|
# the turn. /stats and /recap run now, an anytime command (/help, a
|
|
1044
|
-
# plugin's /btw) starts now on its own thread;
|
|
1045
|
-
# in the
|
|
1083
|
+
# plugin's /btw, /model alone) starts now on its own thread; one that
|
|
1084
|
+
# runs after the turn (/model X, !cmd) waits in the queue, in arrival
|
|
1085
|
+
# order with the other lines that wait; others go back in the prompt.
|
|
1046
1086
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1047
1087
|
def steer_line(line)
|
|
1048
1088
|
local = local_command(line) unless line.nil?
|
|
1049
1089
|
return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
|
|
1090
|
+
return archive_after_turn if local == :archive
|
|
1050
1091
|
return detach_note if local == :detach
|
|
1051
1092
|
return false if @active_cancel_controller&.cancelled?
|
|
1052
|
-
|
|
1053
|
-
|
|
1093
|
+
|
|
1094
|
+
policy = command_registry.mid_turn(line)
|
|
1095
|
+
return start_anytime_command(line) if policy == :anytime
|
|
1096
|
+
return command_queued(line) if policy == :queue
|
|
1097
|
+
# A command, never steering text.
|
|
1054
1098
|
return command_during_turn(line) if local || command_registry.command?(line)
|
|
1055
1099
|
return true if line.strip.empty?
|
|
1056
1100
|
|
|
@@ -1060,7 +1104,9 @@ module Samagotchi
|
|
|
1060
1104
|
return true
|
|
1061
1105
|
end
|
|
1062
1106
|
# Steering merges text only: a line with images runs as the next turn.
|
|
1063
|
-
return image_line_waits if ImageInput.extract(line).any?
|
|
1107
|
+
return image_line_waits(line) if ImageInput.extract(line).any?
|
|
1108
|
+
# Behind a command that waits for the turn's end, a line waits too.
|
|
1109
|
+
return wait_for_turn_end(line) if @command_waits
|
|
1064
1110
|
|
|
1065
1111
|
epoch = @engine.input_epoch
|
|
1066
1112
|
@pending_input_queue.push(line.strip)
|
|
@@ -1071,9 +1117,16 @@ module Samagotchi
|
|
|
1071
1117
|
true
|
|
1072
1118
|
end
|
|
1073
1119
|
|
|
1074
|
-
def image_line_waits
|
|
1120
|
+
def image_line_waits(line)
|
|
1075
1121
|
@surface.commit(IMAGE_LINE_WAITS)
|
|
1076
|
-
|
|
1122
|
+
wait_for_turn_end(line)
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
# +line+ runs once the turn has ended, in the order typed (#with_steering).
|
|
1126
|
+
# @return [true] the turn took it
|
|
1127
|
+
def wait_for_turn_end(line)
|
|
1128
|
+
@after_turn << line
|
|
1129
|
+
true
|
|
1077
1130
|
end
|
|
1078
1131
|
|
|
1079
1132
|
def detach_note
|
|
@@ -1081,6 +1134,18 @@ module Samagotchi
|
|
|
1081
1134
|
true
|
|
1082
1135
|
end
|
|
1083
1136
|
|
|
1137
|
+
# /archive during a turn (D2): the REPL leaves and archives once the
|
|
1138
|
+
# turn and the lines queued behind it (S5) ran, as /exit does.
|
|
1139
|
+
# @return [true]
|
|
1140
|
+
def archive_after_turn
|
|
1141
|
+
return @surface.commit(SCRATCH_ARCHIVE_REFUSED).then { true } if @scratch
|
|
1142
|
+
|
|
1143
|
+
@exit_after_turn = true
|
|
1144
|
+
@archive_on_exit = true
|
|
1145
|
+
@surface.commit("(archives after this turn; Ctrl-C cancels it)")
|
|
1146
|
+
true
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1084
1149
|
def exit_after_turn(delete: false)
|
|
1085
1150
|
@exit_after_turn = true
|
|
1086
1151
|
@delete_on_exit = true if delete
|
|
@@ -1098,10 +1163,17 @@ module Samagotchi
|
|
|
1098
1163
|
def command_during_turn(line)
|
|
1099
1164
|
return true if show_local_command(line)
|
|
1100
1165
|
|
|
1101
|
-
@surface.commit(COMMAND_BUSY)
|
|
1166
|
+
@surface.commit(command_registry.lookup(line)&.id == :rollback ? ROLLBACK_BUSY : COMMAND_BUSY)
|
|
1102
1167
|
:back
|
|
1103
1168
|
end
|
|
1104
1169
|
|
|
1170
|
+
# @return [true] it waits for the turn's end (#wait_for_turn_end)
|
|
1171
|
+
def command_queued(line)
|
|
1172
|
+
@surface.commit(COMMAND_QUEUED)
|
|
1173
|
+
@command_waits = true
|
|
1174
|
+
wait_for_turn_end(line)
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1105
1177
|
# D8: an anytime command runs on its own thread while the turn goes on
|
|
1106
1178
|
# (it reads copies, and shows things through its ctx). What it prints
|
|
1107
1179
|
# while the turn runs goes above the live region now, its cards as it
|
|
@@ -1250,6 +1322,8 @@ module Samagotchi
|
|
|
1250
1322
|
|
|
1251
1323
|
# The activity row goes while the question waits.
|
|
1252
1324
|
@view.finish_thinking_spinner
|
|
1325
|
+
return dismiss_unaskable_question(prompt) if @non_interactive
|
|
1326
|
+
|
|
1253
1327
|
# An edit's diff goes above the slot, into the scrollback.
|
|
1254
1328
|
preview = prompt.preview_lines(paint: method(:paint))
|
|
1255
1329
|
@surface.commit(preview.join("\n")) unless preview.empty?
|
|
@@ -1273,6 +1347,17 @@ module Samagotchi
|
|
|
1273
1347
|
@surface.clear_slot(:notes)
|
|
1274
1348
|
end
|
|
1275
1349
|
|
|
1350
|
+
# --non-interactive has no one to ask (as for an approval or a hook's
|
|
1351
|
+
# question there): the question is dismissed at once, as an empty answer
|
|
1352
|
+
# would be, and the model finishes its reply. Never a read of stdin: a
|
|
1353
|
+
# pipe its writer keeps open would block it forever. Its one line goes to
|
|
1354
|
+
# stderr (#banner): stdout is the answer's alone.
|
|
1355
|
+
def dismiss_unaskable_question(prompt)
|
|
1356
|
+
@engine.cancel_question("user") rescue nil
|
|
1357
|
+
banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
|
|
1358
|
+
false
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1276
1361
|
# Loop until a valid selection or a cancel. The block reads one answer:
|
|
1277
1362
|
# a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
|
|
1278
1363
|
# was cancelled).
|
|
@@ -84,10 +84,11 @@ module Samagotchi
|
|
|
84
84
|
outcomes << outcome.line
|
|
85
85
|
return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
|
|
86
86
|
|
|
87
|
-
# Wait on
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
87
|
+
# Wait on from the child as it was before the relay (read when it
|
|
88
|
+
# asked), the relayed question no longer reported: a turn that
|
|
89
|
+
# ends with no reply during the relay's POST or after it still
|
|
90
|
+
# ends the wait.
|
|
91
|
+
baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
|
|
91
92
|
end
|
|
92
93
|
rescue ArgumentError => e
|
|
93
94
|
"Error: #{e.message}"
|
|
@@ -66,6 +66,8 @@ module Samagotchi
|
|
|
66
66
|
err: output_io
|
|
67
67
|
)
|
|
68
68
|
Process.detach(pid)
|
|
69
|
+
# Read now, compared before each signal: a pid reused later is not this task.
|
|
70
|
+
pid_started = ProcessGroup.start_time(pid)
|
|
69
71
|
|
|
70
72
|
now = timestamp
|
|
71
73
|
record = {
|
|
@@ -76,6 +78,7 @@ module Samagotchi
|
|
|
76
78
|
"workspace_root" => Dir.pwd,
|
|
77
79
|
"status" => "running",
|
|
78
80
|
"pid" => pid,
|
|
81
|
+
"pid_started" => pid_started,
|
|
79
82
|
"created_at" => now,
|
|
80
83
|
"started_at" => now,
|
|
81
84
|
"finished_at" => nil,
|
|
@@ -190,7 +193,7 @@ module Samagotchi
|
|
|
190
193
|
|
|
191
194
|
pid = refreshed["pid"]
|
|
192
195
|
# Checked again just before the signal: never a group chi didn't start.
|
|
193
|
-
return [mark_not_chis(refreshed), nil] unless
|
|
196
|
+
return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed["pid_started"])
|
|
194
197
|
|
|
195
198
|
refreshed["stop_requested_by"] = by.to_s
|
|
196
199
|
write_record(refreshed)
|
|
@@ -210,13 +213,19 @@ module Samagotchi
|
|
|
210
213
|
return record unless record["status"] == "running"
|
|
211
214
|
|
|
212
215
|
pid = record["pid"]
|
|
213
|
-
return record if
|
|
216
|
+
return record if chis_group?(record)
|
|
214
217
|
# Gone: its exit code says how it ended.
|
|
215
218
|
return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
|
|
216
219
|
|
|
217
220
|
mark_not_chis(record)
|
|
218
221
|
end
|
|
219
222
|
|
|
223
|
+
# Whether the record's pid still leads its own group. With +started+
|
|
224
|
+
# (the start time recorded at task_create), also the same process: a
|
|
225
|
+
# ps per call, so only task_stop asks it, just before the signal;
|
|
226
|
+
# a refresh (each task_wait poll, task_list) checks the group only.
|
|
227
|
+
def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
|
|
228
|
+
|
|
220
229
|
# A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
|
|
221
230
|
# ends as failed, unless its exit code or a stop on disk says more.
|
|
222
231
|
def mark_not_chis(record)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -443,7 +443,9 @@ module Samagotchi
|
|
|
443
443
|
warnings.concat(listing.warnings)
|
|
444
444
|
# the web's own spelling, kept as it was: bare on the default host
|
|
445
445
|
listing.rows.each do |row|
|
|
446
|
-
|
|
446
|
+
model = { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
|
|
447
|
+
model[:configured] = true if row.configured
|
|
448
|
+
models << model
|
|
447
449
|
end
|
|
448
450
|
end
|
|
449
451
|
rescue StandardError => e
|
|
@@ -488,15 +490,10 @@ module Samagotchi
|
|
|
488
490
|
# A session's LLM context strategy, apply rule and budget, each with
|
|
489
491
|
# where it came from (LLMContextStrategy::Explained#summary), as its
|
|
490
492
|
# worker would resolve them; nil when they can't be.
|
|
493
|
+
# (LLMContextStrategy.for_session, shared with the ctx % the session
|
|
494
|
+
# lists count.)
|
|
491
495
|
def llm_context_for(session)
|
|
492
|
-
registry
|
|
493
|
-
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
494
|
-
target = registry.resolve(name)
|
|
495
|
-
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
496
|
-
own = session.llm_context
|
|
497
|
-
LLMContextStrategy.explain(target, names: names, **(own&.resolve_args || {})).summary(own)
|
|
498
|
-
rescue StandardError
|
|
499
|
-
nil
|
|
496
|
+
LLMContextStrategy.for_session(session, registry: host_registry)
|
|
500
497
|
end
|
|
501
498
|
|
|
502
499
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
@@ -662,7 +659,8 @@ module Samagotchi
|
|
|
662
659
|
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
663
660
|
# What the worker's server said it served for that model (after a turn).
|
|
664
661
|
if snapshot
|
|
665
|
-
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"]
|
|
662
|
+
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
|
|
663
|
+
served_expected_by: snapshot["served_expected_by"])
|
|
666
664
|
# The model notes its prompt carries now (a /model there changes them).
|
|
667
665
|
session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
|
|
668
666
|
end
|
|
@@ -679,6 +677,8 @@ module Samagotchi
|
|
|
679
677
|
failed_turn: failed_turn_for(session, raw_messages),
|
|
680
678
|
current_turn: current_turn,
|
|
681
679
|
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
680
|
+
# Commands waiting for the running turn's end (their bubbles).
|
|
681
|
+
queued_commands: turn_snapshot ? Array(turn_snapshot["queued_commands"]) : [],
|
|
682
682
|
recap: turn_snapshot && turn_snapshot["recap"],
|
|
683
683
|
saved_recap: saved_recap_for(id, session, turn_snapshot),
|
|
684
684
|
continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
|
|
@@ -1496,7 +1496,8 @@ module Samagotchi
|
|
|
1496
1496
|
# @param owner [OwnerLock::Owner, nil] #session_owner; its kind is shown as `owner`
|
|
1497
1497
|
def session_to_json(s, status: s.status, owner: nil)
|
|
1498
1498
|
SessionSummary.build(s, status: status, owner: owner,
|
|
1499
|
-
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir)
|
|
1499
|
+
session_dir: @session_class.session_dir(s.id, state_dir: default_state_dir),
|
|
1500
|
+
registry: host_registry)
|
|
1500
1501
|
end
|
|
1501
1502
|
|
|
1502
1503
|
# The whole timing: analytics.json's records merged with a live
|
|
@@ -447,16 +447,20 @@ function fitStripColumns() {
|
|
|
447
447
|
// The order a list draws its cards in: +next+, what waits on the user
|
|
448
448
|
// first (waitingFirst), back to its place once answered. Never under the
|
|
449
449
|
// pointer, though: while it is over the list the order last drawn holds,
|
|
450
|
-
// and its leaving draws the list again.
|
|
451
|
-
//
|
|
452
|
-
//
|
|
450
|
+
// and its leaving draws the list again. A hidden list holds nothing: the
|
|
451
|
+
// strip keeps :hover for a moment after a click on its All sessions tile
|
|
452
|
+
// hid it, and an update held then brought it back from the all view in an
|
|
453
|
+
// order nobody saw (a waiting family behind). +idOf+ names a card: a
|
|
454
|
+
// session's id, or familyId (a card per family: its head's id). drawnOrder
|
|
455
|
+
// is the cards drawn, which select mode picks from.
|
|
453
456
|
const drawnOrder = new Map(); // list element → card ids in the order last drawn
|
|
454
457
|
const heldLists = new Set(); // lists drawn in a held order
|
|
455
458
|
const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
|
|
456
459
|
function listOrder(el, next, idOf = (s) => s.id) {
|
|
457
460
|
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
458
461
|
// and a touch screen has no hover at all).
|
|
459
|
-
const holding =
|
|
462
|
+
const holding = el.getClientRects().length > 0
|
|
463
|
+
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()));
|
|
460
464
|
const held = holding ? drawnOrder.get(el) : null;
|
|
461
465
|
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
462
466
|
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
@@ -1152,7 +1156,7 @@ function renderSessionView(data) {
|
|
|
1152
1156
|
setCapabilities(data.capabilities);
|
|
1153
1157
|
current.status = s.status;
|
|
1154
1158
|
current.model = s.model_name;
|
|
1155
|
-
current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
|
|
1159
|
+
current.served = s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null;
|
|
1156
1160
|
current.dir = s.working_directory;
|
|
1157
1161
|
current.owner = s.owner || null;
|
|
1158
1162
|
current.timing = normalizeTiming(data.timing);
|
|
@@ -1160,7 +1164,7 @@ function renderSessionView(data) {
|
|
|
1160
1164
|
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
1161
1165
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
1162
1166
|
// next turn (a running turn's replay below streams over it).
|
|
1163
|
-
const savedPct = savedCtxPct(data.timing?.context);
|
|
1167
|
+
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
1164
1168
|
if (savedPct !== null) current.ctxPct = savedPct;
|
|
1165
1169
|
if (data.timing?.context?.window_tokens) {
|
|
1166
1170
|
current.ctxWindow = data.timing.context.window_tokens;
|
|
@@ -1211,6 +1215,7 @@ function renderSessionView(data) {
|
|
|
1211
1215
|
const replay = snapshotEvents({
|
|
1212
1216
|
current_turn: data.current_turn,
|
|
1213
1217
|
queued: data.queued,
|
|
1218
|
+
queued_commands: data.queued_commands,
|
|
1214
1219
|
started_at: current.timing.activeTurn?.started_at,
|
|
1215
1220
|
});
|
|
1216
1221
|
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
@@ -1821,7 +1826,8 @@ function updateInfoBar() {
|
|
|
1821
1826
|
const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
|
|
1822
1827
|
const tokens = current.timing.tokens;
|
|
1823
1828
|
const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
|
|
1824
|
-
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct
|
|
1829
|
+
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
1830
|
+
budget: current.llmContext?.budget_tokens });
|
|
1825
1831
|
const speedTextNow = lastSpeedText(tokens);
|
|
1826
1832
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
1827
1833
|
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
@@ -1984,7 +1990,7 @@ const streamHandlers = {
|
|
|
1984
1990
|
const { text, thinking } = routeChunk(data);
|
|
1985
1991
|
liveTurn.timing.setNote("");
|
|
1986
1992
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1987
|
-
const pct = extractCtxPct(data, current.ctxWindow);
|
|
1993
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
1988
1994
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
1989
1995
|
current.ctxPct = pct;
|
|
1990
1996
|
updateInfoBar();
|
|
@@ -2001,7 +2007,7 @@ const streamHandlers = {
|
|
|
2001
2007
|
},
|
|
2002
2008
|
generation_completed: (data) => {
|
|
2003
2009
|
liveTurn.timing.setNote("");
|
|
2004
|
-
if (data?.served_model) current.served = [data.served_model, data.requested_model];
|
|
2010
|
+
if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
|
|
2005
2011
|
// The generation's speed and the session's token totals, from the server.
|
|
2006
2012
|
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
2007
2013
|
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
@@ -2133,17 +2139,19 @@ const streamHandlers = {
|
|
|
2133
2139
|
}
|
|
2134
2140
|
if (view.resync) await resync();
|
|
2135
2141
|
// A card's action that isn't anytime (the step-limit question's
|
|
2136
|
-
// /continue) has no bubble: nothing to remove.
|
|
2137
|
-
|
|
2142
|
+
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
2143
|
+
// one's bubble was drawn at its command_queued: filled in here.
|
|
2144
|
+
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
2138
2145
|
if (view.hidden) queued?.remove();
|
|
2139
2146
|
else if (queued) drawCommandBubble(queued, view);
|
|
2140
2147
|
else addCommandBubble(view);
|
|
2141
2148
|
},
|
|
2142
2149
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
2143
|
-
// turn: its bubble goes here, so the cards it shows come after it.
|
|
2150
|
+
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
2151
|
+
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
2144
2152
|
command_queued: (data) => {
|
|
2145
2153
|
// A card's action waits for its command_ran: shown only if it answers.
|
|
2146
|
-
if (data.anytime && !data.card && !commandBubble(data.command_id)) addCommandBubble(
|
|
2154
|
+
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
2147
2155
|
},
|
|
2148
2156
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
2149
2157
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
@@ -2162,7 +2170,7 @@ const streamHandlers = {
|
|
|
2162
2170
|
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
2163
2171
|
},
|
|
2164
2172
|
context_status: (data) => {
|
|
2165
|
-
const pct = extractCtxPct(data, current.ctxWindow);
|
|
2173
|
+
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
2166
2174
|
if (pct !== null) {
|
|
2167
2175
|
current.ctxPct = pct;
|
|
2168
2176
|
updateInfoBar();
|
|
@@ -2198,13 +2206,17 @@ const streamHandlers = {
|
|
|
2198
2206
|
// The loop asks again after an empty answer: a row of the empty step
|
|
2199
2207
|
// (a reload puts it back from the snapshot's cards).
|
|
2200
2208
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
2201
|
-
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
|
|
2209
|
+
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
|
|
2202
2210
|
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
2203
2211
|
// current step, its hover listing the outputs.
|
|
2204
2212
|
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
2205
2213
|
// The provider is asked again after an error, or the turn waits for
|
|
2206
2214
|
// plugins' setup: a note on the live timing line until it streams.
|
|
2207
|
-
generation_retrying: (data) =>
|
|
2215
|
+
generation_retrying: (data) => {
|
|
2216
|
+
// A dropped stream's step asked again: what it streamed is void.
|
|
2217
|
+
if (data?.restarted) turnDom.generationRestarted(data);
|
|
2218
|
+
liveTurn.timing.setNote(retryStatusLine(data));
|
|
2219
|
+
},
|
|
2208
2220
|
plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
|
|
2209
2221
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
2210
2222
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
@@ -2434,8 +2446,8 @@ function addCommandBubble(view) {
|
|
|
2434
2446
|
|
|
2435
2447
|
// A command bubble's line and output (again, when its command_ran fills in
|
|
2436
2448
|
// an anytime command's bubble in place).
|
|
2437
|
-
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, commandId = null }) {
|
|
2438
|
-
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "");
|
|
2449
|
+
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
|
|
2450
|
+
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
|
|
2439
2451
|
if (commandId) div.dataset.commandId = commandId;
|
|
2440
2452
|
div.replaceChildren();
|
|
2441
2453
|
const head = document.createElement("div");
|
|
@@ -30,10 +30,12 @@ export function moveSelection(index, count, delta) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// One row's parts: the name, its description, and a note (the bundle it
|
|
33
|
-
// comes from; "mid-turn too" for an anytime one
|
|
33
|
+
// comes from; "mid-turn too" for an anytime one, "show: mid-turn too" for
|
|
34
|
+
// one whose show form is), "" when there is none.
|
|
34
35
|
export function commandRow(command) {
|
|
35
36
|
const notes = [];
|
|
36
37
|
if (command.source && command.source !== "core") notes.push(command.source);
|
|
37
38
|
if (command.anytime) notes.push("mid-turn too");
|
|
39
|
+
else if (command.mid_turn === "depends") notes.push("show: mid-turn too");
|
|
38
40
|
return { name: command.name, description: command.description || "", note: notes.join(" · ") };
|
|
39
41
|
}
|
|
@@ -2,11 +2,24 @@
|
|
|
2
2
|
// the kernel resolved for this generation (:generation_started carries it as
|
|
3
3
|
// context_window_tokens); llama.cpp's stream payloads report token counts
|
|
4
4
|
// but no n_ctx, so without it only a precomputed pct or an n_ctx works.
|
|
5
|
+
// `budgetTokens` is the session's LLM context budget (llm_context's
|
|
6
|
+
// budget_tokens): a percentage this file computes counts against it when it
|
|
7
|
+
// is smaller than the window, as the kernel's :context_status does
|
|
8
|
+
// (ContextStatus#counted_against), so the meter reads the same from either.
|
|
5
9
|
|
|
6
10
|
function num(v) {
|
|
7
11
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
8
12
|
}
|
|
9
13
|
|
|
14
|
+
// What a fill counts against: the window, or the budget when one is set
|
|
15
|
+
// and smaller; null without a window.
|
|
16
|
+
export function countedTokens(windowTokens, budgetTokens = null) {
|
|
17
|
+
const window = num(windowTokens);
|
|
18
|
+
if (!window || window <= 0) return null;
|
|
19
|
+
const budget = num(budgetTokens);
|
|
20
|
+
return budget && budget > 0 ? Math.min(budget, window) : window;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
// Tokens in context after this chunk: prompt + generated so far.
|
|
11
24
|
function usedTokens(p) {
|
|
12
25
|
if (!p || typeof p !== "object") return null;
|
|
@@ -18,30 +31,35 @@ function usedTokens(p) {
|
|
|
18
31
|
return (prompt ?? 0) + (completion ?? 0);
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
export function extractCtxPct(data, windowTokens = null) {
|
|
34
|
+
export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
|
|
22
35
|
if (!data || typeof data !== "object") return null;
|
|
23
36
|
if (num(data.ctx_pct) !== null) return data.ctx_pct;
|
|
24
37
|
if (num(data.est_pct) !== null) return data.est_pct;
|
|
25
38
|
if (num(data.ctxPct) !== null) return data.ctxPct;
|
|
26
39
|
// The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
|
|
27
40
|
if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
|
|
28
|
-
if (data.context_window_tokens && data.total_tokens)
|
|
41
|
+
if (data.context_window_tokens && data.total_tokens) {
|
|
42
|
+
const against = countedTokens(data.context_window_tokens, budgetTokens);
|
|
43
|
+
if (against) return (data.total_tokens / against) * 100;
|
|
44
|
+
}
|
|
29
45
|
const p = data.payload || data;
|
|
30
46
|
if (num(p.ctx_pct) !== null) return p.ctx_pct;
|
|
31
|
-
const
|
|
47
|
+
const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
|
|
32
48
|
const used = usedTokens(p);
|
|
33
|
-
if (!
|
|
34
|
-
return (used /
|
|
49
|
+
if (!against || used === null) return null;
|
|
50
|
+
return (used / against) * 100;
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
// The saved context (the session's timing.context: used_tokens after the
|
|
38
54
|
// last counted turn, the window now): the meter's value on load, before a
|
|
39
|
-
// turn streams
|
|
40
|
-
|
|
55
|
+
// turn streams, counted against +budgetTokens+ (the session's llm_context
|
|
56
|
+
// budget_tokens) when it is smaller, as live. null when either count is
|
|
57
|
+
// missing.
|
|
58
|
+
export function savedCtxPct(context, budgetTokens = null) {
|
|
41
59
|
const used = num(context?.used_tokens);
|
|
42
|
-
const
|
|
43
|
-
if (used === null || !
|
|
44
|
-
return (used /
|
|
60
|
+
const against = countedTokens(context?.window_tokens, budgetTokens);
|
|
61
|
+
if (used === null || !against) return null;
|
|
62
|
+
return (used / against) * 100;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
// A session card's quiet ctx: "12%", or "" when the list row has none.
|
|
@@ -59,12 +77,24 @@ export function speedText(tps, source) {
|
|
|
59
77
|
return `${source === "estimate" ? "~" : ""}${number} tok/s`;
|
|
60
78
|
}
|
|
61
79
|
|
|
62
|
-
// A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); ""
|
|
63
|
-
// none or zero. As
|
|
64
|
-
|
|
80
|
+
// A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "~$0.12"
|
|
81
|
+
// for an estimate (hosts.<name>.models prices); "" for none or zero. As
|
|
82
|
+
// Formatting#cost_text.
|
|
83
|
+
export function costText(cost, { estimate = false } = {}) {
|
|
65
84
|
const value = num(cost);
|
|
66
85
|
if (value === null || value <= 0) return "";
|
|
67
|
-
return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
|
|
86
|
+
return `${estimate ? "~" : ""}${value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The ctx tooltip's cost: the reported cost as is; with an estimate (an
|
|
90
|
+
// older worker or saved file has none) the total with "~" and both parts.
|
|
91
|
+
function costLine(tokens) {
|
|
92
|
+
const reported = num(tokens.cost_sum) || 0;
|
|
93
|
+
const estimated = num(tokens.cost_estimate_sum) || 0;
|
|
94
|
+
if (estimated <= 0) return reported > 0 ? `cost: ${costText(reported)}` : "";
|
|
95
|
+
const total = costText(reported + estimated, { estimate: true });
|
|
96
|
+
const parts = reported > 0 ? `${costText(reported)} reported, ${costText(estimated, { estimate: true })} estimated` : "estimated";
|
|
97
|
+
return `cost: ${total} (${parts})`;
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
function count(n) {
|
|
@@ -99,23 +129,33 @@ export function ctxBarText(pct, windowSource = null) {
|
|
|
99
129
|
return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
|
|
100
130
|
}
|
|
101
131
|
|
|
102
|
-
// The ctx tooltip's window line ({tokens:, source:, pct:} the
|
|
103
|
-
// window, where it came from
|
|
104
|
-
// 128.0k tokens (server)", as /stats
|
|
132
|
+
// The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
|
|
133
|
+
// session's window, where it came from, the meter's value and the LLM
|
|
134
|
+
// context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
|
|
135
|
+
// names the source; under a budget smaller than the window the meter's
|
|
136
|
+
// percentage is of the budget: "context: ~41.0k of the 64.0k budget,
|
|
137
|
+
// 128.0k window (server)"; "" without a window.
|
|
105
138
|
export function ctxWindowText(window) {
|
|
106
139
|
const tokens = num(window?.tokens);
|
|
107
140
|
if (!tokens || tokens <= 0) return "";
|
|
141
|
+
const against = countedTokens(tokens, window.budget);
|
|
108
142
|
const pct = num(window.pct);
|
|
109
|
-
const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
|
|
110
143
|
const source = window.source === "default"
|
|
111
144
|
? "chi's default, a guess: set window_tokens for this model"
|
|
112
145
|
: window.source;
|
|
113
|
-
|
|
146
|
+
const sourceText = source ? ` (${source})` : "";
|
|
147
|
+
if (against < tokens) {
|
|
148
|
+
const used = pct === null ? "" : `~${countK((pct / 100) * against)} of `;
|
|
149
|
+
return `context: ${used}the ${countK(against)} budget, ${countK(tokens)} window${sourceText}`;
|
|
150
|
+
}
|
|
151
|
+
const used = pct === null ? "" : `~${countK((pct / 100) * tokens)} of `;
|
|
152
|
+
return `context: ${used}${countK(tokens)} tokens${sourceText}`;
|
|
114
153
|
}
|
|
115
154
|
|
|
116
155
|
// The ctx tooltip (info bar and card): +title+, then the session's tokens
|
|
117
156
|
// (in summed over every request, cached with its share, out with the
|
|
118
|
-
// reasoning part) and its cost, when it has any
|
|
157
|
+
// reasoning part) and its cost, when it has any (an estimated part with
|
|
158
|
+
// "~", costLine), and the memory indexes
|
|
119
159
|
// its prompt holds (+memoryIndex+, the snapshot's memory_index); with
|
|
120
160
|
// +window+ (ctxWindowText's) the window line under the title. A
|
|
121
161
|
// delegated session keeps its own metrics, so these are this session's only.
|
|
@@ -133,8 +173,8 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
|
|
|
133
173
|
if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
|
|
134
174
|
parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
|
|
135
175
|
const lines = [title, `tokens: ${parts.join(" · ")}`];
|
|
136
|
-
const cost =
|
|
137
|
-
if (cost) lines.push(
|
|
176
|
+
const cost = costLine(tokens);
|
|
177
|
+
if (cost) lines.push(cost);
|
|
138
178
|
lines.push("this session only, all requests");
|
|
139
179
|
return `${lines.join("\n")}${memoryLine}`;
|
|
140
180
|
}
|