samagotchi 0.139.0 → 0.139.1

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: 4acefa9a1809c64422fa70d4cc0603318d37ed5405ba73729b6f1c6e98c79c54
4
- data.tar.gz: 5f4f9b8181e0490272402c570f6bd1c70f72e6432fc380a383b6ff5d83a3e6fb
3
+ metadata.gz: 1ef79d43ec1d5fc441ca8892ceb240a42fca4629295e71debc495df44b5efde8
4
+ data.tar.gz: 39b1d809ff216fcd1a1af146dc7858b45e80f94a8018e00841839573b753be0d
5
5
  SHA512:
6
- metadata.gz: 0aaf6e6d8ee1cdfd0b3001beb44f884614cf45373a1cf8c0f566d7419407b01818ae3155bcfe368f485f657985fd5177aa897174567df9c0a7efcb8ba44b0504
7
- data.tar.gz: d53d479d947de0f63b78dfd5c67fecc41cf6cface32d6ffad6cb64a743fb9e0ba08dc5115746e6513d0bb7f11fac21b3c350215dbc9ba8544385b7606de19acf
6
+ metadata.gz: 228b7f233e3d6fd752ba0dbad22f4fed1e27910eb97a37190ec9172db94d1369dffe13f64389b3e1f77914bc845d2625c273c7569594fe76ffc4aa18edf749f6
7
+ data.tar.gz: f6439358124c6785a2ab54947e97928f725a817146bcd24e2263734e48bd2718b693e6573d4253fc64bbfe660a390125720f6a71662f31400bb7fbd39a4dd9da
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.139.1] - 2026-10-31
11
+
12
+ ### Fixed
13
+
14
+ - A project's mise.toml can no longer set `TMPDIR` for the session's tools: the guardrails judge `$TMPDIR` paths
15
+ by chi's own, so a write there could have been taken for a tmp write while it landed in the project.
16
+ - MCP servers (mcp 0.7.4) and context sources start like `execute`'s commands: without chi's own Bundler setup
17
+ (`RUBYOPT=-rbundler/setup`, `BUNDLE_GEMFILE`) or the project's `RUBYOPT`/`RUBYLIB`/Bundler variables, so a
18
+ chi run from a checkout no longer hands them its Gemfile.
19
+ - A worker that never starts its Bridge (`chi --attach`, the web's wake) now points at `chi home doctor`, which names
20
+ a worker that failed to boot the chi home's chi.
21
+ - The system bundle's self-knowledge names `execute`'s early hand-over to a task (a message for the turn), not only
22
+ its time limit.
23
+ - Web: a Revise stack's version chip names the version you are looking at ("v1 of 3"), and an earlier version you
24
+ switched to has no Copy link (it linked the newest turn with the earlier version's quote).
25
+ - Web: the bell is one control in each place: a toggle in a browser tab, the notifications sheet's opener in the
26
+ Home Screen app.
27
+ - Web: a markdown report in the stage's one-line rows is exactly a plain row's height.
28
+ - Web: on a phone, Revise, the stack switcher, Copy, the composer's Revise ✕, a fold's size and the activity
29
+ summaries no longer stay tinted after a tap.
30
+
31
+ Update with `chi update` (system, mcp 0.7.4).
32
+
10
33
  ## [0.139.0] - 2026-10-30
11
34
 
12
35
  ### Added
@@ -5303,7 +5326,8 @@ and long-lived sessions.
5303
5326
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5304
5327
  hotkey panel that send selected text or the clipboard to your sessions.
5305
5328
 
5306
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.139.0...HEAD
5329
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.139.1...HEAD
5330
+ [0.139.1]: https://github.com/dm1try/samagotchi/compare/v0.139.0...v0.139.1
5307
5331
  [0.139.0]: https://github.com/dm1try/samagotchi/compare/v0.138.0...v0.139.0
5308
5332
  [0.138.0]: https://github.com/dm1try/samagotchi/compare/v0.137.0...v0.138.0
5309
5333
  [0.137.0]: https://github.com/dm1try/samagotchi/compare/v0.136.1...v0.137.0
data/docs/cli.md CHANGED
@@ -1061,7 +1061,7 @@ moves into the later stub. The newest version has a chip, "v3 · 2 earlier ▾",
1061
1061
  listing every version by its prompt; pick one to jump to its stub. Before
1062
1062
  that chip, a "‹ 3/3 ›" switcher reads an earlier version's answer in the
1063
1063
  newest one's place (its left edge dashed while it shows; Copy copies the
1064
- version on view); › back to the newest. Switching only changes what this
1064
+ version on view, and the chip names it, "v1 of 3"); › back to the newest. Switching only changes what this
1065
1065
  page shows, and a reload shows the newest again. Revise
