samagotchi 0.14.0 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 760138c7792d414a15c39e074c5a6dc0ad44dfd0937c12707c3e6428a3544d43
4
- data.tar.gz: d347cc3e3d7433292c40484beb73b95e2e3a9efb48ecd5ba5168b3c562c8d7e2
3
+ metadata.gz: 7cb85a0b5509a00cd396dd2db8c269c7959ccbbdd004649d626476067b652bbd
4
+ data.tar.gz: b2a46cc3632563de6f1d7c0f979b9ac54bccdfa765a643c576a5c24d7b492bf3
5
5
  SHA512:
6
- metadata.gz: ae3585c68672ad0b163448b92c83e9f2d60dbc5efd60c47090170d80750ad20a08a5eae97aebf0dbc57629909db228de565a6a3a00ccb40bff9757ea66c3aeff
7
- data.tar.gz: 4330ef708fe3ebc22f30e4cf249f88fb68a870eb1afd3be9894822e8e819ca25d393b34ffbabf56ef23ee784016b7cdcae9014d709f42db31fac88f501048ddb
6
+ metadata.gz: '07187883e2ffb8d0a0cbeaafd870551df02e166d7301e3696223efeba5d7b4eaab002a4ccc5180dd88c8d20c8cbcec302b2c9932ea99bd0b872b34051375d651'
7
+ data.tar.gz: 759493ed3abbd40da61128bca6cbb2805937697af546739b52e1e365b01867d1c648484aefdd7a4b1512acc50515d2c588f6fc48342c36f238b2c34713827750
data/CHANGELOG.md CHANGED
@@ -8,6 +8,32 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.15.0] - 2026-10-02
12
+
13
+ ### Added
14
+
15
+ - The hidden session strip's pill counts the sessions that wait for you (`▾ 5 sessions · 1 live · 1 waiting`, in the
16
+ warn colour).
17
+ - The all-sessions search finds the sessions that wait for you: `waiting`, `question`, `approval` or `needs you`.
18
+
19
+ ### Fixed
20
+
21
+ - `chi --resume ID` (and `--attach`) shows the last exchange's tool calls as the live tool rows
22
+ (`tool> running command (execute command="true"): ok`) between the prompt and the answer, not the prompt and the
23
+ answer alone.
24
+ - A `--no-shared` REPL keeps the session's `cards.json` as a worker does: its turns count, so the cards and notices an
25
+ earlier worker showed stay where they were in the web, and the REPL's own are saved too.
26
+ - A deleted, discarded or retention-pruned session takes its plugins' state with it: a bundle's
27
+ `plugins/<bundle>/sessions/<id>.json` (or `<id>/`) under the state dir, such as check-in's `/checkin` settings, no
28
+ longer stays behind.
29
+ - `chi self`'s context window row shows the current model's configured window: its `models.<key>.window_tokens`, else
30
+ its host's `hosts.<name>.window_tokens`, else `context.window_tokens` or the default, and says which.
31
+ - The web pairs each prompt with its own turn's timing: a failed turn (its prompt went back to you) no longer shifts
32
+ the timing lines and turn numbers of the turns after it. chi saves a turn id on each prompt; prompts saved before
33
+ this still pair by their place.
34
+
35
+ Update with `chi update` (no bundle changes).
36
+
11
37
  ## [0.14.0] - 2026-10-02
12
38
 
13
39
  ### Added
@@ -782,7 +808,8 @@ and long-lived sessions.
782
808
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
783
809
  hotkey panel that send selected text or the clipboard to your sessions.
784
810
 
785
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.14.0...HEAD
811
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.15.0...HEAD
812
+ [0.15.0]: https://github.com/dm1try/samagotchi/compare/v0.14.0...v0.15.0
786
813
  [0.14.0]: https://github.com/dm1try/samagotchi/compare/v0.13.0...v0.14.0
787
814
  [0.13.0]: https://github.com/dm1try/samagotchi/compare/v0.12.0...v0.13.0
