samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. metadata +5 -1
@@ -66,6 +66,12 @@ module Samagotchi
66
66
  # @api private
67
67
  attr_writer :reading
68
68
 
69
+ # Monotonic time a read's cursor query went out (#reset) when the read
70
+ # was stopped before Reline took the reply: the reply is still on its
71
+ # way, and LiveRegion.close waits for it. nil with none.
72
+ # @return [Float, nil]
73
+ attr_accessor :unanswered_query_at
74
+
69
75
  # @return [Boolean] this Reline has every method the seam relies on
70
76
  def supported?
71
77
  defined?(Reline::LineEditor::RenderedScreen) &&
@@ -107,7 +113,11 @@ module Samagotchi
107
113
  end
108
114
 
109
115
  def reset(...)
116
+ # Reline's reset asks the terminal for the cursor row and waits for
117
+ # the reply: a Stop raised meanwhile leaves this set.
118
+ RelineSeam.unanswered_query_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
119
  super
120
+ RelineSeam.unanswered_query_at = nil
111
121
  RelineSeam.reading = true
112
122
  @rendered_screen.base_y = 0 if RelineSeam.screen
113
123
  end
@@ -1,61 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- # TokenUsage extracts real server-reported token counts from a model API
5
- # stream payload, transport-agnostically.
4
+ # TokenUsage is the server-reported token accounting of one model API stream
5
+ # payload, read transport-agnostically.
6
6
  #
7
7
  # llama.cpp's /completion stream emits per-chunk `timings.prompt_n` /
8
8
  # `timings.predicted_n` (cumulative) and a final top-level
9
- # `tokens_evaluated` / `tokens_predicted`. mlx_lm.server (OpenAI-compatible)
10
- # emits a `usage` object with `prompt_tokens` / `completion_tokens`.
9
+ # `tokens_evaluated` / `tokens_predicted`. OpenAI-compatible servers emit a
10
+ # `usage` object with `prompt_tokens` / `completion_tokens`.
11
11
  #
12
- # Returns nil when no server token data is present, so a caller can fall back
13
- # to a chars/4 heuristic. The reported values are cumulative per request, so
14
- # callers should take the running MAX across chunks (see SessionMetrics).
15
- class TokenUsage
12
+ # The last chunk can say more: llama.cpp adds a `timings` block with exact
13
+ # speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
14
+ # so it is not read); OpenRouter adds `usage.cost` (USD) and the
15
+ # `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens`
16
+ # breakdowns. Those fields are nil when a payload doesn't carry them.
17
+ #
18
+ # from_payload returns nil when no server token counts are present, so a
19
+ # caller can fall back to a chars/4 heuristic. The reported values are
20
+ # cumulative per request, so callers should take the running MAX across
21
+ # chunks (see SessionMetrics).
22
+ class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :reasoning_tokens, :cost,
23
+ :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
16
24
  CHARS_PER_TOKEN = 4.0
17
25
 
18
26
  class << self
19
27
  # @param payload [Hash, nil] the raw streamed chunk payload
20
- # @return [Hash, nil] { prompt_tokens:, completion_tokens:, source: :server }
28
+ # @return [TokenUsage, nil]
21
29
  def from_payload(payload)
22
30
  payload_hash = payload.is_a?(Hash) ? payload : {}
23
- usage = payload_hash["usage"] || payload_hash[:usage]
24
- usage = {} unless usage.is_a?(Hash)
31
+ usage = hash_at(payload_hash, :usage)
32
+ timings = hash_at(payload_hash, :timings)
25
33
 
26
34
  prompt_tokens = first_positive(
27
- usage["prompt_tokens"],
28
- usage[:prompt_tokens],
29
- payload_hash["prompt_tokens"],
30
- payload_hash[:prompt_tokens],
31
- payload_hash["prompt_n"],
32
- payload_hash[:prompt_n],
33
- payload_hash["tokens_evaluated"],
34
- payload_hash[:tokens_evaluated],
35
- payload_hash.dig("timings", "prompt_n"),
36
- payload_hash.dig(:timings, :prompt_n)
35
+ *both(usage, :prompt_tokens), *both(payload_hash, :prompt_tokens), *both(payload_hash, :prompt_n),
36
+ *both(payload_hash, :tokens_evaluated), *both(timings, :prompt_n)
37
37
  )