1066
1066
  again on the newest version adds to the stack. Stacks are session data (the
1067
1067
  stamp is on the Revise prompt, see [sessions.md](sessions.md), Revise
@@ -1402,7 +1402,9 @@ launches it. It is recorded in `~/.config/samagotchi/home.json` (`$XDG_CONFIG_HO
1402
1402
  `home_older_than_spawner`), but chi web's restart runs the newest chi it knows outside the home instead
1403
1403
  (`restart_home_older`): its flags may be ones the older chi doesn't know.
1404
1404
  - **A home that still fails at boot** (a broken install): the worker writes one `boot_failed` line to the debug log
1405
- (`log.file`) and exits, rather than leave the spawner waiting; `chi home doctor` reports the newest one.
1405
+ (`log.file`) and exits, rather than leave the spawner waiting; `chi home doctor` reports the newest one, and an
1406
+ attach whose worker never starts its Bridge says so ("… did not start its Bridge in time (chi home doctor shows a
1407
+ worker that failed to boot)").
1406
1408
  - Each machine has its own home (a remote's is that machine's).
1407
1409
 
1408
1410
  ### Checking it: chi home doctor
@@ -45,7 +45,7 @@ Behavior:
45
45
  - `execute` and `task_create` both spawn with chi's own Ruby/Bundler setup scrubbed (`TaskRuntime.sanitized_spawn_env`): `RUBYOPT` loses its `-r…bundler/setup` flags (others stay) and `RUBYLIB`, `BUNDLE_GEMFILE`, `BUNDLE_BIN_PATH`, `BUNDLER_VERSION` are unset, so a project's `ruby -e` or `bundle exec` uses its own Gemfile when chi runs under `bundle exec`. `task_create` refuses those keys in the model's `env`; `execute`'s `env` comes from chi's callers (`Builtins`, `!cmd`), not the model, and is set over the scrub. An `execute` handed over at its limit keeps the process it spawned, so the task runs with the same env.
46
46
  - `execute` and `task_create` export chi's own markers into the command, set over the model's `env` (which can't set them: `Error: env key is reserved`): `SAMAGOTCHI_PARENT_SESSION` (the session's id, `chi` without one; a chi started there answers as a parent agent), `SAMAGOTCHI_SESSION_MODEL` (the resolved ref the session runs on, read at each call so it follows `/model`; unset when unknown) `SAMAGOTCHI_SESSION_THINKING` (the session's own thinking level; unset when it has none) and `SAMAGOTCHI_SESSION_REASONING_HISTORY` (its own reasoning_history level, the same way). `chi self` reads the last three for its model, thinking level and reasoning history. The four are `ChildEnv::SESSION_MARKERS` (`TaskRuntime::MARKER_ENV_KEYS`), which a spawned worker unsets (`ChildEnv.unset`, merged into its spawn env). A session's worker never inherits them from whoever started it (a `chi send` run by another session's `execute`): its other commands (a user's `!cmd`, an MCP server, a context command) have none.
47
47
  - They also pass on a set `XDG_STATE_HOME` / `XDG_CONFIG_HOME` made absolute (`Builtins.xdg_absolute`, via `Paths`), so a chi the command runs in another `cwd` (`chi send`) reads this chi's state and config. These aren't reserved: an XDG dir the model sets in `task_create`'s `env` stays its own.
48
- - Every runner starts from the session's project env, `ProjectEnv.tools_env`: the overlay a spawned worker computed once at boot (`SessionManager.apply_project_env` → `ProjectEnv.compute` → `ProjectEnv.install!`; its folder's `mise env`, and the chi shim's dir first on PATH so `chi` is the chi home's), less `ProjectEnv::TOOLS_DROP` (`RUBYOPT`, `RUBYLIB`, Bundler's: the scrub above sets those). Outside a worker (`ProjectEnv::NONE`) it is `{}`. The sites: `execute` and `task_create` (`Tools::Builtins#project_env`, under the markers and the model's `env`; a participant's turn gets `{}`), the user's `!cmd` (`SessionCommands#shell`) and a context source's command (`ContextFetch#env_for`). MCP servers take `ProjectEnv.current.env` whole under the server's own keys (`bundles/mcp/plugin.rb` `server_env`: no `TOOLS_DROP`, no participant rule). chi's own process env is never changed, and the project env never enters the prompt. User docs: [Project env](../sessions.md#project-env); the launch side: [How chi launches itself](../architecture.md#how-chi-launches-itself).
48
+ - Every runner starts from the session's project env, `ProjectEnv.tools_env`: the overlay a spawned worker computed once at boot (`SessionManager.apply_project_env` → `ProjectEnv.compute` → `ProjectEnv.install!`; its folder's `mise env`, and the chi shim's dir first on PATH so `chi` is the chi home's), less `ProjectEnv::TOOLS_DROP` (`RUBYOPT`, `RUBYLIB`, Bundler's: the scrub above sets those). Outside a worker (`ProjectEnv::NONE`) it is `{}`. The sites: `execute` and `task_create` (`Tools::Builtins#project_env`, under the markers and the model's `env`; a participant's turn gets `{}`), the user's `!cmd` (`SessionCommands#shell`), and through `ProjectEnv.spawn_env` (the scrub above, then `tools_env`: what `execute` spawns with, for the runners that don't go through it) a context source's command (`ContextFetch#env_for`) and an MCP server, under the server's own keys (`bundles/mcp/plugin.rb` `server_env`, from mcp 0.7.4; no participant rule: the server is the session's). chi's own process env is never changed, and the project env never enters the prompt. User docs: [Project env](../sessions.md#project-env); the launch side: [How chi launches itself](../architecture.md#how-chi-launches-itself).
49
49
 
50
50
  ## Finish wake
51
51
 
@@ -50,7 +50,7 @@ Configuration (config.yml `execute.*` keys and CLI flags too, as for `read`):
50
50
  - `SAMAGOTCHI_EXECUTE_TIMEOUT_SEC` (`execute.timeout_sec`, default `120`): what a command still running then gets is `execute.on_timeout`.
51
51
  - `SAMAGOTCHI_EXECUTE_ON_TIMEOUT` (`execute.on_timeout`, default `task`): `task` hands the command over as a background task, not killed; the result starts `Still running at the 120s limit: moved to a background task, not killed.` (`Execute::HANDED_OVER`), then its `task_id:` and the tails of stdout and stderr so far, and `task_wait` waits for it. `kill` stops it and answers `Error: command timed out after …s` (`Execute::TIMEOUT_PREFIX`) with the hint to use `task_create`. The `!cmd` bang always kills. With `task`, a message from the user waiting for the turn hands a command over early, after 3 s, with the first line `Still running at the 4s mark: a message from the user is waiting, …` (same `Execute::HANDED_OVER` start; never under `kill`). See [Background tasks](background-tasks.md).
52
52
 
53
- The env a command runs in (the session's project env, `ProjectEnv.tools_env`, then the Ruby/Bundler scrub and chi's markers) is the same for `execute`, `task_create`, `!cmd` and context sources: see [Background tasks](background-tasks.md).
53
+ The env a command runs in (the session's project env, `ProjectEnv.tools_env`, then the Ruby/Bundler scrub and chi's markers) is the same for `execute`, `task_create`, `!cmd`, context sources and MCP servers (`ProjectEnv.spawn_env`): see [Background tasks](background-tasks.md).
54
54
 
55
55
  Implementation note:
56
56
 
data/docs/sessions.md CHANGED
@@ -386,7 +386,8 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
386
386
  belong to the starter's Ruby), unless mise sets its own; kept when the Ruby stays the same.
387
387
  - **Never what a `chi` in a tool reads.** The tools' env never takes `HOME`, any `XDG_*`, any `SAMAGOTCHI_*`,
388
388
  `CHI_REMOTE`, `RACK_ENV` or `CI` from the project (a `chi` run there would read another config or state dir, or
389
- send its session commands to another machine): they are dropped
389
+ send its session commands to another machine), nor `TMPDIR` (the guardrails judge a tool's `$TMPDIR/x` and its
390
+ tmp dirs by the worker's own `TMPDIR`, so the tools run on that one): they are dropped
390
391
  and the debug log names them once (`project_env_reserved`). `mise env` runs without `MISE_YES` (it would trust an
391
392
  untrusted mise.toml for you) and `CI`; your `MISE_TRUSTED_CONFIG_PATHS` stays.
392
393
  - **Only this session.** A worker started from this one (a restart, a delegate child) starts on the worker's own
@@ -403,7 +404,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
403
404
  included, asks you first; a read-only mention (`find . -name mise.toml`) doesn't. `.envrc` and mise's local files
404
405
  (`mise.local.toml`, `mise.<env>.local.toml`, dotted or not, and `config.local.toml` / `config.<env>.local.toml` in
405
406
  `.config/mise/`, `mise/`, `.mise/`) are secrets to them, as `.env` is.
406
- - **What tools still drop**: every `execute`, task and `!cmd` takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
407
+ - **What tools still drop**: every `execute`, task, `!cmd`, context source and MCP server takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
407
408
  `BUNDLE_BIN_PATH` or `BUNDLER_VERSION` from the project's mise env: it runs without `RUBYLIB` and Bundler's
408
409
  variables, and with the worker's own `RUBYOPT`, Bundler's part removed (`TaskRuntime`).
409
410
  - **`chi` in a tool** stays the home's: with a chi home the tools' PATH starts with `$XDG_STATE_HOME/samagotchi/bin`,
@@ -447,17 +447,17 @@ module Samagotchi
447
447
  @stdout.puts "Bundle '#{name}' not installed."
448
448
  return 0
449
449
  end
450
- @stdout.puts "Bundle: #{name} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
451
- if st[:scope_error]
450
+ @stdout.puts "Bundle: #{name} v#{st.provenance.version}#{dev_label(st.provenance)} scope=#{status_scope(st)} installed=#{st.provenance.installed_at}"
451
+ if st.scope_error
452
452
  @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
453
453
  else
454
- @stdout.puts "Target: #{st[:target_dir]}"
454
+ @stdout.puts "Target: #{st.target_dir}"
455
455
  end
456
- st[:files].each do |k, info|
456
+ st.files.each do |k, info|
457
457
  next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
458
458
 
459
459
  mods = []
460
- mods << "conflict (kept your edits over v#{st[:provenance].version}: chi bundle diff #{name} #{k})" if info[:conflict]
460
+ mods << "conflict (kept your edits over v#{st.provenance.version}: chi bundle diff #{name} #{k})" if info[:conflict]
461
461
  mods << "modified" if info[:modified]
462
462
  mods << "missing" if info[:missing]
463
463
  mods << "no-index" unless info[:index_present] || info[:overlay]
@@ -466,9 +466,9 @@ module Samagotchi
466
466
  @stdout.puts " #{k}: #{label}"
467
467
  end
468
468
  # Hooks
469
- hooks = st[:provenance].hooks
470
- trust = st[:provenance].trust_level || "experimental"
471
- commit = st[:provenance].source_commit
469
+ hooks = st.provenance.hooks
470
+ trust = st.provenance.trust_level || "experimental"
471
+ commit = st.provenance.source_commit
472
472
  @stdout.puts " trust_level: #{trust}" if hooks.any? || trust
473
473
  @stdout.puts " source_commit: #{commit}" if commit
474
474
  if hooks.any?
@@ -478,17 +478,17 @@ module Samagotchi
478
478
  exists = File.exist?(hook_path) ? "ok" : "missing"
479
479
  @stdout.puts " #{hk}: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
480
480
  end
481
- @stdout.puts " requires_chi: #{st[:provenance].requires_chi}" if st[:hooks_requires_failure]
482
- @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
481
+ @stdout.puts " requires_chi: #{st.provenance.requires_chi}" if st.hooks_requires_failure
482
+ @stdout.puts " not loaded: #{st.hooks_requires_failure}" if st.hooks_requires_failure
483
483
  end
484
- if (plugin = st[:plugin])
484
+ if (plugin = st.plugin)
485
485
  note = plugin[:state] == "modified" ? " (edited after install: not loaded; reinstall the bundle)" : ""
486
486
  @stdout.puts " Plugin: #{plugin[:file]} [#{plugin[:state]}]#{note}"
487
487
  @stdout.puts " requires_chi: #{plugin[:requires_chi]}" if plugin[:requires_chi]
488
488
  @stdout.puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
489
489
  end
490
- st[:needs].each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
491
- (st[:provenance].includes || []).each do |member|
490
+ st.needs.each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
491
+ (st.provenance.includes || []).each do |member|
492
492
  state = Samagotchi::MemoryBundle::Provenance.new(name: member).installed? ? "installed" : "not installed: chi bundle install #{member}"
493
493
  @stdout.puts " includes #{member} [#{state}]"
494
494
  end
@@ -499,17 +499,17 @@ module Samagotchi
499
499
  next @stdout.puts(" #{bname} (manifest.json unreadable)") if record.error?
500
500
 
501
501
  st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
502
- mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
503
- hooks_count = st[:provenance].hooks.size
502
+ mods = st.files.values.count { |v| v[:modified] || v[:missing] }
503
+ hooks_count = st.provenance.hooks.size
504
504
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
505
- plugin = st[:plugin]
505
+ plugin = st.plugin
506
506
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
507
- mods += 1 if st[:hooks_requires_failure]
508
- mods += 1 if st[:scope_error]
507
+ mods += 1 if st.hooks_requires_failure
508
+ mods += 1 if st.scope_error
509
509
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
510
- includes = st[:provenance].includes
510
+ includes = st.provenance.includes
511
511
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
512
- @stdout.puts " #{bname} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
512
+ @stdout.puts " #{bname} v#{st.provenance.version}#{dev_label(st.provenance)} scope=#{status_scope(st)} files=#{st.files.size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
513
513
  end
514
514
  end
515
515
  0
@@ -523,7 +523,7 @@ module Samagotchi
523
523
  end
524
524
 
525
525
  # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
526
- def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
526
+ def status_scope(st) = st.scope_error ? "#{st.scope} (unknown)" : st.scope
527
527
 
528
528
  def diff(rest)
529
529
  args = rest.reject { |a| a.start_with?("--") }
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.7.3
3
+ version: 0.7.4
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:65532b75b33fa7a58b4a35f9ed32e2705f4e3d323c6f770e71448da5cc9314d0
10
+ sha256: sha256:b892de35b1fc16dd22152cd95a6c90447a2be45f4daff04a4648ab2653463a20
11
11
  # 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
12
12
  requires_chi: ">= 0.121.0"
@@ -454,10 +454,15 @@ class Plugin
454
454
  client
455
455
  end
456
456
 
457
- # A server's env: the session's project env (the worker's mise env, on a
458
- # chi that has it: Samagotchi::ProjectEnv) under the server's own keys.
457
+ # A server's env: the session's project env as execute's commands get it
458
+ # (the worker's mise env without chi's own Ruby and Bundler setup, on a
459
+ # chi that has it: Samagotchi::ProjectEnv.spawn_env) under the server's
460
+ # own keys. An older chi has only the overlay, or none.
459
461
  def server_env(server)
460
- project = defined?(Samagotchi::ProjectEnv) ? Samagotchi::ProjectEnv.current.env : {}
462
+ project = if !defined?(Samagotchi::ProjectEnv) then {}
463
+ elsif Samagotchi::ProjectEnv.respond_to?(:spawn_env) then Samagotchi::ProjectEnv.spawn_env
464
+ else Samagotchi::ProjectEnv.current.env
465
+ end
461
466
  project.merge(server.env)
462
467
  end
463
468
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.139.0
3
+ version: 0.139.1
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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:df3f8da539a9f5812f79e089c2c8b9894da6124a89654f292160046a6bb060da
8
+ self_map.md: sha256:3253cb9ae7f09821d8fe1fed5154dd2b5e33fe0e9f096eee2af981d7d7d66f16
9
9
  config_modification_protocol.md: sha256:ff4efccf2bdde453fc4e6741df8c18f78da78e353f6b1a5a64d0e7a631aea258
10
10
  memory_guide.md: sha256:3b9ff779ee962f1307d56937c826d5e581923114f3f39455b779ca618b363cd7
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -31,8 +31,8 @@
31
31
  asking for changes, checks failing, the PR closed), chi may start a turn for it with no user
32
32
  message ("context <name> changed"): I tell my user what changed and act on nothing.
33
33
  - `[TASK FINISHED from task <id8>, …]` is a background task I started (`task_create`, an `execute`
34
- handed over at its limit) that finished while I wasn't waiting on it: its exit code, duration
35
- and log tail. When it opens a turn with no user message ("task <id8> finished"), I carry on with
34
+ handed over at its limit, or early when a message came for my turn) that finished while I wasn't
35
+ waiting on it: its exit code, duration and log tail. When it opens a turn with no user message ("task <id8> finished"), I carry on with
36
36
  what I planned for its result, or tell my user in a sentence or two how it went. Without the