788
815
  [0.12.0]: https://github.com/dm1try/samagotchi/compare/v0.11.0...v0.12.0
data/docs/plugins.md CHANGED
@@ -479,7 +479,7 @@ full label, `plugin.rb (bundle my-bundle)`.
479
479
  | `ctx.cwd` | the session's working directory |
480
480
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
481
481
  | `ctx.settings` | the bundle's settings, frozen |
482
- | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use |
482
+ | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
483
483
  | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
484
484
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
485
485
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
@@ -937,8 +937,8 @@ with a line and carry on unchanged.
937
937
 
938
938
  `/checkin` (anytime) for this session. What it changes (on/off, the mode,
939
939
  the threshold) is saved with the session, in the bundle's data dir
940
- (`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart;
941
- another session starts from config.yml:
940
+ (`plugins/check-in/sessions/<id>.json`), so it outlasts a worker's restart
941
+ and goes with the session; another session starts from config.yml:
942
942
 
943
943
  | | |
944
944
  |---|---|
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.14.0
3
+ version: 0.15.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -1362,8 +1362,10 @@ module Samagotchi
1362
1362
  # +ended+: the end event is out (what follows is post-turn work).
1363
1363
  # +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
1364
1364
  # set on the kernel in #generate.
1365
+ # +id+: names the turn on :turn_started (its metrics record's id) and on
1366
+ # its saved prompt, so a UI pairs the two whatever left the conversation.
1365
1367
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1366
- :settings) do
1368
+ :settings, :id) do
1367
1369
  # The turn's boundary events carry the origin only when there is one,
1368
1370
  # so payloads stay unchanged for callers that don't pass it.
1369
1371
  def tag(event) = origin ? event.merge(origin: origin) : event
@@ -1478,7 +1480,8 @@ module Samagotchi
1478
1480
  @guardrail_wiring.begin_turn(origin)
1479
1481
 
1480
1482
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1481
- controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
1483
+ controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
1484
+ id: SecureRandom.uuid)
1482
1485
  end
1483
1486
  private :begin_turn
1484
1487
 
@@ -1492,7 +1495,7 @@ module Samagotchi
1492
1495
  image_refs, image_error = turn_image_refs(session, images)
1493
1496
  # Emit turn_started event
1494
1497
  bind_metrics(session)
1495
- turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
1498
+ turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
1496
1499
  turn_started[:continue] = true if turn.continue
1497
1500
  turn_started[:images] = image_refs unless image_refs.empty?
1498
1501
  emit_event(turn.on_event, turn.tag(turn_started))
@@ -1595,7 +1598,7 @@ module Samagotchi
1595
1598
 
1596
1599
  return if turn.continue
1597
1600
 
1598
- user_message = { role: "user", content: turn.prompt }
1601
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
1599
1602
  user_message[:images] = image_refs unless image_refs.empty?
1600
1603
  turn.messages << user_message
1601
1604
  session.last_prompt = turn.prompt
@@ -193,9 +193,10 @@ module Samagotchi
193
193
  # The shape the engine persists: role and content (parts stay an
194
194
  # Array), plus a model turn's tool_calls and thinking (the host's
195
195
  # reasoning, never sent back), a result's tool_call_id, the image
196
- # refs of a user message or a tool result, and a plugin tool result's
197
- # tool_params and tool_labels (the live row's params line and label,
198
- # never sent back), and an edit/write result's tool_diffs (never sent).
196
+ # refs of a user message or a tool result, a prompt's turn_id (Engine,
197
+ # never sent), and a plugin tool result's tool_params and tool_labels
198
+ # (the live row's params line and label, never sent back), and an
199
+ # edit/write result's tool_diffs (never sent).
199
200
  def plain(conversation)
200
201
  conversation.map do |entry|
201
202
  content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
@@ -203,6 +204,7 @@ module Samagotchi
203
204
  message[:tool_calls] = entry[:tool_calls] if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
204
205
  message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
205
206
  message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
207
+ message[:turn_id] = entry[:turn_id] if entry[:turn_id]
206
208
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
207
209
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
208
210
  message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "paths"
5
+ require_relative "session"
6
+
7
+ module Samagotchi
8
+ # A plugin's state for one session, by convention under its data dir
9
+ # (ctx.data_dir): sessions/<id>.json or sessions/<id>/. It goes when the
10
+ # session does (SessionManager.delete_session: the CLI, the web, a
11
+ # discarded empty session; SessionRetention's prune), so no bundle keeps
12
+ # state for sessions that are gone (check-in's /checkin settings).
13
+ module PluginSessionState
14
+ module_function
15
+
16
+ # @return [Array<String>] the paths removed
17
+ def remove(session_id, env: ENV)
18
+ id = session_id.to_s
19
+ # Only a plain id: anything else could name a path outside the dir.
20
+ return [] unless Session.valid_id?(id)
21
+
22
+ Dir.glob(File.join(Paths.state_dir(env: env), "plugins", "*", "sessions")).flat_map do |dir|
23
+ [File.join(dir, "#{id}.json"), File.join(dir, id)].select { |path| File.exist?(path) }
24
+ .each { |path| FileUtils.rm_rf(path) }
25
+ end
26
+ rescue SystemCallError
27
+ []
28
+ end
29
+ end
30
+ end
@@ -56,7 +56,7 @@ module Samagotchi
56
56
  ["profile", model ? profile_for(model, env) : "-"],
57
57
  ["thinking", model ? thinking_for(model, env) : "-"],
58
58
  ["served model", model ? served_model_for(model, env) : "-"],
59
- ["context window", context_window(env)],
59
+ ["context window", context_window(env, model)],
60
60
  ["bundles", bundles_summary],
61
61
  ["desktop", desktop_summary(env)],
62
62
  ["chi web", web_summary]
@@ -100,9 +100,29 @@ module Samagotchi
100
100
  def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
101
101
 
102
102
  # Offline, so only the fallback: the running server's n_ctx wins at runtime.
103
- def context_window(env)
104
- window = ContextWindow.configured(env: env)
105
- "#{window.tokens} (#{window.source}; the server's n_ctx wins at runtime)"
103
+ # The window config gives the current model (ContextWindow: its
104
+ # models.<key>.window_tokens, its host's, then context.window_tokens or
105
+ # the default) and which of them, as a turn resolves it offline.
106
+ def context_window(env, model = nil)
107
+ tokens, where = model_window(env, model) || ContextWindow.configured(env: env).to_h.values_at(:tokens, :source)
108
+ "#{tokens} (#{where}; the server's n_ctx wins at runtime)"
109
+ end
110
+
111
+ # [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
112
+ def model_window(env, model)
113
+ return nil unless model
114
+
115
+ registry = HostRegistry.new(env: env)
116
+ target = registry.resolve(model)
117
+ names = registry.lookup_names(model, target: target)
118
+ models = ConfigFile.model_settings(env: env)
119
+ window = ContextWindow.setting(target, names: names, models: models)
120
+ return nil unless window
121
+ return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
122
+
123
+ [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
124
+ rescue StandardError
125
+ nil
106
126
  end
107
127
 
108
128
  def model_name
@@ -23,6 +23,7 @@ require_relative "installed_gem"
23
23
  require_relative "recap_store"
24
24
  require_relative "archive_store"
25
25
  require_relative "image_store"
26
+ require_relative "plugin_session_state"
26
27
  require_relative "terminal_ui"
27
28
 
28
29
  module Samagotchi
@@ -598,6 +599,7 @@ module Samagotchi
598
599
  FileUtils.rm_rf(dir)
599
600
  removed << dir
600
601
  end
602
+ PluginSessionState.remove(id)
601
603
  { id: id, removed: removed, stopped: !owner.nil? }
602
604
  end
603
605
 
@@ -284,7 +284,7 @@ module Samagotchi
284
284
  token_sources: [],
285
285
  retries: 0,
286
286
  model: nil,
287
- id: SecureRandom.uuid,
287
+ id: event[:turn_id] || SecureRandom.uuid,
288
288
  started_at: now.iso8601(3),
289
289
  started_monotonic: monotonic_time,
290
290
  tool_calls_by_id: {}
@@ -8,6 +8,7 @@ require_relative "config"
8
8
  require_relative "log"
9
9
  require_relative "model_profile"
10
10
  require_relative "session_manager"
11
+ require_relative "plugin_session_state"
11
12
 
12
13
  module Samagotchi
13
14
  # Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
@@ -79,7 +80,10 @@ module Samagotchi
79
80
  next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
80
81
  next if SessionManager.session_owner(name, state_dir: state_dir)
81
82
 
82
- FileUtils.rm_rf(dir) unless dry_run
83
+ unless dry_run
84
+ FileUtils.rm_rf(dir)
85
+ PluginSessionState.remove(name)
86
+ end
83
87
  name
84
88
  rescue SystemCallError
85
89
  nil
@@ -246,6 +250,7 @@ module Samagotchi
246
250
  FileUtils.rm_f(path)
247
251
  sidecar = File.join(state_dir, session.id)
248
252
  FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
253
+ PluginSessionState.remove(session.id)
249
254
  deleted << session.id
250
255
  rescue StandardError
251
256
  skipped << session.id
@@ -21,6 +21,8 @@ require_relative "../output_formatter"
21
21
  require_relative "../session_commands"
22
22
  require_relative "../session_manager"
23
23
  require_relative "../guardrails/parent_approvals"
24
+ require_relative "../tool_activity"
25
+ require_relative "../web/message_parts"
24
26
 
25
27
  module Samagotchi
26
28
  class TerminalUI
@@ -937,9 +939,7 @@ module Samagotchi
937
939
 
938
940
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
939
941
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
940
- exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
941
- @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
942
- end
942
+ render_join_steps(messages, messages.index { |m| m.equal?(exchange[last_user]) })
943
943
  # The saved answer is raw: the latest keeps its thinking, and one may
944
944
  # be only a tool call.
945
945
  answer = exchange[(last_user + 1)..].reverse_each
@@ -949,6 +949,31 @@ module Samagotchi
949
949
  render_join_notes(messages, after_exchange: true)
950
950
  end
951
951
 
952
+ # What the last exchange did, as the live turn drew it: each saved
953
+ # call's tool row (action, params, status from its result; no
954
+ # duration, which the session doesn't keep) and a plugin's steer
955
+ # where it came. The step texts stay out, as live (the ticker's).
956
+ def render_join_steps(messages, from)
957
+ messages.each_with_index.drop(from + 1).each do |m, i|
958
+ if Steer.steer?(m)
959
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
960
+ elsif %w[model assistant].include?(m[:role].to_s)
961
+ responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
962
+ Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each { |part| @screen.commit(join_tool_line(part)) }
963
+ end
964
+ end
965
+ end
966
+
967
+ # A saved call's row; "no result" for a call the turn never answered
968
+ # (it ended first).
969
+ def join_tool_line(part)
970
+ output = part[:output]
971
+ status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
972
+ activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
973
+ params: part[:params], status: status }
974
+ "#{format_tool_activity_line(activity)}#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
975
+ end
976
+
952
977
  # The context notes that came since the last prompt (all of them in a
953
978
  # session with none), after the exchange the header shows.
954
979
  def render_join_notes(messages, after_exchange: false)
@@ -14,6 +14,7 @@ require_relative "llm/errors"
14
14
  require_relative "host_registry"
15
15
  require_relative "session"
16
16
  require_relative "owner_lock"
17
+ require_relative "bridge/card_store"
17
18
  require_relative "engine"
18
19
  require_relative "guardrails/parent_approvals"
19
20
  require_relative "tools/memory"
@@ -223,6 +224,7 @@ module Samagotchi
223
224
  scratch: @scratch
224
225
  ))
225
226
  claim_session!(session.id) unless @owner_lock
227
+ keep_cards(session)
226
228
  # Saved at once, so a process killed before its first turn leaves a
227
229
  # file the sweep knows to delete.
228
230
  session.save if @scratch
@@ -345,6 +347,15 @@ module Samagotchi
345
347
  @surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
346
348
  end
347
349
 
350
+ # The session's cards.json, kept as a worker's Bridge keeps it (it owns
351
+ # the session as a worker would): this REPL's kept turns count on from
352
+ # the saved count, so a card an earlier worker showed stays that many
353
+ # turns back, and its own cards and notices are saved for the next.
354
+ def keep_cards(session)
355
+ path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
356
+ @cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
357
+ end
358
+
348
359
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
349
360
  # its own Engine, so no worker may run the session meanwhile.
350
361
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1269,6 +1269,10 @@ module Samagotchi
1269
1269
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
1270
1270
  # read by step +step+.
1271
1271
  message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1272
+ # The turn it started (Engine): the page pairs it with that turn's
1273
+ # record by this, not by its place.
1274
+ turn_id = m[:turn_id] || m["turn_id"]
1275
+ message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
1272
1276
  images = m[:images] || m["images"]
1273
1277
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1274
1278
  if norm_role == "assistant"
@@ -38,7 +38,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
38
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
40
  import { stripColumns, stripShowParts } from "./strip.js";
41
- import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, withChildrenAfterParents } from "./sessions_list.js";
41
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
42
  import { createNotifications } from "./notifications.js";
43
43
  import {
44
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
@@ -262,7 +262,8 @@ function filteredSessions(list) {
262
262
  (s.first_preview || "").toLowerCase().includes(q) ||
263
263
  s.short_id.toLowerCase().includes(q) ||
264
264
  s.id.toLowerCase().includes(q) ||
265
- s.status.toLowerCase().includes(q),
265
+ s.status.toLowerCase().includes(q) ||
266
+ waitingSearchText(s).includes(q),
266
267
  );
267
268
  }
268
269
 
@@ -385,11 +386,13 @@ function renderTop(sessions) {
385
386
  $("#stripHide").addEventListener("click", () => setStripHidden(true));
386
387
  }
387
388
 
388
- // The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
389
+ // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
390
+ // following the list.
389
391
  function renderStripShow() {
390
- const { count, live } = stripShowParts(shownSessions());
392
+ const { count, live, waiting } = stripShowParts(shownSessions());
391
393
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
392
- $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
394
+ const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
395
+ $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
393
396
  }
394
397
 
395
398
  // A delegated session sits right after its parent: applied on every
@@ -636,6 +636,8 @@ body.has-session #scope .scope-where{display:none}
636
636
  #stripShow{display:none;white-space:nowrap;flex-shrink:0}
637
637
  #stripShow .live{color:var(--ok-text);white-space:nowrap}
638
638
  #stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
639
+ #stripShow .waiting{color:var(--warn-text);white-space:nowrap}
640
+ #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
639
641
  #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
640
642
  #stripHide:hover{text-decoration:underline;color:var(--text-2)}
641
643
  body.strip-hidden #stripShow{display:block}
@@ -81,6 +81,14 @@ export function waitingBadge(summary) {
81
81
  return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
82
82
  }
83
83
 
84
+ // What the all view's search matches for a session that waits on the user:
85
+ // "waiting" and its badge's word ("question", "approval", "needs you");
86
+ // "" for one that doesn't.
87
+ export function waitingSearchText(summary) {
88
+ const badge = waitingBadge(summary);
89
+ return badge ? `waiting ${badge.text}` : "";
90
+ }
91
+
84
92
  // The sessions that wait on the user (waitingOn) first, the rest after,
85
93
  // each part in the list's order: an answered one goes back to its place.
86
94
  // A delegated session moves with its parent when both are listed (a
@@ -1,3 +1,5 @@
1
+ import { waitingOn } from "./notify.js";
2
+
1
3
  // How many session cards the top strip shows for its width in px: a card
2
4
  // needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
3
5
  // never fewer than 3 (narrow screens scroll them), never more than 6.
@@ -13,13 +15,18 @@ export function stripColumns(width) {
13
15
  // The hidden strip's "▾ sessions" pill carries the list's count, and how
14
16
  // many of those sessions a worker runs (the cards' green "live" badge), so a
15
17
  // user who hid the strip still sees there is a live session to go back to.
16
- // Plain text parts; live is "" when none. Zero sessions keep the bare label.
18
+ // And how many wait on the user (notify.js waitingOn: a question, an
19
+ // approval, a card), so a hidden strip still says one needs an answer.
20
+ // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
+ // the bare label.
17
22
  export function stripShowParts(sessions) {
18
23
  const list = sessions || [];
19
24
  const n = list.length;
20
25
  const liveN = list.filter((s) => s && s.owner === "worker").length;
26
+ const waitingN = list.filter((s) => waitingOn(s)).length;
21
27
  return {
22
28
  count: n ? `${n} ${n === 1 ? "session" : "sessions"}` : "sessions",
23
29
  live: liveN ? `${liveN} live` : "",
30
+ waiting: waitingN ? `${waitingN} waiting` : "",
24
31
  };
25
32
  }
@@ -105,7 +105,7 @@ export function turnGroups(items, timing) {
105
105
  turn.merged.push(i);
106
106
  } else if (item?.role === "user") {
107
107
  turnIndex += 1;
108
- turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
108
+ turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
109
109
  groups.push(turn);
110
110
  } else if (item?.role === "assistant") {
111
111
  if (turn) turn.texts.push(i);
@@ -138,8 +138,17 @@ function withSteers(group, steerIndexes, items) {
138
138
  return { ...group, steps, steers: left };
139
139
  }
140
140
 
141
+ // A prompt's turn record: the one with its turn id (the engine saves it
142
+ // on the prompt), so a turn whose prompt left the conversation (a failed
143
+ // one's went back to the user, !rollback) shifts no other; a prompt saved
144
+ // before turn ids pairs by its place among the prompts.
145
+ function promptRecord(timing, g) {
146
+ if (typeof g.turnId !== "string" || !g.turnId) return turnRecordAt(timing, g.turnIndex);
147
+ return (timing?.turnRecords || []).find((r) => r.id === g.turnId) || null;
148
+ }
149
+
141
150
  function turnGroup(g, items, timing) {
142
- const record = turnRecordAt(timing, g.turnIndex);
151
+ const record = promptRecord(timing, g);
143
152
  const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
144
153
  const stepsOnly = canceledSteps(g, record, tools);
145
154
  if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
@@ -687,8 +687,11 @@ function reloadRowHtml(r, thumbs) {
687
687
  export function turnHistoryHtml(items, timing, { thumbs }) {
688
688
  const timingHtml = (group) => {
689
689
  const record = group.record;
690
+ // Numbered by its place in the turn records, as live (a failed turn
691
+ // whose prompt went back still counts).
692
+ const at = (timing?.turnRecords || []).indexOf(record);
690
693
  return record && formatDuration(record.duration_ms)
691
- ? `<div class="turn-timing">${escapeHtml(turnTimingText(group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
694
+ ? `<div class="turn-timing">${escapeHtml(turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
692
695
  : "";
693
696
  };
694
697
  // The timing line after the bubble, not in it: where the live view
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -301,6 +301,7 @@ files:
301
301
  - lib/samagotchi/plugin/sessions.rb
302
302
  - lib/samagotchi/plugin/side_question.rb
303
303
  - lib/samagotchi/plugin/tool_result.rb
304
+ - lib/samagotchi/plugin_session_state.rb
304
305
  - lib/samagotchi/plugin_tasks.rb
305
306
  - lib/samagotchi/project_scope.rb
306
307
  - lib/samagotchi/prompt.rb