38
-
39
38
  completion_tokens = first_positive(
40
- usage["completion_tokens"],
41
- usage[:completion_tokens],
42
- payload_hash["completion_tokens"],
43
- payload_hash[:completion_tokens],
44
- payload_hash["predicted_n"],
45
- payload_hash[:predicted_n],
46
- payload_hash["tokens_predicted"],
47
- payload_hash[:tokens_predicted],
48
- payload_hash.dig("timings", "predicted_n"),
49
- payload_hash.dig(:timings, :predicted_n)
39
+ *both(usage, :completion_tokens), *both(payload_hash, :completion_tokens),
40
+ *both(payload_hash, :predicted_n), *both(payload_hash, :tokens_predicted), *both(timings, :predicted_n)
50
41
  )
51
-
52
42
  return nil unless prompt_tokens || completion_tokens
53
43
 
54
- {
44
+ new(
55
45
  prompt_tokens: prompt_tokens,
56
46
  completion_tokens: completion_tokens,
47
+ cached_tokens: first_positive(*both(hash_at(usage, :prompt_tokens_details), :cached_tokens),
48
+ *both(timings, :cache_n)),
49
+ reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
50
+ cost: number(*both(usage, :cost)),
51
+ predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
52
+ prompt_per_second: positive_number(*both(timings, :prompt_per_second)),
53
+ predicted_ms: positive_number(*both(timings, :predicted_ms)),
57
54
  source: :server
58
- }
55
+ )
59
56
  end
60
57
 
61
58
  # Estimate token count from raw text length using the chars/4 heuristic.
@@ -73,16 +70,35 @@ module Samagotchi
73
70
  # float never raises RangeError; non-numeric values are skipped.
74
71
  def first_positive(*values)
75
72
  values.each do |value|
76
- next if value.nil?
77
-
78
- n = Float(value).to_i
79
- return n if n.positive?
80
- rescue ArgumentError, TypeError
81
- next
73
+ n = float(value)&.to_i
74
+ return n if n&.positive?
82
75
  end
83
76
 
84
77
  nil
85
78
  end
79
+
80
+ private
81
+
82
+ def hash_at(hash, key)
83
+ value = hash[key.to_s] || hash[key]
84
+ value.is_a?(Hash) ? value : {}
85
+ end
86
+
87
+ def both(hash, key) = [hash[key.to_s], hash[key]]
88
+
89
+ def positive_number(*values) = values.filter_map { |value| float(value) }.find(&:positive?)
90
+
91
+ # A cost of 0 is a real answer (a free model), so zero counts here.
92
+ def number(*values) = values.filter_map { |value| float(value) }.find { |n| n >= 0 }
93
+
94
+ def float(value)
95
+ return nil if value.nil?
96
+
97
+ n = Float(value)
98
+ n.finite? ? n : nil
99
+ rescue ArgumentError, TypeError
100
+ nil
101
+ end
86
102
  end
87
103
  end
88
104
  end
@@ -44,6 +44,7 @@ module Samagotchi
44
44
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
45
45
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
46
46
  # The gate runs first, so tool_call_started shows the call that runs.
47
+ original = call.dup
47
48
  verdict = evaluate(call, iteration, params)
48
49
  call = verdict.call
49
50
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
@@ -73,6 +74,11 @@ module Samagotchi
73
74
  result, images = attach_images(call, result) if result[:images]
74
75
 
75
76
  output = scrub(result[:output].to_s)
77
+ # The gate may have replaced the call (known-names corrects a near
78
+ # miss): one leading line tells the model what actually ran.
79
+ if (changed = changed_call_line(original, call))
80
+ output = "#{changed}\n#{output}"
81
+ end
76
82
  capped = output
77
83
  truncated = false
78
84
  if max_tool_output_chars && output.length > max_tool_output_chars
@@ -84,14 +90,16 @@ module Samagotchi
84
90
  # full output: ok, error, blocked, stopped); a dispatcher that raised
85
91
  # left none, an error.
86
92
  status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
87
- fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
88
- status: status || "error" })
89
93
  completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
90
94
  tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
91
95
  completed[:images] = images if images&.any?
92
96
  completed[:diff] = diff if diff