37
37
  "chi started this turn" line it is only information for my next answer.
38
38
  - `forget_outputs` (only when my model's `llm_context.strategy` has the experimental forget layer)
@@ -20,8 +20,8 @@ module Samagotchi
20
20
  @generation = nil
21
21
  end
22
22
 
23
- # @param detail [Object, nil] what the canceller says about it (a cut
24
- # generation's {by:, reason:})
23
+ # @param detail [Object, nil] what the canceller says about it (the Cut
24
+ # of a cut generation, or a turn stopped by a hook)
25
25
  def cancel!(reason = :manual, detail = nil)
26
26
  listeners = []
27
27
  @mutex.synchronize do
@@ -54,11 +54,11 @@ module Samagotchi
54
54
  @mutex.synchronize { @detail }
55
55
  end
56
56
 
57
- # Who stopped it when a hook did: the detail's +by+ (the bundle, or the
57
+ # Who stopped it when a hook did: the Cut's +by+ (the bundle, or the
58
58
  # hook's label); nil for any other cancel.
59
59
  def stopped_by
60
60
  reason, detail = @mutex.synchronize { [@reason, @detail] }
61
- by = detail[:by].to_s if reason == :hook && detail.is_a?(Hash)
61
+ by = detail.by.to_s if reason == :hook && detail.respond_to?(:by)
62
62
  by unless by.nil? || by.empty?
63
63
  end
64
64
 
@@ -109,8 +109,8 @@ module Samagotchi
109
109
 
110
110
  def env_for(location, name)
111
111
  previous = location.snapshot_path(name)
112
- # The session's project env (ProjectEnv) under the source's own keys.
113
- ProjectEnv.tools_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
112
+ # The session's project env (ProjectEnv.spawn_env) under the source's own keys.
113
+ ProjectEnv.spawn_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
114
114
  "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil)
115
115
  end
116
116
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Why a generation was cut: what CancellationController#cancel_generation!
5
+ # is given (a bundle's stop_generation, or Engine#cut_for_steer) and what
6
+ # the loops keep on a cut generation. +by+ is who stopped it (the hook's
7
+ # label, or "steer"), +reason+ what it said, +steer+ true when a queued user
8
+ # message cut it (no nudge, no retry attempt spent), +source+ that message,
9
+ # for the steer_cut row. The same value rides a whole-turn stop
10
+ # (stop_turn): the cancel note reads its +by+ and +reason+.
11
+ Cut = Data.define(:by, :reason, :steer, :source) do
12
+ def initialize(by: nil, reason: nil, steer: false, source: nil) = super
13
+
14
+ # A hook's cut (stop_generation, or stop_turn): the bundle +by+ and its
15
+ # +reason+.
16
+ def self.hook(by: nil, reason: nil) = new(by: by, reason: reason)
17
+
18
+ # Engine#cut_for_steer: a queued message cut the streaming generation.
19
+ def self.steer(source) = new(by: "steer", reason: "a new message", steer: true, source: source)
20
+ end
21
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cut"
3
4
  require_relative "turn_note"
4
5
 
5
6
  module Samagotchi
@@ -30,7 +31,7 @@ module Samagotchi
30
31
 
31
32
  module_function
32
33
 
33
- # +cut+: the cut's detail (the stop_generation payload). +emit+ takes an
34
+ # +cut+: the Cut (what stop_generation passed). +emit+ takes an
34
35
  # event Hash; +inject+ puts queued input in (true when there was any).
35
36
  # +finish_reason+ and +thinking_chars+: the cut generation's, for the
36
37
  # retry's row.
@@ -40,13 +41,13 @@ module Samagotchi
40
41
  return STOPPED if cancel_controller.cancelled?
41
42
 
42
43
  # The row under the cut step, above the message it was cut for.
43
- emit.call({ type: :steer_cut, iteration: iteration, source: cut[:source].to_s }) if cut[:steer]
44
- return AGAIN if inject.call || cut[:steer]
44
+ emit.call({ type: :steer_cut, iteration: iteration, source: cut.source.to_s }) if cut.steer
45
+ return AGAIN if inject.call || cut.steer
45
46
 
46
47
  if empty_retry.left?
47
- empty_retry.nudge!(conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
48
+ empty_retry.nudge!(conversation, TurnNote.cut_retry(cut.by, cut.reason),
48
49
  emit: emit, iteration: iteration, finish_reason: finish_reason,
49
- thinking_chars: thinking_chars, stopped_by: cut[:by])
50
+ thinking_chars: thinking_chars, stopped_by: cut.by)
50
51
  return AGAIN
51
52
  end
52
53
  empty_retry.drop_nudge!(conversation)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cut"
3
4
  require_relative "event_routes"
4
5
  require_relative "hooks"
5
6
  require_relative "log"
@@ -104,7 +105,7 @@ module Samagotchi
104
105
  return false unless ctrl && !ctrl.cancelled?
105
106
 
106
107
  notify("stopped the turn: #{reason}", :warn, hook)
107
- ctrl.cancel!(:hook, { by: source(hook), reason: reason.to_s })
108
+ ctrl.cancel!(:hook, Cut.hook(by: source(hook), reason: reason.to_s))
108
109
  end
109
110
 
110
111
  # Cut the streaming generation (reason :hook); the turn goes on and asks
@@ -115,7 +116,7 @@ module Samagotchi
115
116
  ctrl = @cancel_controller.call
116
117
  return false unless ctrl
117
118
 
118
- ctrl.cancel_generation!(:hook, { by: source(hook), reason: reason.to_s })
119
+ ctrl.cancel_generation!(:hook, Cut.hook(by: source(hook), reason: reason.to_s))
119
120
  end
120
121
 
121
122
  private
@@ -13,6 +13,7 @@ require_relative "client"
13
13
  require_relative "llm/errors"
14
14
  require_relative "log"
15
15
  require_relative "empty_answer_retry"
16
+ require_relative "cut"
16
17
  require_relative "cut_policy"
17
18
  require_relative "thinking"
18
19
  require_relative "hooks"
@@ -327,8 +328,9 @@ module Samagotchi
327
328
  # One request: the prompt and its images as sent, the images' token
328
329
  # estimate, and the window it was measured against.
329
330
  Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
330
- # One generation: the response (nil when cut), the cut's detail, this
331
- # generation's own server counts, the model the server named, the
331
+ # One generation: the response (nil when cut), the Cut that stopped it
332
+ # (an empty one when nothing said why), this generation's own server
333
+ # counts, the model the server named, the
332
334
  # thinking it streamed, and why it stopped (llama.cpp's finish
333
335
  # reason, Client.finish_reason_from; nil when not named),
334
336
  # and its prompt-cache counts (the largest seen: they come cumulative),
@@ -472,7 +474,7 @@ module Samagotchi
472
474
  rescue Client::RequestCancelled
473
475
  raise unless cut?(generation_controller, turn.cancel_controller)
474
476
 
475
- generation.cut = generation_controller.detail || {}
477
+ generation.cut = generation_controller.detail || Cut.new
476
478
  nil
477
479
  end
478
480
  generation.cut ? cut_generation(turn, generation, buffer_mark) : completed_generation(turn, request, generation)
@@ -634,8 +636,8 @@ module Samagotchi
634
636
  turn.buffer.slice!(buffer_mark..)
635
637
  emit(turn, type: :generation_completed, iteration: turn.iteration, content_length: 0,
636
638
  thinking_chars: generation.streamed_thinking, served_model: generation.served_model,
637
- requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut[:by],
638
- stop_reason: generation.cut[:reason])
639
+ requested_model: turn.model_name, finish_reason: "stopped", stopped_by: generation.cut.by,
640
+ stop_reason: generation.cut.reason)
639
641
  end
640
642
 
641
643
  def completed_generation(turn, request, generation)
@@ -668,7 +670,7 @@ module Samagotchi
668
670
  raise Client::RequestCancelled, turn.cancel_controller.reason if outcome.stopped?
669
671
  return :next if outcome.again?
670
672
 
671
- emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut[:by])
673
+ emit(turn, type: :generation_cancelled, iteration: turn.iteration, reason: :hook, stopped_by: cut.by)
672
674
  cancelled_result(turn.conversation, tool_activity: turn.tool_activity, reason: :hook, partial_assistant_text: "")
673
675
  end
674
676
 
@@ -17,8 +17,11 @@ module Samagotchi
17
17
  # into a kept one.
18
18
  SCRATCH_LEFTOVER = "that's a leftover scratch session; it is deleted at the next sweep (chi sessions clean)"
19
19
  # A worker spawned or resumed for an attach whose Bridge never came (%s:
20
- # the session id).
21
- BRIDGE_LATE = "the worker for session %s did not start its Bridge in time"
20
+ # the session id). A worker whose chi home couldn't load chi logs
21
+ # SessionManager::HOME_BOOT_FAILED and exits: HomeDoctor's boot row says
22
+ # what and what to run, so the text names chi home doctor.
23
+ BRIDGE_LATE = "the worker for session %s did not start its Bridge in time " \
24
+ "(chi home doctor shows a worker that failed to boot)"
22
25
  # An attach (or a resume) that found the session's worker leaving, its