93
97
  completed[:waited_ms] = waited_ms if waited_ms
94
98
  emit(on_stream_event, completed)
99
+ # After the completed event: a hook's card (check-in, skills) prints
100
+ # under the call's tool row, not before it.
101
+ fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
102
+ status: status || "error" })
95
103
 
96
104
  run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
97
105
  run[:images] = images if images&.any?
@@ -203,6 +211,17 @@ module Samagotchi
203
211
  # A plugin tool's label, what the UIs show for its raw name.
204
212
  def plugin_label(name) = ToolActivity.plugin_label(name, registry: tools)
205
213
 
214
+ # The one leading line the model gets when the gate changed the call it
215
+ # asked for: "ran as: <tool> <changed args>", or nil when nothing
216
+ # changed. The args are the changed call's activity params, so the line
217
+ # stays short.
218
+ def changed_call_line(original, call)
219
+ return nil if original == call
220
+
221
+ params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
222
+ ["ran as: #{call[:name]}", params].compact.join(" ")
223
+ end
224
+
206
225
  # The Engine sets the kernel's gate (its context, later the approval
207
226
  # flow); a bare kernel (specs) gets one that only runs the hooks.
208
227
  def gate
@@ -199,22 +199,23 @@ module Samagotchi
199
199
  # thread; the text is safe to read afterwards.
200
200
  def finish
201
201
  @thread.join(STOP_POLL_INTERVAL_SEC) unless done?
202
- Execute.send(:close_quietly, @io)
202
+ close_quietly
203
203
  @thread.kill unless @thread.join(STOP_GRACE_SEC)
204
204
  end
205
205
 
206
206
  def text
207
207
  @buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
208
208
  end
209
- end
210
- private_constant :OutputReader
211
209
 
212
- def self.close_quietly(io)
213
- io.close unless io.closed?
214
- rescue IOError
215
- nil
210
+ private
211
+
212
+ def close_quietly
213
+ @io.close unless @io.closed?
214
+ rescue IOError
215
+ nil
216
+ end
216
217
  end
217
- private_class_method :close_quietly
218
+ private_constant :OutputReader
218
219
 
219
220
  def self.terminate_process_tree(pid)
220
221
  signal_process(pid, "TERM")
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.19.0"
4
+ VERSION = "0.21.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -21,6 +21,7 @@ require_relative "../steer"
21
21
  require_relative "../host_registry"
22
22
  require_relative "../model_catalog"
23
23
  require_relative "../model_profile"
24
+ require_relative "../sampling_settings"
24
25
  require_relative "../project_scope"
25
26
  require_relative "../prompt_history"
26
27
  require_relative "../version"
@@ -440,11 +441,29 @@ module Samagotchi
440
441
  if default && models.none? { |m| m[:name].casecmp?(default) }
441
442
  models.unshift({ name: default, host: nil, id: default })
442
443
  end
444
+ add_sampling(models, registry)
443
445
  payload = { default: default, models: models }
444
446
  payload[:warning] = warnings.join("; ") unless warnings.empty?
445
447
  json_response(200, payload)
446
448
  end
447
449
 
450
+ # Each model's configured sampling as /model words it ("temperature=0.6
451
+ # (hosts.work)"), on the models that have some: the picker's tooltip.
452
+ # A config that can't be read leaves the list as it is.
453
+ def add_sampling(models, registry)
454
+ return if registry.nil? || models.empty?
455
+
456
+ settings = ConfigFile.model_settings
457
+ models.each do |model|
458
+ target = registry.resolve(model[:name])
459
+ summary = SamplingSettings.summary(target, names: registry.lookup_names(model[:name], target: target),
460
+ models: settings)
461
+ model[:sampling] = summary if summary
462
+ end
463
+ rescue StandardError
464
+ nil
465
+ end
466
+
448
467
  # Built again when config.yml's hosts: changed (compared as read: the
449
468
  # YAML read is mtime-cached), so the model picker lists a host added
450
469
  # while chi web runs. An injected registry (specs) stays.
@@ -20,7 +20,7 @@ import { MODEL_KEY } from "./model_pick.js";
20
20
  import { emptySessionState } from "./session_state.js";
21
21
  import { createModelPicker } from "./model_picker.js";
22
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
- import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
23
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
24
  import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