23
26
  # Bridge closed and its recap on leave still being written: it waits for
24
27
  # the worker to let go (OwnerLock.await_release), then starts a new one.
@@ -14,6 +14,7 @@ require_relative "stream_drops"
14
14
  require_relative "../kernel_loop"
15
15
  require_relative "../answer_display"
16
16
  require_relative "../answer_refs"
17
+ require_relative "../cut"
17
18
  require_relative "../cut_policy"
18
19
  require_relative "../context_status"
19
20
  require_relative "../context_window"
@@ -763,7 +764,7 @@ module Samagotchi
763
764
  rescue RequestCancelled
764
765
  raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
765
766
 
766
- return cut_response(iteration, thought, generation_controller.detail || {})
767
+ return cut_response(iteration, thought, generation_controller.detail || Cut.new)
767
768
  end
768
769
  dropped = settle_thinking_drop(iteration, response.thinking_drop)
769
770
  record_context_status(response.usage, window)
@@ -820,7 +821,7 @@ module Samagotchi
820
821
  # stream never sent its last chunk).
821
822
  def cut_response(iteration, thought, cut)
822
823
  emit(type: :generation_completed, iteration: iteration, content_length: 0, thinking_chars: thought.length,
823
- requested_model: @model_name, finish_reason: "stopped", stopped_by: cut[:by], stop_reason: cut[:reason])
824
+ requested_model: @model_name, finish_reason: "stopped", stopped_by: cut.by, stop_reason: cut.reason)
824
825
  response = ChatResponse.new(text: "", reasoning: thought, tool_calls: [], usage: nil, finish_reason: "stopped",
825
826
  cut: cut)
826
827
  [response, nil]
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # +provider+ is the provider that served it (the body's `provider`,
27
27
  # OpenRouter's routing name); nil when the server says nothing.
28
28
  # +cut+ is set on the empty response the chat loop makes for a
29
- # generation a plugin cut (stop_generation): {by:, reason:}.
29
+ # generation a plugin cut (stop_generation): the Cut.
30
30
  # +top_logprobs+: the first answer token's likeliest alternatives
31
31
  # (TokenLogprob) when the request asked for logprobs and the server
32
32
  # sent them (a non-streamed answer only); [] otherwise.
@@ -12,6 +12,16 @@ require_relative "../model_overlay"
12
12
  module Samagotchi
13
13
  module MemoryBundle
14
14
  module Status
15
+ # A bundle as this chi reads it now (what chi bundle status prints):
16
+ # its stored record, the scope it resolves to (scope_error when it
17
+ # can't), the folder its files belong in, what each file looks like on
18
+ # disk, its installed plugin, why none of its hooks load, and its needs
19
+ # with what this process's PATH found. The +files+ entries and +plugin+
20
+ # stay Hashes: their fields differ per case (an unchecked file has no
21
+ # paths at all).
22
+ Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
23
+ :hooks_requires_failure, :needs)
24
+
15
25
  # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
16
26
  # guessed: scope_error says so, target_dir is nil and each file is
17
27
  # unchecked (no disk reads, nothing counted as missing).
@@ -50,9 +60,9 @@ module Samagotchi
50
60
  base_path: base_path
51
61
  }
52
62
  end
53
- { provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
54
- target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
55
- hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle) }
63
+ Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
64
+ target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
65
+ hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle))
56
66
  end
57
67
 
58
68
  # Why none of the bundle's hooks load (this chi doesn't meet its
@@ -50,8 +50,10 @@ module Samagotchi
50
50
  # Keys the tools' overlay never sets: a `chi` a tool runs (the shim's)
51
51
  # reads its config, state and log dirs, its settings and the machine its
52
52
  # session commands go to (CHI_REMOTE) from them, and the test-run flags.
53
- # Dropped, with one warning.
54
- RESERVED_KEYS = %w[HOME RACK_ENV CI CHI_REMOTE].freeze
53
+ # And TMPDIR: the guardrails judge a tool's `$TMPDIR/x` and its tmp dirs
54
+ # (Guardrails::PathResolve.tmp_roots) by the worker's own TMPDIR, so the
55
+ # tools must run on that one too. Dropped, with one warning.
56
+ RESERVED_KEYS = %w[HOME TMPDIR RACK_ENV CI CHI_REMOTE].freeze
55
57
  RESERVED_PREFIXES = %w[XDG_ SAMAGOTCHI_].freeze
56
58
  # Never set from an ORIG_ENV record (a tool can write it), only unset:
57
59
  # loader and Bundler variables.
@@ -112,6 +114,18 @@ module Samagotchi
112
114
  # @return [Hash{String=>String, nil}]
113
115
  def tools_env = current.env.except(*TOOLS_DROP)
114
116
 
117
+ # The whole env change for a command chi starts for the session outside
118
+ # execute (MCP servers, context sources): what execute spawns over
119
+ # (Tools::TaskRuntime.sanitized_spawn_env: chi's own RUBYOPT without
120
+ # bundler/setup, no RUBYLIB or Bundler variables), then #tools_env. One
121
+ # env for every command, so a worker run from a checkout under `bundle
122
+ # exec` hands none of them its Gemfile.
123
+ # @return [Hash{String=>String, nil}]
124
+ def spawn_env
125
+ require_relative "tools/task_runtime" # lazily: bin/chi loads this file for #take_orig!
126
+ Tools::TaskRuntime.sanitized_spawn_env.merge(tools_env)
127
+ end
128
+
115
129
  # Specs: back to NONE.
116
130
  def reset! = @current = NONE
117
131
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "cut"
4
5
  require_relative "log"
5
6
  require_relative "steer"
6
7
  require_relative "generation_phase"
@@ -131,7 +132,7 @@ module Samagotchi
131
132
 
132
133
  def cut!(ctrl, source, waited: false)
133
134
  age = @phase.age
134
- cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
135
+ cut = ctrl.cancel_generation!(:steer, Cut.steer(source))
135
136
  if cut
136
137
  @waiting.clear!
137
138
  fields = { source: source, age: age&.round(1) }
@@ -103,7 +103,9 @@ module Samagotchi
103
103
  "Samagotchi::TokenUsage::CHARS_PER_TOKEN" => "estimates a tool's tokens: could be a stable constant",
104
104
  'require "samagotchi/tool_declarations"' => "loaded for Api.tool_spec's schemas",
105
105
  "Samagotchi::ProjectEnv" => "checks the worker has a project env (an older chi doesn't)",
106
- "Samagotchi::ProjectEnv.current" => "starts a server in the session's project env: needs a ctx tools-env API"
106
+ "Samagotchi::ProjectEnv.current" => "starts a server in the session's project env: needs a ctx tools-env API",
107
+ "Samagotchi::ProjectEnv.respond_to?" => "checks the worker has ProjectEnv.spawn_env (an older chi doesn't)",
108
+ "Samagotchi::ProjectEnv.spawn_env" => "starts a server in the session's project env: needs a ctx tools-env API"
107
109
  },
108
110
  "slow-commands" => {
109
111
  'require "samagotchi/guardrails/shell_lex"' => "for ShellLex",
@@ -98,8 +98,8 @@ module Samagotchi
98
98
  # before a tool call, so the note says "reply"
99
99
  # @param running_tasks [Array<Hash>] {id:, command:} of this session's
100
100
  # background tasks still running: a Stop doesn't end them
101
- # @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
102
- # it (stop_turn, or a cut with no retry left)
101
+ # @param stopped_by [Cut, nil] the Cut of a hook that stopped it
102
+ # (stop_turn, or a cut with no retry left)
103
103
  def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
104
104
  why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
105
105
  after = seconds ? " after #{seconds.round}s" : ""
@@ -107,12 +107,12 @@ module Samagotchi
107
107
  message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
108
108
  end
109
109
 
110
- # " <by>: <reason>" of a hook that stopped the turn, as far as known.
110
+ # " <by>: <reason>" of the hook whose Cut stopped the turn, as far as known.
111
111
  def stopper(stopped_by)
112
- return "" unless stopped_by.is_a?(Hash)
112
+ return "" if stopped_by.nil?
113
113
 
114
- by = one_line(stopped_by[:by])
115
- reason = one_line(stopped_by[:reason])
114
+ by = one_line(stopped_by.by)
115
+ reason = one_line(stopped_by.reason)
116
116
  [by.empty? ? nil : " #{by}", reason.empty? ? nil : ": #{reason}"].compact.join
117
117
  end
118
118
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.139.0"
4
+ VERSION = "0.139.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -822,7 +822,9 @@ function claimOpts() {
822
822
  // active (it opens in the browser their review link was opened in: one
823
823
  // link per browser, the cookie), else this page's own.
824
824
  function claimLinkFor(source, range) {
825
- if (!store.selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
825
+ // Not in a Revise stack's switched copy (revise_switch.js: view only, no claim key; Verify isn't offered there either).
826
+ if (!store.selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)
827
+ || source.root.closest("[data-stack-view]")) return null;
826
828
  const turnId = turnIdOf(historyEl, source.root);
827
829
  if (!turnId) return null;
828
830
  const claim = { turnId, ...selectionClaim(source.root, range, claimOpts()) };
@@ -31,7 +31,7 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
31
31
  .revise-chip.refused{border-color:var(--warn-border);background:var(--warn-bg);color:var(--warn-text)}
32
32
  .revise-chip-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
33
33
  .revise-chip-clear{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;min-height:0;padding:0;border:0;border-radius:999px;background:transparent;color:var(--text-muted);font-size:11px;cursor:pointer}
34
- .revise-chip-clear:hover{background:var(--bg-raised);color:var(--text-bright);filter:none}
34
+ .revise-chip-clear:hover{filter:none}@media(hover:hover){.revise-chip-clear:hover{background:var(--bg-raised);color:var(--text-bright)}}
35
35
  .chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);border:1px solid var(--border-strong);border-radius:8px;padding:3px 4px 3px 3px;font-size:12px;max-width:220px}