25
  import { createStageView } from "./stage_view.js";
26
26
  import { appendQuote } from "./annotations.js";
@@ -28,9 +28,9 @@ import { parsePresets } from "./annotate_presets.js";
28
28
  import { createAnnotateBar } from "./annotate_bar.js";
29
29
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
30
30
  import { routeChunk } from "./chunk_router.js";
31
- import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
31
+ import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
32
32
  import { createQuestionCards } from "./question_cards.js";
33
- import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
33
+ import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
34
34
  import { gripFloor, promptHeight } from "./composer_size.js";
35
35
  import { createCommandList } from "./command_list.js";
36
36
  import { createPromptHistory } from "./prompt_history.js";
@@ -45,7 +45,7 @@ import {
45
45
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
46
46
  } from "./timing.js";
47
47
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
48
- import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
+ import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
49
49
 
50
50
  const $ = (s) => document.querySelector(s);
51
51
  const topStripEl = $("#topStrip");
@@ -134,6 +134,9 @@ const questionCards = createQuestionCards({
134
134
  // was rendered at (null: no live worker then), where a stream opened later
135
135
  // has to start.
136
136
  let selectSeq = null;
137
+ // The stream was opened from 0 (a wake with no cursor to go on) and no
138
+ // new turn has started on it yet: see followWokenWorker.
139
+ let streamFromGuess = false;
137
140
  // The last input_merged had an origin with no bubble: its merged text
138
141
  // (pending_input_merged) gets one.
139
142
  let unmatchedMerge = false;
@@ -165,6 +168,7 @@ let scopeError = null;
165
168
  async function refresh() {
166
169
  try {
167
170
  allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
171
+ notifications.noticeList(allSessions);
168
172
  } catch (e) {
169
173
  if (e.status !== 400 || !scope.dir) throw e;
170
174
  scopeError = e.message;
@@ -317,7 +321,7 @@ function cardHtml(s, { deletable = false } = {}) {
317
321
  const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
318
322
  const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
319
323
  const ctx = cardCtxText(s.ctx_pct);
320
- const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
324
+ const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
321
325
  const waiting = waitingBadge(s);
322
326
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
323
327
  const dotWord = waiting ? waiting.title : s.status;
@@ -1173,13 +1177,26 @@ function cardElement(id) {
1173
1177
  return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1174
1178
  }
1175
1179
 
1180
+ // A notice is a <details> (a one-line row that opens), a card a <div>: a
1181
+ // card that turns into the other kind (btw's "thinking…" → its answer) is
1182
+ // drawn on a new element in its place. Returns the element drawn.
1176
1183
  function drawCard(el, card) {
1184
+ const notice = isNoticeCard(card);
1185
+ if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
1186
+ const fresh = document.createElement(notice ? "details" : "div");
1187
+ el.replaceWith(fresh);
1188
+ el = fresh;
1189
+ }
1177
1190
  el.className = cardClass(card);
1178
1191
  el.dataset.cardId = card.id;
1179
1192
  // A running turn's own card (the stage's status waits on one that asks).
1180
1193
  if (card.in_turn) el.dataset.inTurn = "1";
1181
1194
  else delete el.dataset.inTurn;
1182
- if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1195
+ if (notice) {
1196
+ const open = el.open;
1197
+ el.innerHTML = noticeInnerHtml(card);
1198
+ el.open = open;
1199
+ } else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1183
1200
  // The same body again from the stream: keep the rendered one.
1184
1201
  const body = el.querySelector(".card-body").innerHTML;
1185
1202
  el.innerHTML = cardInnerHtml({ ...card, body_html: body });
@@ -1187,6 +1204,7 @@ function drawCard(el, card) {
1187
1204
  el.innerHTML = cardInnerHtml(card);
1188
1205
  }
1189
1206
  el.dataset.body = String(card.body ?? "");
1207
+ return el;
1190
1208
  }
1191
1209
 
1192
1210
  // Show +card+: update the one with its id where it is, else add it (a
@@ -1198,14 +1216,13 @@ function upsertCard(card, { before = null } = {}) {
1198
1216
  if (!card || !card.id) return;
1199
1217
  const existing = cardElement(card.id);
1200
1218
  if (existing) {
1201
- drawCard(existing, card);
1202
- existing.classList.add("card-updated");
1203
- setTimeout(() => existing.classList.remove("card-updated"), 900);
1219
+ const drawn = drawCard(existing, card);
1220
+ drawn.classList.add("card-updated");
1221
+ setTimeout(() => drawn.classList.remove("card-updated"), 900);
1204
1222
  return;
1205
1223
  }
1206
1224
  removeHintIfEmpty();
1207
- const el = document.createElement("div");
1208
- drawCard(el, card);
1225
+ const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
1209
1226
  const asks = (card.actions || []).some((a) => a && a.command);
1210
1227
  if (card.in_turn && !before && !asks && turnDom.card(el)) return;
1211
1228
  const follow = isNearBottom();
@@ -1328,7 +1345,7 @@ function onCardAction(e) {
1328
1345
  sendCommand(selected, button.dataset.command, { clientId, card: true })
1329
1346
  .then(() => {
1330
1347
  // The command woke the worker: follow its stream if none is open.
1331
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1348
+ followWokenWorker();
1332
1349
  })
1333
1350
  .catch((error) => {
1334
1351
  err.textContent = error.message;
@@ -1403,6 +1420,7 @@ function updateInfoBar() {
1403
1420
  archiveBtn.style.display = selected ? "" : "none";
1404
1421
  const archived = selectedArchived();
1405
1422
  archiveBtn.textContent = archived ? "unarchive" : "archive";
1423
+ archiveBtn.classList.toggle("archived", archived);
1406
1424
  archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
1407
1425
  }
1408
1426
  if (!selected) {
@@ -1416,6 +1434,11 @@ function updateInfoBar() {
1416
1434
  const idShort = selected.slice(0,8);
1417
1435
  const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1418
1436
  const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
1437
+ const tokens = current.timing.tokens;
1438
+ const ctxTip = tokensTipText(tokens);
1439
+ const speedTextNow = lastSpeedText(tokens);
1440
+ const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1441
+ const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
1419
1442
  const sessionDuration = currentSessionDuration();
1420
1443
  const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
1421
1444
  const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
@@ -1426,7 +1449,7 @@ function updateInfoBar() {
1426
1449
  const attachCmd = `chi --attach ${selected}`;
1427
1450
  const copied = Date.now() - attachCopiedAt < 2000;
1428
1451
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1429
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1452
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}${workerBadgeHtml()}</div>`;
1430
1453
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1431
1454
  infoTextEl.innerHTML = metaHtml + previewHtml;
1432
1455
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
@@ -1575,7 +1598,18 @@ function setUsedMemories(names) {
1575
1598
  }
1576
1599
  }
1577
1600
 
1601
+ // A turn or command just woke the session's worker: follow its stream if
1602
+ // none is open, from the render's cursor. A render with none (no worker
1603
+ // then) streams from 0, the start of a new worker's events; should that
1604
+ // worker have been up already, turn_started says so (replaysDrawnTurn).
1605
+ function followWokenWorker() {
1606
+ if (streamControl?.live) return;
1607
+ startStream(selected, selectSeq ?? 0);
1608
+ streamFromGuess = selectSeq === null;
1609
+ }
1610
+
1578
1611
  function startStream(id, fromSeq) {
1612
+ streamFromGuess = false;
1579
1613
  if (streamControl) {
1580
1614
  streamControl.close();
1581
1615
  streamControl = null;
@@ -1648,16 +1682,17 @@ const streamHandlers = {
1648
1682
  },
1649
1683
  generation_completed: (data) => {
1650
1684
  setLiveTurnNote("");
1651
- if (data?.served_model) {
1652
- current.served = [data.served_model, data.requested_model];
1653
- updateInfoBar();
1654
- }
1685
+ if (data?.served_model) current.served = [data.served_model, data.requested_model];
1686
+ // The generation's speed and the session's token totals, from the server.
1687
+ current.timing.tokens = generationTokens(current.timing.tokens, data);
1688
+ if (data?.served_model || data?.tokens) updateInfoBar();
1655
1689
  turnDom.generationCompleted();
1656
1690
  },
1657
1691
  // Prompts sent while a turn ran merge into it at an iteration boundary:
1658
1692
  // input_merged names them (their bubbles turn "steered"), then
1659
1693
  // pending_input_merged carries the merged text, shown only for prompts
1660
- // this tab has no bubble for.
1694
+ // this tab has no bubble for. Its `answer` (the answer the merge
1695
+ // superseded) is the TUI's line: here it streamed as that step's text.
1661
1696
  input_merged: (data) => applyPromptOps(data),
1662
1697
  pending_input_merged: (data) => {
1663
1698
  applyPromptOps(data);
@@ -1665,6 +1700,16 @@ const streamHandlers = {
1665
1700
  },
1666
1701
  turn_enqueued: (data) => applyPromptOps(data),
1667
1702
  turn_started: (data) => keepFollowing(historyEl, () => {
1703
+ if (streamFromGuess) {
1704
+ streamFromGuess = false;
1705
+ // A replay of a turn the render drew: re-read and stream from there.
1706
+ if (replaysDrawnTurn(data, current.timing.turnRecords)) {
1707
+ streamControl?.close();
1708
+ streamControl = null;
1709
+ resync();
1710
+ return;
1711
+ }
1712
+ }
1668
1713
  // A finished turn still in the stage goes first: the start op looks
1669
1714
  // for a queued prompt among its rows.
1670
1715
  turnDom.handOff?.({ immediate: true });
@@ -1711,7 +1756,7 @@ const streamHandlers = {
1711
1756
  }
1712
1757
  },
1713
1758
  turn_canceled: (data) => {
1714
- endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
1759
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
1715
1760
  setLiveStatus("idle");
1716
1761
  refreshTimingFromSession();
1717
1762
  },
@@ -1947,7 +1992,7 @@ async function handleSendTurn() {
1947
1992
  echo.remove();
1948
1993
  promptEl.value = "";
1949
1994
  fitPrompt();
1950
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1995
+ followWokenWorker();
1951
1996
  return;
1952
1997
  }
1953
1998
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
@@ -1969,7 +2014,7 @@ async function handleSendTurn() {
1969
2014
  // events in between. Without one, POST /turn just woke a worker: its
1970
2015
  // events start after the seq the session was rendered at (0 for a new
1971
2016
  // worker process).
1972
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
2017
+ followWokenWorker();
1973
2018
  } catch (e) {
1974
2019
  echo.remove();
1975
2020
  // The turn never reached the worker's queue: an early restore waiting
@@ -2010,7 +2055,7 @@ async function handleSendCommand(line) {
2010
2055
  fitPrompt();
2011
2056
  refreshHistory();
2012
2057
  // The command woke the worker: follow its stream if none is open.
2013
- if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
2058
+ followWokenWorker();
2014
2059
  } catch (e) {
2015
2060
  if (/owned_by_tui|409/.test(e.message)) {
2016
2061
  current.owner = "tui";
@@ -2432,6 +2477,7 @@ shortWindow.addEventListener("change", () => {
2432
2477
  applyStripHidden();
2433
2478
  });
2434
2479
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2480
+ $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
2435
2481
 
2436
2482
  $("#filter").addEventListener("input", renderAll);
2437
2483
  // A list whose order was held under the pointer (listOrder) catches up.
@@ -3,9 +3,41 @@
3
3
  // the element; this module draws it and says where it goes.
4
4
  import { escapeHtml, recapPlace } from "./format.js";
5
5
 
6
- // The card's class: a warn card wears the warning colour.
6
+ // The card's class: a warn card wears the warning colour; a notice is a
7
+ // one-line row.
7
8
  export function cardClass(card) {
8
- return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "");
9
+ return "bubble plugin-card" + (card?.level === "warn" ? " warn" : "") + (isNoticeCard(card) ? " notice" : "");
10
+ }
11
+
12
+ // The longest body a notice row carries; a longer one is a card to read.
13
+ export const NOTICE_CHARS = 160;
14
+
15
+ // Whether a card is a notice (check-in's "Nudged the model at 105 tool
16
+ // calls."): info level, nothing to click, a body of one short line. It
17
+ // shows as a collapsed one-line row, "▸ check-in: Nudged …", as the tool
18
+ // rows and the resolved question cards do; a click opens it. A warn card,
19
+ // one that asks (actions) and one with more to read stay cards.
20
+ export function isNoticeCard(card) {
21
+ if (!card || card.level === "warn") return false;
22
+ if ((card.actions || []).some((a) => a && a.command)) return false;
23
+ const body = String(card.body ?? "").trim();
24
+ return !body.includes("\n") && body.length <= NOTICE_CHARS;
25
+ }
26
+
27
+ // A notice's one line: "title: body" (the title alone for no body).
28
+ export function noticeCardLine(card) {
29
+ const title = String(card?.title ?? "").trim();
30
+ const body = String(card?.body ?? "").trim();
31
+ return title && body ? `${title}: ${body}` : title || body;
32
+ }
33
+
34
+ // A notice's markup inside its <details>: the line as the summary, and
35
+ // where it came from when the row is opened (the line wraps then).
36
+ export function noticeInnerHtml(card) {
37
+ const title = String(card?.title ?? "").trim();
38
+ const source = String(card?.source ?? "").trim();
39
+ const from = source && source !== title ? `<div class="notice-from">from <span class="card-source">${escapeHtml(source)}</span></div>` : "";
40
+ return `<summary>${escapeHtml(noticeCardLine(card))}</summary>${from}`;
9
41
  }
10
42
 
11
43
  // Whether a card shown as a row of a step leaves the turn's block when the
@@ -48,3 +48,62 @@ export function savedCtxPct(context) {
48
48
  export function cardCtxText(pct) {
49
49
  return num(pct) === null ? "" : `${Math.round(pct)}%`;
50
50
  }
51
+
52
+ // A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
53
+ // server timings); "" without one. The TUI's /stats says the same
54
+ // (Formatting#speed_text; spec/shared/labels_matrix.json).
55
+ export function speedText(tps, source) {
56
+ const value = num(tps);
57
+ if (value === null || value <= 0) return "";
58
+ const number = value >= 1000 ? `${(Math.round(value / 100) / 10).toFixed(1)}k` : String(Math.round(value));
59
+ return `${source === "estimate" ? "~" : ""}${number} tok/s`;
60
+ }
61
+
62
+ // A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "" for
63
+ // none or zero. As Formatting#cost_text.
64
+ export function costText(cost) {
65
+ const value = num(cost);
66
+ if (value === null || value <= 0) return "";
67
+ return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
68
+ }
69
+
70
+ function count(n) {
71
+ return Math.round(n).toLocaleString("en-US");
72
+ }
73
+
74
+ // The ctx tooltip (info bar and card): +title+, then the session's tokens
75
+ // (in summed over every request, cached with its share, out with the
76
+ // reasoning part) and its cost, when it has any. A delegated session keeps
77
+ // its own metrics, so these are this session's only.
78
+ export function tokensTipText(tokens, title = "Context used after the last turn") {
79
+ const prompt = num(tokens?.prompt_sum) || 0;
80
+ const completion = num(tokens?.completion_sum) || 0;
81
+ if (!prompt && !completion) return title;
82
+ const cached = num(tokens.cached_sum) || 0;
83
+ const reasoning = num(tokens.reasoning_sum) || 0;
84
+ const parts = [`in ${count(prompt)}`];
85
+ if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
86
+ parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
87
+ const lines = [title, `tokens: ${parts.join(" · ")}`];
88
+ const cost = costText(tokens.cost_sum);
89
+ if (cost) lines.push(`cost: ${cost}`);
90
+ lines.push("this session only, all requests");
91
+ return lines.join("\n");
92
+ }
93
+
94
+ // The info bar's speed: the newest generation's decode speed, "" before one.
95
+ export function lastSpeedText(tokens) {
96
+ return speedText(tokens?.last_decode_tps, tokens?.tps_source);
97
+ }
98
+
99
+ // The session's tokens block after a :generation_completed: the event's
100
+ // running totals (the server closed the generation and put them on it),
101
+ // with its speed; an event without them (a replay, an older worker) keeps
102
+ // +previous+.
103
+ export function generationTokens(previous, data) {
104
+ const tokens = data?.tokens;
105
+ if (!tokens || typeof tokens !== "object") return previous ?? null;
106
+ const speed = data.speed;
107
+ if (!speed || num(speed.decode_tps) === null) return tokens;
108
+ return { ...tokens, last_decode_tps: speed.decode_tps, tps_source: speed.source };
109
+ }