36
36
  .chip img{width:32px;height:32px;object-fit:cover;border-radius:5px}
37
37
  .chip .chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -61,7 +61,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
61
61
  /* Folded: the bubble hidden, an answer or a delegate report (#history's: outranks .bubble.user's display). */
62
62
  #history>.bubble.folded{display:none}
63
63
  .answer-fold{display:flex;align-items:baseline;gap:0;align-self:stretch;max-width:100%;min-width:0;margin:0;padding:12px 14px;font:inherit;font-size:13px;font-weight:600;line-height:1.5;color:var(--text-muted);text-align:left;cursor:pointer;user-select:none;background:var(--bg-panel);border:1px solid var(--border);border-left:3px solid var(--accent-strong);border-radius:12px}
64
- .answer-fold:hover .answer-fold-size{color:var(--text-2)}
64
+ @media(hover:hover){.answer-fold:hover .answer-fold-size{color:var(--text-2)}}
65
65
  .answer-fold:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
66
66
  .answer-fold::before{content:"▾\00a0";color:var(--accent);flex:none}
67
67
  .answer-fold.folded::before{content:"▸\00a0"}
@@ -92,7 +92,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
92
92
  .revise-row.stacked{height:auto;margin-top:-6px;justify-content:flex-start}
93
93
  .revise-btn{min-height:0;height:22px;padding:0 8px;border-radius:6px;font-size:11px;line-height:20px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);cursor:pointer;opacity:0;transition:opacity .12s ease,color .12s ease}
94
94
  .revise-row:not(.stacked) .revise-btn{position:absolute;right:14px;top:-10px;z-index:1;height:20px;line-height:18px}
95
- .revise-btn:hover:not(:disabled){color:var(--text-bright);filter:none}
95
+ .revise-btn:hover:not(:disabled){filter:none}@media(hover:hover){.revise-btn:hover:not(:disabled){color:var(--text-bright)}}
96
96
  .revise-btn:disabled{cursor:default}
97
97
  .revise-btn.armed,.revise-btn.armed:hover:not(:disabled){opacity:1;color:var(--on-solid);background:var(--accent-strong);border-color:var(--accent-strong)}
98
98
  .bubble.output:hover+.revise-row .revise-btn,.revise-row:hover .revise-btn,.revise-row.stacked .revise-btn,.revise-btn:focus-visible{opacity:1}
@@ -110,7 +110,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
110
110
  /* The ‹ N/M › switcher (revise_switch.js): an earlier version's answer read in the top one's place; its copy dashed, the top hidden. */
111
111
  .stack-switch{display:inline-flex;align-items:center;gap:2px;font-size:12px;color:var(--text-2);font-variant-numeric:tabular-nums}
112
112
  .stack-switch-btn{min-height:0;height:22px;min-width:24px;padding:0 6px;border-radius:6px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2);font:inherit;font-size:14px;line-height:20px;cursor:pointer}
113
- .stack-switch-btn:hover:not([aria-disabled="true"]){color:var(--text-bright);filter:none}
113
+ .stack-switch-btn:hover:not([aria-disabled="true"]){filter:none}@media(hover:hover){.stack-switch-btn:hover:not([aria-disabled="true"]){color:var(--text-bright)}}
114
114
  .stack-switch-btn:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
115
115
  .stack-switch-btn[aria-disabled="true"]{opacity:.4;cursor:default}
116
116
  .stack-switch-n{min-width:2.6em;text-align:center}
@@ -277,12 +277,12 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
277
277
  /* A reloaded row's ✂ mark (an LLM context edit on its output), one line above the output. */
278
278
  .activity-edit{flex:1 1 100%;min-width:0;margin:2px 0 0 var(--row-gutter);font-size:12px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:help}
279
279
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
280
- .activity-diff>summary:hover{color:var(--text-2)}
280
+ @media(hover:hover){.activity-diff>summary:hover{color:var(--text-2)}}
281
281
  .activity-diff[open]>summary{margin-bottom:4px}
282
282
  /* A read of the file as-is: its numbered lines, closed under the preview (read_view.js); open, they stand for it. */
283
283
  .activity-read{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
284
284
  .activity-read>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
285
- .activity-read>summary:hover{color:var(--text-2)}
285
+ @media(hover:hover){.activity-read>summary:hover{color:var(--text-2)}}
286
286
  .activity-read[open]>summary{margin-bottom:4px}
287
287
  .activity-row:has(>.activity-read[open])>.activity-output{display:none}
288
288
  .read-lines{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
@@ -305,7 +305,7 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
305
305
  .has-steps .activity-command-cwd{flex:1 1 auto;min-width:0;margin:0;overflow-wrap:anywhere}
306
306
  .activity-command-cwd>.activity-command-sep{font-family:inherit;color:var(--text-faint)}
307
307
  .activity-command-raw{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 7px;border:1px solid var(--border-strong);border-radius:9px;font-size:11px;color:var(--text-muted);cursor:pointer;user-select:none;-webkit-user-select:none}
308
- .activity-command-raw:hover{color:var(--text-2)}
308
+ @media(hover:hover){.activity-command-raw:hover{color:var(--text-2)}}
309
309
  .activity-command-raw input{position:absolute;opacity:0;width:0;height:0;margin:0}
310
310
  .activity-command-raw:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:1px}
311
311
  .activity-command-raw:has(input:checked){color:var(--accent);border-color:var(--accent-border);background:var(--accent-tint)}
@@ -618,7 +618,7 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
618
618
  .bubble.output,.bubble.user{position:relative}
619
619
  .copy-btn{position:absolute;top:6px;right:6px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);font-size:11px;line-height:1;white-space:nowrap;user-select:none;-webkit-user-select:none;opacity:0;transition:opacity .12s ease,color .12s ease}
620
620
  .copy-btn svg{display:block}
621
- .copy-btn:hover:not(:disabled){filter:none;color:var(--text-bright)}
621
+ .copy-btn:hover:not(:disabled){filter:none}@media(hover:hover){.copy-btn:hover:not(:disabled){color:var(--text-bright)}}
622
622
  .bubble:hover>.copy-btn,.code-wrap:hover>.copy-btn,.copy-btn:focus-visible,.copy-btn[data-copied]{opacity:1}
623
623
  .copy-btn[data-copied],.copy-btn[data-copied]:hover:not(:disabled){color:var(--ok-text)}
624
624
  .copy-btn[data-copied] svg{display:none}
@@ -847,8 +847,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
847
847
  .ts-extras>.bubble.user>.copy-btn{display:none}
848
848
  .ts-extras>.bubble.user .user-message{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;white-space:normal}
849
849
  /* A line's markdown (a delegate report's) reads as a plain row in the clamped rows: its blocks run inline at the
850
- row's own size, a space between them, rules and copy buttons gone. The prompt line shown whole keeps it. */
851
- :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(p,h1,h2,h3,h4,h5,h6,ul,ol,li,blockquote,pre,table,thead,tbody,tr,th,td,.code-wrap){display:inline;margin:0;padding:0;border:0;border-radius:0;background:transparent;font-size:inherit;font-weight:inherit;line-height:inherit;color:inherit;white-space:normal}
850
+ row's own size, font and baseline (a pre's monospace or a cell's middle alignment would make the line box taller), a
851
+ space between them, rules and copy buttons gone. The prompt line shown whole keeps it. */
852
+ :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(p,h1,h2,h3,h4,h5,h6,ul,ol,li,blockquote,pre,table,thead,tbody,tr,th,td,.code-wrap){display:inline;margin:0;padding:0;border:0;border-radius:0;background:transparent;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;color:inherit;white-space:normal;vertical-align:baseline}
852
853
  :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(p,h1,h2,h3,h4,h5,h6,li,blockquote,pre,td,th)::after{content:" "}
853
854
  :is(.ts-extras>.bubble.user,.ts-prompt:not(.full)>.bubble.user)>.user-message.markdown :is(hr,img,.copy-btn){display:none}
854
855
  /* Inside the dock's own blur a second backdrop-filter sees nothing: the cards are opaque here (as #commandList is). */
@@ -200,7 +200,16 @@ export function createNotifications({
200
200
  button.hidden = state === "unsupported";
201
201
  button.dataset.state = state;
202
202
  button.title = (push ? PUSH_TITLES : NOTIFY_TITLES)[state] || "";
203
- button.setAttribute("aria-pressed", state === "on" ? "true" : "false");
203
+ // In a tab the bell is a toggle (aria-pressed); in the app it opens the
204
+ // notifications sheet, whose shell (chip_popover.js) keeps its
205
+ // aria-expanded, as every popover opener's: one state, never both.
206
+ if (push) {
207
+ button.removeAttribute("aria-pressed");
208
+ button.setAttribute("aria-haspopup", "dialog");
209
+ if (!button.hasAttribute("aria-expanded")) button.setAttribute("aria-expanded", "false");
210
+ } else {
211
+ button.setAttribute("aria-pressed", state === "on" ? "true" : "false");
212
+ }
204
213
  }
205
214
 
206
215
  async function toggleNotify() {
@@ -94,9 +94,10 @@ export function stubText({ version, label = "", lines = 0, top }) {
94
94
  return `v${version}${said}${size} — superseded by v${top}`;
95
95
  }
96
96
 
97
- // The version chip's words: "v3 · 2 earlier".
98
- export function versionChipText(count) {
99
- return `v${count} · ${count - 1} earlier`;
97
+ // The version chip's words for version +at+ (0-based) on view of +count+:
98
+ // the newest, "v3 · 2 earlier"; an earlier one the switcher shows, "v1 of 3".
99
+ export function versionChipText(count, at = count - 1) {
100
+ return at >= count - 1 ? `v${count} · ${count - 1} earlier` : `v${at + 1} of ${count}`;
100
101
  }
101
102
 
102
103
  // The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
@@ -236,12 +237,12 @@ export function installReviseStacks({
236
237
  function drawRow(row, { id, answer, chain, openings }) {
237
238
  const off = unavailable();
238
239
  const isArmed = armed() === id;
240
+ const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
239
241
  const versions = chain
240
- ? html`<details class="stack-versions"><summary>${versionChipText(chain.length)}</summary><div class="stack-versions-list" role="list">${chain.map((vid, k) => html`<button type="button" role="listitem" class=${vid === id ? "stack-version current" : "stack-version"} data-stack-pick=${vid} ?disabled=${vid === id}><span class="stack-version-n">v${k + 1}</span> ${promptLabel(openings.get(vid))}</button>`)}</div></details>`
242
+ ? html`<details class="stack-versions"><summary>${versionChipText(chain.length, at)}</summary><div class="stack-versions-list" role="list">${chain.map((vid, k) => html`<button type="button" role="listitem" class=${vid === id ? "stack-version current" : "stack-version"} data-stack-pick=${vid} ?disabled=${vid === id}><span class="stack-version-n">v${k + 1}</span> ${promptLabel(openings.get(vid))}</button>`)}</div></details>`
241
243
  : nothing;
242
244
  const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? REVISE_UNAVAILABLE : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>`;
243
245
  row.classList.toggle("stacked", !!chain);
244
- const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
245
246
  render(html`${chain ? switcherTemplate(id, at, chain.length) : nothing}${versions}${button}`, row);
246
247
  rowAnswer.set(row, answer);
247
248
  }
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.139.0
4
+ version: 0.139.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -309,6 +309,7 @@ files:
309
309
  - lib/samagotchi/context_usage.rb
310
310
  - lib/samagotchi/context_window.rb
311
311
  - lib/samagotchi/continue_offer.rb
312
+ - lib/samagotchi/cut.rb
312
313
  - lib/samagotchi/cut_policy.rb
313
314
  - lib/samagotchi/debug_log.rb
314
315
  - lib/samagotchi/delivery.rb