samagotchi 0.52.0 → 0.53.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: 98660ef911df1f010a45ded3190d0017793fc4e01c1229860ab523a7d2825ffd
4
- data.tar.gz: 2c1eab9152f8fba6efa7507d17dde3deb54a0e5bbd26a56a61f23f54d3d38a15
3
+ metadata.gz: f1d170176f088bc87560a05b7596d57925d955011cc5818b93f5ae33ae2b1f55
4
+ data.tar.gz: 941c3bd879e09b78144ede81d4c6cf4724f9ca3a7ce8fa62f0174524a26f25bf
5
5
  SHA512:
6
- metadata.gz: d85715550f95d875ced247c2642c04183507fbb2d85bf6f1201e62a3fb2471ba27c27a110dee57046a716a38e139acd8aaf5dbc7ea0ded2051c486056bb115fb
7
- data.tar.gz: 25591f45d69afe94d02ebabfce8887cfb126bf0c1b21ed74cc172ecbcfe881990f3431f43cde1b5b2c8801684102f628afbf4446bc2acab77fc50ef2ca6ff7f7
6
+ metadata.gz: ddc3c2d02b18137887e5e90f636b23dd3ea44461f33a723e4033d58f4cfcd9c7be3267db9cc3349aba1d3af6b73a398415696e3af2c8fdad6662704944dad189
7
+ data.tar.gz: bd8bded906f5e3855c8f63ab081582b601d4b5332bb10e9ed5a7dff36e185851583c95d28e8b3528ece4d8b34463cc6e65fd16bb95bf2c493de502d9efa79775
data/CHANGELOG.md CHANGED
@@ -8,6 +8,31 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.53.0] - 2026-10-10
12
+
13
+ ### Changed
14
+
15
+ - Web: the `think` chip's level popover is a lit-html template (it was built as an HTML string); it looks and works
16
+ as before.
17
+
18
+ ### Fixed
19
+
20
+ - `/model X` (and `/model X --default`, `/model clear`, and a successful `/model X --alias NAME`) now carry the
21
+ model's thinking level in the same place the plain `/model` reply does; a switch that fails to persist its alias
22
+ is unchanged.
23
+ - `chi bundle build --name <profile>` (an installed profile such as `core`) is refused with a line saying why: it used
24
+ to build a plain bundle of the scope's other memories under the profile's name, without its `includes:` and with
25
+ its trust level, which reinstalled over the profile would lose its members.
26
+ - Web: Tab completion in the composer offers `/cut` (`/cu` + Tab); the terminals' own commands (`/stats`,
27
+ `/exit` …) stay out of it.
28
+ - `chi -p /stats --non-interactive` (and `/exit`, `/recap` … — the terminals' own commands) is refused with a line
29
+ on stderr, as `chi send` refuses them, instead of going to the model as a prompt; it exits 1 and makes no session.
30
+ - A turn that fails on its host under a model alias says what the alias became: `--model splash` (an alias with no
31
+ host) failing on host `main` adds `(splash is an alias: gemma-x on host main)` to the error line.
32
+ - `chi sessions prune`, the lazy sweep and the TUI's discard-on-exit no longer crash on a session folder whose
33
+ `analytics.json` or `cards.json` is broken (not JSON or the wrong shape): every caller now keeps that folder like any
34
+ other not-empty one, so one corrupt file no longer stops the whole retention sweep.
35
+
11
36
  ## [0.52.0] - 2026-10-10
12
37
 
13
38
  ### Added
@@ -2387,7 +2412,8 @@ and long-lived sessions.
2387
2412
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2388
2413
  hotkey panel that send selected text or the clipboard to your sessions.
2389
2414
 
2390
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.52.0...HEAD
2415
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.53.0...HEAD
2416
+ [0.53.0]: https://github.com/dm1try/samagotchi/compare/v0.52.0...v0.53.0
2391
2417
  [0.52.0]: https://github.com/dm1try/samagotchi/compare/v0.51.0...v0.52.0
2392
2418
  [0.51.0]: https://github.com/dm1try/samagotchi/compare/v0.50.0...v0.51.0
2393
2419
  [0.50.0]: https://github.com/dm1try/samagotchi/compare/v0.49.0...v0.50.0
data/docs/releasing.md CHANGED
@@ -79,9 +79,12 @@ The agent does each step and stops where the user has to say yes.
79
79
  8. **The user approves the `release` environment** in the Actions run
80
80
  (Review deployments → Approve).
81
81
  9. **Watch the run.** `gh run watch` (or `gh run list --workflow release.yml`).
82
- It checks that the tag is `v` + VERSION and on main, builds the gem, pushes
83
- it with a trusted-publishing token and creates the GitHub release with the
84
- notes and the .gem attached.
82
+ It doesn't run the suites again: it checks that the CI workflow succeeded
83
+ on the tagged commit as a push to main (waiting while that run is still
84
+ going, stopping if it failed or never ran) and runs the full
85
+ `bundles:check`. Then it checks that the tag is `v` + VERSION and on main,
86
+ builds the gem, pushes it with a trusted-publishing token and creates the
87
+ GitHub release with the notes and the .gem attached.
85
88
  10. **Verify the published gem** in a clean GEM_HOME:
86
89
  ```sh
87
90
  tmp=$(mktemp -d)
data/docs/testing.md CHANGED
@@ -15,9 +15,11 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
15
15
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
16
16
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
17
17
 
18
- The web specs run in plain node. One that renders with lit-html (`turn_view.js`, `vendor/lit-html.js`) needs a DOM:
19
- `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only, never shipped; about 0.3 s per spec
20
- file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments.
18
+ The web specs run in plain node. One that renders with lit-html (`turn_view.js`, `thinking_chip.js`,
19
+ `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
20
+ never shipped; about 0.3 s per spec file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
21
+ without lit's marker comments. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
22
+ `?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
21
23
 
22
24
  ## Worktrees
23
25
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Samagotchi
6
+ # A session's analytics.json: the SessionMetrics snapshot with every turn
7
+ # and tool record, saved next to the session file (<session dir>/
8
+ # analytics.json). SessionMetrics#persist writes it; the session lists,
9
+ # `chi sessions stats`, the web's timing and a collector loading an earlier
10
+ # process's records read it. The records stay string-keyed hashes as
11
+ # saved: their keys grow over versions and every reader treats each one
12
+ # as optional.
13
+ module AnalyticsFile
14
+ NAME = "analytics.json"
15
+
16
+ module_function
17
+
18
+ # @param session_dir [String]
19
+ # @return [String]
20
+ def path(session_dir)
21
+ File.join(session_dir, NAME)
22
+ end
23
+
24
+ # The parsed file, string-keyed; nil when it is missing, unreadable,
25
+ # not JSON or not an object (an older or broken file reads as none).
26
+ # @param session_dir [String]
27
+ # @return [Hash, nil]
28
+ def read(session_dir)
29
+ data = JSON.parse(File.read(path(session_dir)))
30
+ data.is_a?(Hash) ? data : nil
31
+ rescue JSON::ParserError, SystemCallError
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -397,13 +397,10 @@ module Samagotchi
397
397
  if hooks.any?
398
398
  @stdout.puts " Hooks (#{hooks.size}):"
399
399
  hooks.each do |hk, meta|
400
- m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
401
- event = m["event"] || ""
402
- on_error = m["on_error"] || "skip"
403
- prio = m["priority"] || 100
400
+ hook = Samagotchi::MemoryBundle::BundleHook.parse(meta)
404
401
  hook_path = File.join(Samagotchi::MemoryBundle::Provenance.new(name: name).hooks_dir, hk.to_s)
405
402
  exists = File.exist?(hook_path) ? "ok" : "missing"
406
- @stdout.puts " #{hk}: event=#{event} on_error=#{on_error} priority=#{prio} [#{exists}]"
403
+ @stdout.puts " #{hk}: event=#{hook.event} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
407
404
  end
408
405
  @stdout.puts " requires_chi: #{st[:provenance][:requires_chi]}" if st[:hooks_requires_failure]
409
406
  @stdout.puts " not loaded: #{st[:hooks_requires_failure]}" if st[:hooks_requires_failure]
@@ -496,10 +493,7 @@ module Samagotchi
496
493
  @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
497
494
  # Show hook metadata
498
495
  meta = hooks[file_arg.to_sym] || hooks[file_arg]
499
- if meta
500
- m = meta.transform_keys(&:to_s)
501
- @stdout.puts "--- metadata: event=#{m["event"]} on_error=#{m["on_error"]} priority=#{m["priority"]} sha256=#{m["sha256"]}"
502
- end
496
+ @stdout.puts hook_metadata_line(meta) if meta
503
497
  elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
504
498
  show_plugin.call
505
499
  else
@@ -529,10 +523,7 @@ module Samagotchi
529
523
  @stdout.puts "--- current (on-disk) ---"
530
524
  @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
531
525
  meta = hooks[k.to_sym] || hooks[k]
532
- if meta
533
- m = meta.transform_keys(&:to_s)
534
- @stdout.puts "--- metadata: event=#{m["event"]} on_error=#{m["on_error"]} priority=#{m["priority"]} sha256=#{m["sha256"]}"
535
- end
526
+ @stdout.puts hook_metadata_line(meta) if meta
536
527
  @stdout.puts ""
537
528
  end
538
529
  if plugin_file
@@ -586,6 +577,11 @@ module Samagotchi
586
577
  "includes=#{here.join(",")}#{" left out=#{gone.join(",")}" unless gone.empty?}"
587
578
  end
588
579
 
580
+ def hook_metadata_line(meta)
581
+ hook = Samagotchi::MemoryBundle::BundleHook.parse(meta)
582
+ "--- metadata: event=#{hook.event} on_error=#{hook.on_error} priority=#{hook.priority} sha256=#{hook.sha256}"
583
+ end
584
+
589
585
  def build(rest)
590
586
  parsed = parse_flags(BUILD_FLAGS, rest, "build", help: BUILD_HELP)
591
587
  return parsed if parsed.is_a?(Integer)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.52.0
3
+ version: 0.53.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:
@@ -504,6 +504,20 @@ module Samagotchi
504
504
  end
505
505
  private :record_prompt_notes
506
506
 
507
+ # A failed turn's one line (the error's #summary). A host's failure
508
+ # under an alias says what the alias became (HostRegistry#alias_note):
509
+ # "host main" alone doesn't tell --model splash's user it was splash.
510
+ # @return [String]
511
+ def failure_summary(error)
512
+ summary = error.respond_to?(:summary) ? error.summary : error.message
513
+ return summary unless error.is_a?(LLM::ProviderError) && error.host
514
+
515
+ note = @host_registry.alias_note(@effective_model_name)
516
+ note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
517
+ rescue StandardError
518
+ summary
519
+ end
520
+
507
521
  # +name+'s resolved ref (ModelRef#ref).
508
522
  def model_ref_for(name)
509
523
  @host_registry.model_ref(name).ref
@@ -2119,7 +2133,7 @@ module Samagotchi
2119
2133
  failed[:kept_steps] = steps if steps
2120
2134
  # A provider error says what kind it is, for one line per kind in the UIs.
2121
2135
  if error.is_a?(LLM::ProviderError)
2122
- failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
2136
+ failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
2123
2137
  end
2124
2138
  failed
2125
2139
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
+ require_relative "../memory_bundle/bundle_hook"
6
7
  require_relative "../memory_bundle/provenance"
7
8
  require_relative "../memory_bundle/manifest"
8
9
  require_relative "../version"
@@ -44,7 +45,8 @@ module Samagotchi
44
45
  #
45
46
  # @param bundle_name [String]
46
47
  # @param hooks_dir [String] directory containing the bundle's .rb hook files
47
- # @param metadata [Hash] basename => { event:, on_error:, priority: }
48
+ # @param metadata [Hash] basename => { event:, on_error:, priority:,
49
+ # sha256: } (MemoryBundle::BundleHook.parse reads it)
48
50
  # @param registry [Samagotchi::Hooks::Registry]
49
51
  # @param failures [Guardrails::LoadFailures, nil] collects hooks that
50
52
  # failed to load
@@ -64,14 +66,13 @@ module Samagotchi
64
66
  basename = raw_basename.to_s
65
67
  next if basename.empty?
66
68
 
67
- meta = ->(key) { raw_meta && (raw_meta[key] || raw_meta[key.to_s]) }
68
- event = meta.call(:event).to_s
69
+ hook = MemoryBundle::BundleHook.parse(raw_meta)
69
70
  # A hook with no declared event cannot be auto-registered.
70
- next if event.empty?
71
+ next if hook.event.empty?
71
72
 
72
- event_sym = event.to_sym
73
- on_error = (meta.call(:on_error) || "skip").to_s
74
- priority = (meta.call(:priority) || 100).to_i
73
+ event_sym = hook.event.to_sym
74
+ on_error = hook.on_error
75
+ priority = hook.priority
75
76
  fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
76
77
  what = "hook #{basename} (bundle #{bundle_name})"
77
78
  file = hooks_dir && File.join(hooks_dir, basename)
@@ -79,7 +80,7 @@ module Samagotchi
79
80
  failures&.add(what, "the file is missing", required: true) if fail_closed
80
81
  next
81
82
  end
82
- if (mismatch = too_old || sha_mismatch(file, meta.call(:sha256), required: fail_closed))
83
+ if (mismatch = too_old || sha_mismatch(file, hook.sha256, required: fail_closed))
83
84
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
84
85
  failures&.add(what, mismatch, required: fail_closed)
85
86
  next
@@ -273,6 +273,19 @@ module Samagotchi
273
273
  ModelTarget.new(model: raw_model, entry: entry, bare_model: bare, client: client_for(entry))
274
274
  end
275
275
 
276
+ # What the alias +raw_model+ became, for an error that names only the
277
+ # host ("splash is an alias: gemma-x on host main"), or nil when it
278
+ # isn't an alias. An alias without a host of its own goes where a bare
279
+ # id goes (#host_for_model), often the default host the user never typed.
280
+ # @return [String, nil]
281
+ def alias_note(raw_model)
282
+ ref = model_ref(raw_model)
283
+ return nil unless ref.alias_name
284
+
285
+ target = resolve(raw_model)
286
+ "#{ref.alias_name} is an alias: #{target.bare_model} on host #{target.entry.name}"
287
+ end
288
+
276
289
  # The model id sent for +full_ref+: its alias applied, a known host
277
290
  # prefix stripped ("box:gemma" → "gemma").
278
291
  def bare_name(full_ref)
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "fileutils"
5
5
  require "tmpdir"
6
+ require_relative "bundle_hook"
6
7
  require_relative "manifest"
7
8
  require_relative "placeholder"
8
9
  require_relative "../context_providers"
@@ -50,6 +51,8 @@ module Samagotchi
50
51
  validate_version!(resolved_version)
51
52
  raise BuildError, "invalid scope: #{@scope}" unless %w[system project].include?(@scope)
52
53
 
54
+ refuse_profile!(resolved_name)
55
+
53
56
  # Collect *.md files, excluding index.md and hidden files
54
57
  all_md = Dir.glob(File.join(target_dir, "*.md")).sort
55
58
  candidates = all_md.reject { |p| File.basename(p) == "index.md" }
@@ -110,20 +113,9 @@ module Samagotchi
110
113
  # Skip if hook file missing on disk
111
114
  next unless File.exist?(src)
112
115
 
113
- # meta may be symbol-keyed
114
- m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
115
- sha = (m["sha256"] || m["checksum"] || "").to_s
116
- if sha.empty?
117
- sha = "sha256:#{Digest::SHA256.hexdigest(File.read(src))}"
118
- elsif !sha.start_with?("sha256:")
119
- sha = "sha256:#{sha}"
120
- end
121
- hooks_map[basename] = {
122
- "sha256" => sha,
123
- "event" => (m["event"] || "").to_s,
124
- "on_error" => (m["on_error"] || "skip").to_s,
125
- "priority" => (m["priority"] || 100).to_i
126
- }
116
+ hook = BundleHook.parse(meta)
117
+ hook = hook.with(sha256: BundleHook.of_file(src).sha256) if hook.sha256.empty?
118
+ hooks_map[basename] = hook
127
119
  hooks_to_copy << [src, basename]
128
120
  end
129
121
  else
@@ -132,13 +124,7 @@ module Samagotchi
132
124
  if Dir.exist?(local_hooks_dir)
133
125
  Dir.glob(File.join(local_hooks_dir, "*.rb")).sort.each do |src|
134
126
  basename = File.basename(src)
135
- sha = Digest::SHA256.hexdigest(File.read(src))
136
- hooks_map[basename] = {
137
- "sha256" => "sha256:#{sha}",
138
- "event" => "",
139
- "on_error" => "skip",
140
- "priority" => 100
141
- }
127
+ hooks_map[basename] = BundleHook.of_file(src)
142
128
  hooks_to_copy << [src, basename]
143
129
  end
144
130
  end
@@ -268,6 +254,22 @@ module Samagotchi
268
254
 
269
255
  private
270
256
 
257
+ # An installed profile (meta bundle) ships only its includes: a build
258
+ # under its name would make a plain bundle of the scope's memories,
259
+ # without the includes, carrying the profile's trust_level.
260
+ def refuse_profile!(name)
261
+ require_relative "profile"
262
+ data = Provenance.new(name: name).read
263
+ return unless Profile.installed_meta?(name, data)
264
+
265
+ shipped = File.join(SourceNormalizer::SHIPPED_DIR, name)
266
+ members = Profile.recorded(data, File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil)
267
+ raise BuildError, "#{name} is an installed profile (includes: #{members.join(", ")}): it ships only its includes, " \
268
+ "so a build can't remake it; pick another --name for these memories"
269
+ rescue JSON::ParserError, SystemCallError, Manifest::ValidationError
270
+ nil
271
+ end
272
+
271
273
  # The installed bundle's scripts its record lists, file name => path
272
274
  # in its scripts/ (a missing one is left out); {} without a record.
273
275
  def installed_scripts(prov, prov_data)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # One hook's metadata, as a bundle's manifest.yml declares it and its
8
+ # installed record (manifest.json) keeps it: the file's sha256
9
+ # ("sha256:<hex>", "" when none is recorded), the event it registers
10
+ # for ("" when none: it isn't loaded), on_error (skip, log or
11
+ # fail_closed) and priority. The one place the defaults live.
12
+ class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
13
+ DEFAULT_ON_ERROR = "skip"
14
+ DEFAULT_PRIORITY = 100
15
+
16
+ def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
17
+ super
18
+ end
19
+
20
+ # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
21
+ # symbol keys (a read record); missing or blank values get the
22
+ # defaults
23
+ def self.parse(raw)
24
+ return raw if raw.is_a?(BundleHook)
25
+
26
+ h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
27
+ on_error = h["on_error"].to_s.strip
28
+ new(sha256: prefixed(h["sha256"]), event: h["event"].to_s.strip,
29
+ on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
30
+ priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
31
+ end
32
+
33
+ # A hook known only by its file (no declared event): its sha, the defaults.
34
+ def self.of_file(path)
35
+ new(sha256: "sha256:#{Digest::SHA256.hexdigest(File.binread(path))}")
36
+ end
37
+
38
+ # "sha256:<hex>" from either form, "" for none.
39
+ def self.prefixed(sha)
40
+ sha = sha.to_s.strip
41
+ sha.empty? || sha.start_with?("sha256:") ? sha : "sha256:#{sha}"
42
+ end
43
+
44
+ # The sha's hex, "" when none is recorded.
45
+ def hex = sha256.delete_prefix("sha256:")
46
+
47
+ # The string-keyed form manifest.yml and manifest.json hold.
48
+ def to_record = to_h.transform_keys(&:to_s)
49
+ end
50
+ end
51
+ end
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "digest"
5
5
  require_relative "../memory_paths"
6
+ require_relative "bundle_hook"
6
7
  require_relative "provenance"
7
8
  require_relative "manifest"
8
9
  require_relative "source"
@@ -180,7 +181,7 @@ module Samagotchi
180
181
  discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
181
182
  discovered.each do |bn|
182
183
  # Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
183
- hook_entries[bn] = { sha256: "", event: "", on_error: "skip", priority: 100 }
184
+ hook_entries[bn] = BundleHook.new
184
185
  end
185
186
  end
186
187
 
@@ -206,7 +207,7 @@ module Samagotchi
206
207
  if @upgrade && existing_provenance && File.exist?(dest) && !@force
207
208
  prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
208
209
  if prev_meta
209
- prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
210
+ prev_sha = BundleHook.parse(prev_meta).sha256
210
211
  if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
211
212
  @warnings << "Hook #{basename} was locally modified; overwriting"
212
213
  end
@@ -403,8 +404,7 @@ module Samagotchi
403
404
  hooks_files_for_provenance.each do |basename, path|
404
405
  next unless File.exist?(path)
405
406
 
406
- sha = Digest::SHA256.hexdigest(File.read(path))
407
- hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
407
+ hooks_for_provenance[basename] = BundleHook.of_file(path)
408
408
  end
409
409
  end
410
410
  Provenance.new(name: @name).write(
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require "digest"
5
5
  require "fileutils"
6
6
  require_relative "../context_providers"
7
+ require_relative "bundle_hook"
7
8
 
8
9
  module Samagotchi
9
10
  module MemoryBundle
@@ -53,7 +54,8 @@ module Samagotchi
53
54
  BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
54
55
 
55
56
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
56
- # the description a mapping gave, for the files that have one.
57
+ # the description a mapping gave, for the files that have one; hooks:
58
+ # name → BundleHook.
57
59
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
58
60
  :scripts, :context_providers
59
61
 
@@ -99,13 +101,7 @@ module Samagotchi
99
101
 
100
102
  # Returns sha256 hex for a hook basename (e.g. "guardrails.rb").
101
103
  def checksum_for_hook(basename)
102
- raw = @hooks[basename]
103
- return nil unless raw
104
-
105
- sha = raw[:sha256] || raw["sha256"]
106
- return nil unless sha
107
-
108
- sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
104
+ @hooks[basename]&.hex
109
105
  end
110
106
 
111
107
  # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
@@ -142,14 +138,7 @@ module Samagotchi
142
138
  text = file_descriptions[key].to_s.strip
143
139
  [key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
144
140
  end
145
- if hooks && !hooks.empty?
146
- # Normalize hooks to string-keyed with sha256 prefix preserved
147
- manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
148
- h = v.transform_keys(&:to_s)
149
- h["sha256"] = h["sha256"].to_s.start_with?("sha256:") ? h["sha256"].to_s : "sha256:#{h["sha256"]}" if h["sha256"]
150
- h
151
- end
152
- end
141
+ manifest["hooks"] = hooks.to_h { |k, v| [k.to_s, BundleHook.parse(v).to_record] } if hooks && !hooks.empty?
153
142
  manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
154
143
  if plugin
155
144
  sha = plugin[:sha256].to_s
@@ -306,18 +295,7 @@ module Samagotchi
306
295
  next unless k.is_a?(String) && !k.empty?
307
296
  next unless v.is_a?(Hash)
308
297
 
309
- sha = (v["sha256"] || v[:sha256] || "").to_s
310
- sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
311
- event = (v["event"] || v[:event] || "").to_s.strip
312
- on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
313
- on_error = "skip" if on_error.empty?
314
- priority = (v["priority"] || v[:priority] || 100).to_i
315
- acc[k] = {
316
- sha256: sha,
317
- event: event,
318
- on_error: on_error,
319
- priority: priority
320
- }
298
+ acc[k] = BundleHook.parse(v)
321
299
  end
322
300
  end
323
301
  end
@@ -6,6 +6,7 @@ require "date"
6
6
  require "fileutils"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../memory_paths"
9
+ require_relative "bundle_hook"
9
10
 
10
11
  module Samagotchi
11
12
  module MemoryBundle
@@ -177,22 +178,14 @@ module Samagotchi
177
178
  hooks_map = {}
178
179
  # existing hooks for pruning
179
180
  existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
180
- # Normalize incoming hooks (basename => metadata hash)
181
+ # Incoming hooks: basename => BundleHook (or its metadata hash)
181
182
  normalized_hooks = {}
182
183
  if hooks.is_a?(Hash)
183
184
  hooks.each do |k, v|
184
185
  next unless k.is_a?(String) && !k.empty?
185
- next unless v.is_a?(Hash)
186
-
187
- hv = v.transform_keys(&:to_sym)
188
- sha = (hv[:sha256] || "").to_s
189
- sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
190
- normalized_hooks[k] = {
191
- sha256: sha,
192
- event: (hv[:event] || "").to_s,
193
- on_error: (hv[:on_error] || "skip").to_s,
194
- priority: (hv[:priority] || 100).to_i
195
- }
186
+ next unless v.is_a?(Hash) || v.is_a?(BundleHook)
187
+
188
+ normalized_hooks[k] = BundleHook.parse(v)
196
189
  end
197
190
  end
198
191
  # Prune stale hook bases
@@ -210,12 +203,10 @@ module Samagotchi
210
203
  # compares against it, and a declared sha can be wrong (installing
211
204
  # only warns about that).
212
205
  hooks_files = {} unless hooks_files.is_a?(Hash)
213
- normalized_hooks.each do |k, meta|
206
+ normalized_hooks.each do |k, hook|
214
207
  src = hooks_files[k] || hooks_files[k.to_sym]
215
- if src && File.exist?(src.to_s)
216
- meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
217
- end
218
- hooks_map[k] = meta.transform_keys(&:to_s)
208
+ hook = hook.with(sha256: BundleHook.of_file(src.to_s).sha256) if src && File.exist?(src.to_s)
209
+ hooks_map[k] = hook.to_record
219
210
  next unless src && File.exist?(src.to_s)
220
211
 
221
212
  begin
@@ -481,26 +481,8 @@ module Samagotchi
481
481
  # (a bundle's too).
482
482
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
483
483
 
484
- # The UI-only built-in command +text+ (stripped) is a line of (/stats,
485
- # /exit, …), or nil: the worker never runs it, so chi send must not pass
486
- # it on. What the UIs run: the line as the entry's match takes it — bare
487
- # "exit" too (/exit's match), but not with arguments ("/exit --delete" is
488
- # a UI line; "/stats please" is a prompt), as in a terminal.
489
- # @return [Commands::Registry::Entry, nil]
490
- def local_command(text)
491
- line = text.to_s.strip
492
- return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
493
-
494
- entry = SessionCommands.builtin_registry.lookup_local(line)
495
- # Only the exact line the UI runs: "/stats please" is a prompt, and
496
- # "/exit --delete" is left to the worker (the TUI runs it as /exit, the
497
- # worker answers its unknown-command way). Bare "exit" is /exit's line
498
- # as its match takes it; the reply names the command, /exit.
499
- return nil unless entry
500
- return entry if line == entry.name
501
-
502
- line.match?(/\Aexit\z/i) ? entry : nil
503
- end
484
+ # @return [Commands::Registry::Entry, nil] SessionCommands.ui_only_entry
485
+ def local_command(text) = SessionCommands.ui_only_entry(text)
504
486
 
505
487
  # A UI-only command: nothing is sent (with --new, no session is created
506
488
  # either). /stats points at its model-free reader and names the session;
@@ -525,13 +507,7 @@ module Samagotchi
525
507
  CLI::Exit::FAILED
526
508
  end
527
509
 
528
- # Why a UI-only command isn't sent: /stats points at its model-free
529
- # reader (naming the session when there is one).
530
- def local_refusal(name, id)
531
- return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
532
-
533
- "#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
534
- end
510
+ def local_refusal(name, id) = SessionCommands.ui_only_refusal(name, id, runner: "chi send")
535
511
 
536
512
  # --wait after a message that ran as a session command: no reply comes
537
513
  # for it. Its output shows where the session is open.
@@ -141,6 +141,34 @@ module Samagotchi
141
141
  @builtin_registry ||= register_builtins(Commands::Registry.new).freeze
142
142
  end
143
143
 
144
+ # The UI-only built-in command +text+ is a line of (/stats, /exit, …), or
145
+ # nil: neither a worker nor a one-shot run (chi send, chi -p
146
+ # --non-interactive) runs it, so they refuse it rather than send it to
147
+ # the model as a prompt. What the UIs run: the line as the entry's match
148
+ # takes it — bare "exit" too (/exit's match), but not with arguments
149
+ # ("/exit --delete" is a UI line; "/stats please" is a prompt), as in a
150
+ # terminal.
151
+ # @return [Commands::Registry::Entry, nil]
152
+ def self.ui_only_entry(text)
153
+ line = text.to_s.strip
154
+ return nil unless line.start_with?("/", "!") || line.match?(/\Aexit\z/i)
155
+
156
+ entry = builtin_registry.lookup_local(line)
157
+ return nil unless entry
158
+ return entry if line == entry.name
159
+
160
+ line.match?(/\Aexit\z/i) ? entry : nil
161
+ end
162
+
163
+ # Why a UI-only command isn't run by +runner+ ("chi send"): /stats points
164
+ # at its model-free reader (naming the session +id+ when there is one).
165
+ # @return [String]
166
+ def self.ui_only_refusal(name, id, runner:)
167
+ return "#{name} is a terminal and web command; #{runner} doesn't run it" unless name == "/stats"
168
+
169
+ "#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
170
+ end
171
+
144
172
  # The name of this chi's own session command +line+ is (one a worker
145
173
  # runs), or nil. A worker that answers such a line unknown_command runs
146
174
  # an older chi that doesn't have it yet (/llm-context against 0.39.0):
@@ -488,7 +516,7 @@ module Samagotchi
488
516
 
489
517
  key = alias_name.strip.downcase
490
518
  warn_prefix = previous ? "warning: overwriting alias '#{key}' (#{previous} -> #{model_name}); " : ""
491
- base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated" : "runtime model set to #{model_name}#{model_note}"
519
+ base = persist_default ? "runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}" : "runtime model set to #{model_name}#{model_note}#{thinking_note}"
492
520
  ["#{warn_prefix}#{base}; alias '#{key}' -> '#{model_name}' persisted", true]
493
521
  else
494
522
  return ["--default requires a model name: usage /model --default <name> or /model <name> [--default]", false] if arg.empty?
@@ -497,14 +525,14 @@ module Samagotchi
497
525
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
498
526
 
499
527
  switch_model(@default_model)
500
- return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
528
+ return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
501
529
  end
502
530
 
503
531
  switch_model(arg, persist_default: persist_default)
504
532
  if persist_default
505
- ["runtime model set to #{model_name}#{model_note} and default updated#{notes_note}", true]
533
+ ["runtime model set to #{model_name}#{model_note} and default updated#{thinking_note}#{notes_note}", true]
506
534
  else
507
- ["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
535
+ ["runtime model set to #{model_name}#{model_note}#{thinking_note}#{notes_note}", true]
508
536
  end
509
537
  end
510
538
  end
@@ -6,6 +6,7 @@ require "time"
6
6
  require "securerandom"
7
7
  require "rbconfig"
8
8
  require_relative "client_id"
9
+ require_relative "analytics_file"
9
10
  require_relative "atomic_file"
10
11
  require_relative "config"
11
12
  require_relative "delivery"
@@ -974,7 +975,7 @@ module Samagotchi
974
975
  # session keeps. "pid" is the pid file workers wrote until 2026-10-01:
975
976
  # nothing writes or reads it now, but a folder from before still has one
976
977
  # and is still empty.
977
- EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, "analytics.json"].freeze
978
+ EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerSidecar::FILE, AnalyticsFile::NAME].freeze
978
979
  EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
979
980
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
980
981
 
@@ -1058,17 +1059,48 @@ module Samagotchi
1058
1059
  path = File.join(dir, name)
1059
1060
  if EMPTY_SKELETON_DIRS.include?(name)
1060
1061
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.empty?(path))
1061
- elsif name == "analytics.json"
1062
- Array(JSON.parse(File.read(path))["turn_records"]).empty?
1062
+ elsif name == AnalyticsFile::NAME
1063
+ analytics_empty?(path)
1063
1064
  # Cards and notices from before any turn (Bridge::CardStore).
1064
1065
  elsif name == "cards.json"
1065
- JSON.parse(File.read(path))["turns"].to_i.zero?
1066
+ cards_empty?(path)
1066
1067
  else
1067
1068
  EMPTY_SKELETON_FILES.include?(name)
1068
1069
  end
1069
1070
  end
1070
1071
  end
1071
1072
 
1073
+ # A folder's analytics.json is empty only when it is a JSON object with
1074
+ # an empty turn_records array. Anything unreadable, not JSON, not an
1075
+ # object, the wrong type, or turn_records the wrong shape reads as
1076
+ # "not empty": a broken analytics keeps the folder instead of crashing
1077
+ # the retention sweep (or of a wrong-shape one reading as empty).
1078
+ def self.analytics_empty?(path)
1079
+ data = read_json(path)
1080
+ turns = data.is_a?(Hash) ? data["turn_records"] : nil
1081
+ turns.is_a?(Array) && turns.empty?
1082
+ end
1083
+
1084
+ # A folder's cards.json is empty only when it is a JSON object whose
1085
+ # turns is an integer zero. Anything unreadable, not JSON, not an
1086
+ # object, the wrong type, or turns not an integer reads as "not empty":
1087
+ # a broken or wrong-shape cards file keeps the folder instead of
1088
+ # raising (Array#/Hash#to_i) or reading as empty.
1089
+ def self.cards_empty?(path)
1090
+ data = read_json(path)
1091
+ turns = data.is_a?(Hash) ? data["turns"] : nil
1092
+ turns.is_a?(Integer) && turns.zero?
1093
+ end
1094
+
1095
+ # A session-dir JSON file's contents, or nil when it is missing,
1096
+ # unreadable, not JSON or not an object (mirrors AnalyticsFile#read): a
1097
+ # broken file reads as none, so its folder is kept, not deleted.
1098
+ def self.read_json(path)
1099
+ JSON.parse(File.read(path))
1100
+ rescue JSON::ParserError, SystemCallError
1101
+ nil
1102
+ end
1103
+
1072
1104
  # Delete one session: its <id>.json, the whole <id>/ directory
1073
1105
  # (history sidecars, input, output, notes, images) and its attached
1074
1106
  # context (ContextSources: its sources, subscriptions, mute markers). The CLI, the TUI's
@@ -5,6 +5,7 @@ require "monitor"
5
5
  require "time"
6
6
  require "fileutils"
7
7
  require "securerandom"
8
+ require_relative "analytics_file"
8
9
  require_relative "atomic_file"
9
10
  require_relative "token_usage"
10
11
  require_relative "model_price"
@@ -172,8 +173,8 @@ module Samagotchi
172
173
  # @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
173
174
  # @return [SavedSummary, nil]
174
175
  def self.saved_summary(session_dir, budget_tokens: nil)
175
- data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
176
- return nil unless data.is_a?(Hash)
176
+ data = AnalyticsFile.read(session_dir)
177
+ return nil unless data
177
178
 
178
179
  tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
179
180
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
@@ -182,7 +183,7 @@ module Samagotchi
182
183
  reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
183
184
  cost_estimate_sum: count.call("cost_estimate_sum"),
184
185
  memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
185
- rescue JSON::ParserError, SystemCallError, TypeError
186
+ rescue TypeError
186
187
  nil
187
188
  end
188
189
 
@@ -217,10 +218,10 @@ module Samagotchi
217
218
  def self.saved_tool_records(session_dir, turn_id)
218
219
  return [] if turn_id.to_s.empty?
219
220
 
220
- records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
221
+ records = AnalyticsFile.read(session_dir)&.dig("tool_records")
221
222
  Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
222
223
  .sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
223
- rescue JSON::ParserError, SystemCallError, TypeError
224
+ rescue TypeError
224
225
  []
225
226
  end
226
227
 
@@ -234,9 +235,9 @@ module Samagotchi
234
235
  def self.saved_turn_record(session_dir, turn_id)
235
236
  return nil if turn_id.to_s.empty?
236
237
 
237
- records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["turn_records"]
238
+ records = AnalyticsFile.read(session_dir)&.dig("turn_records")
238
239
  Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
239
- rescue JSON::ParserError, SystemCallError, TypeError
240
+ rescue TypeError
240
241
  nil
241
242
  end
242
243
 
@@ -422,8 +423,7 @@ module Samagotchi
422
423
  # (XDG) location; passing an explicit nil would override it and break
423
424
  # File.join.
424
425
  FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
425
- path = File.join(dir, "analytics.json")
426
- AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
426
+ AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
427
427
  @mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
428
428
  true
429
429
  rescue StandardError
@@ -977,11 +977,8 @@ module Samagotchi
977
977
  # broken file, or an older shape: nothing, or zeros where keys are
978
978
  # missing). Caller holds the mutex.
979
979
  def load_persisted
980
- path = File.join(session_dir(@session_id, @state_dir), "analytics.json")
981
- return unless File.file?(path)
982
-
983
- prior = JSON.parse(File.read(path))
984
- return unless prior.is_a?(Hash)
980
+ prior = AnalyticsFile.read(session_dir(@session_id, @state_dir))
981
+ return unless prior
985
982
 
986
983
  loaded_turns = loaded_records(prior["turn_records"])
987
984
  @turn_records = loaded_turns + @turn_records
@@ -1004,12 +1001,10 @@ module Samagotchi
1004
1001
  @memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
1005
1002
  # The window last seen, until this process's first generation reports.
1006
1003
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
1007
- if window["window_tokens"] && @context_window_tokens.nil?
1008
- @context_window_tokens = window["window_tokens"]
1009
- @context_window_source = window["window_source"]
1010
- end
1011
- rescue JSON::ParserError, SystemCallError
1012
- nil
1004
+ return unless window["window_tokens"] && @context_window_tokens.nil?
1005
+
1006
+ @context_window_tokens = window["window_tokens"]
1007
+ @context_window_source = window["window_source"]
1013
1008
  end
1014
1009
 
1015
1010
  def symbolized(hash)
@@ -5,6 +5,7 @@ require_relative "session"
5
5
  require_relative "session_manager"
6
6
  require_relative "session_retention"
7
7
  require_relative "project_scope"
8
+ require_relative "analytics_file"
8
9
  require_relative "session_metrics"
9
10
  require_relative "recap_store"
10
11
  require_relative "session_delete_command"
@@ -337,10 +338,10 @@ module Samagotchi
337
338
  return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
338
339
  end
339
340
  end
340
- return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
341
+ return [nil, false, nil] unless File.file?(Samagotchi::AnalyticsFile.path(dir))
341
342
 
342
343
  snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
343
- note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
344
+ note = live_worker?(session.id) ? "(no live worker answered; from the saved #{Samagotchi::AnalyticsFile::NAME})" : nil
344
345
  [snapshot, false, note]
345
346
  end
346
347
 
@@ -20,7 +20,7 @@ module Samagotchi
20
20
  # (no UI: the same text from the CLI).
21
21
  # @return [String] "(no metrics yet)" without a snapshot
22
22
  def self.format_session_metrics(snapshot)
23
- (@formatter ||= Object.new.extend(Formatting)).send(:format_session_metrics, snapshot)
23
+ (@formatter ||= Object.new.extend(Formatting)).format_session_metrics(snapshot)
24
24
  end
25
25
 
26
26
  def format_tool_activity_line(activity, duration_ms: nil)
@@ -543,6 +543,8 @@ module Samagotchi
543
543
  value.length > width ? value[0, width] : value
544
544
  end
545
545
 
546
+ public
547
+
546
548
  # Render the analytics snapshot as a compact, user-facing report. Raw event
547
549
  # logs (debug-only) are intentionally excluded; this surface is for the REPL
548
550
  # and `chi sessions stats` (the module function above).
@@ -607,6 +609,8 @@ module Samagotchi
607
609
  lines.join("\n")
608
610
  end
609
611
 
612
+ private
613
+
610
614
  # " (25.0% of the 64000 budget)" when the session's llm_context budget
611
615
  # is set and smaller than the window, else " (12.5%)": the live meter
612
616
  # counts against the smaller of the two (ContextStatus#counted_against),
@@ -238,6 +238,10 @@ module Samagotchi
238
238
  # --non-interactive with no --prompt is a harmless no-op exit: build
239
239
  # nothing and return (no transient session, no banner).
240
240
  return if @non_interactive && @prompt.nil?
241
+ # A UI-only command (/stats, /exit …) isn't run without the REPL, and
242
+ # isn't a prompt either: refused as chi send refuses it, before any
243
+ # session is made (bin/chi exits 1).
244
+ return refuse_one_shot_ui_command if @non_interactive && SessionCommands.ui_only_entry(@prompt)
241
245
 
242
246
  session = @resume_session || @engine.store_model!(Session.new_session(
243
247
  mode: "assist",
@@ -348,7 +352,7 @@ module Samagotchi
348
352
  # so the session is kept and `chi --resume` takes it up again.
349
353
  # @return [Symbol] :turn_failed
350
354
  def one_shot_failed(session, error)
351
- warn "Error: #{error.respond_to?(:summary) ? error.summary : error.message}"
355
+ warn "Error: #{@engine.failure_summary(error)}"
352
356
  if !@scratch && Session.exist?(session.id)
353
357
  warn "chi: the session is kept with your prompt; continue it with: chi --resume #{session.id}"
354
358
  end
@@ -371,6 +375,14 @@ module Samagotchi
371
375
  :turn_failed if result.status == :error
372
376
  end
373
377
 
378
+ # `chi -p /stats --non-interactive`: why it doesn't run, on stderr.
379
+ # @return [Symbol] :turn_failed
380
+ def refuse_one_shot_ui_command
381
+ name = SessionCommands.ui_only_entry(@prompt).name
382
+ warn "chi: refused: #{SessionCommands.ui_only_refusal(name, @resume_session&.id, runner: "--non-interactive")}"
383
+ :turn_failed
384
+ end
385
+
374
386
  # Ctrl-C in a -p --non-interactive turn: the Engine kept the prompt and
375
387
  # a cancel note in the session (it saves only a failed turn), so save
376
388
  # it here: `chi --resume` takes it up again. A scratch session goes in
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.52.0"
4
+ VERSION = "0.53.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -9,6 +9,7 @@ require "uri"
9
9
  require "rack"
10
10
  require "rack/request"
11
11
 
12
+ require_relative "../analytics_file"
12
13
  require_relative "../client_id"
13
14
  require_relative "../delivery"
14
15
  require_relative "../answer_tail"
@@ -1722,13 +1723,7 @@ module Samagotchi
1722
1723
  end
1723
1724
 
1724
1725
  def read_analytics(session_id)
1725
- path = File.join(@session_class.session_dir(session_id, state_dir: default_state_dir), "analytics.json")
1726
- return {} unless File.file?(path)
1727
-
1728
- data = JSON.parse(File.read(path))
1729
- data.is_a?(Hash) ? data : {}
1730
- rescue JSON::ParserError, SystemCallError
1731
- {}
1726
+ AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
1732
1727
  end
1733
1728
 
1734
1729
  def merge_timing_records(persisted, live)
@@ -126,7 +126,6 @@ const llmContextNewControl = createLlmContextControl({
126
126
  // in the session's worker.
127
127
  const thinkingControl = createThinkingControl({
128
128
  el: infoTextEl,
129
- escapeHtml,
130
129
  summary: () => (selected ? current.thinking : null),
131
130
  session: () => selected,
132
131
  running: () => liveTurn.running,
@@ -139,7 +138,6 @@ let newChatThinking = null;
139
138
  const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
140
139
  const thinkingNewControl = createThinkingControl({
141
140
  el: thinkingNewEl,
142
- escapeHtml,
143
141
  summary: newChatThinkingSummary,
144
142
  kind: NEW_CHAT,
145
143
  choice: () => newChatThinking,
@@ -4,17 +4,22 @@
4
4
 
5
5
  // The commands to offer while the composer holds one "/word" being typed
6
6
  // (no space or newline yet): the session's slash commands that start with
7
- // it, by name. The terminal UIs' own (local: /stats, /exit …) don't run here.
7
+ // it, by name. A UI's own (local) one is offered only when its uis name
8
+ // the web (/cut); the terminals' own (/stats, /exit …) don't run here.
8
9
  export function commandMatches(commands, text) {
9
10
  const typed = String(text || "");
10
11
  if (!/^\/\S*$/.test(typed)) return [];
11
12
  const needle = typed.toLowerCase();
12
13
  return (Array.isArray(commands) ? commands : [])
13
- .filter((c) => c && !c.local && typeof c.name === "string" && c.name.startsWith("/"))
14
+ .filter((c) => c && runsInWeb(c) && typeof c.name === "string" && c.name.startsWith("/"))
14
15
  .filter((c) => c.name.toLowerCase().startsWith(needle))
15
16
  .sort((a, b) => a.name.localeCompare(b.name));
16
17
  }
17
18
 
19
+ function runsInWeb(command) {
20
+ return !command.local || (Array.isArray(command.uis) && command.uis.includes("web"));
21
+ }
22
+
18
23
  // What picking +name+ does to the composer: {text, send}. The name
19
24
  // completes with a space for its arguments; ⏎ on a name already typed in
20
25
  // full sends it as it is (⏎ on "/help" runs /help).
@@ -12,6 +12,7 @@
12
12
  // nothing is sent unless the user sets one.
13
13
 
14
14
  import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
15
+ import { html, nothing, render } from "./vendor/lit-html.js";
15
16
 
16
17
  export const LEVELS = ["off", "low", "medium", "high"];
17
18
  const DEFAULT = "default";
@@ -36,6 +37,8 @@ export function chipModel(summary, { kind = SESSION } = {}) {
36
37
  return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
37
38
  }
38
39
 
40
+ // The session bar's chip, as a string: the info bar is still built as one
41
+ // (app.js); it becomes a template when the info bar moves to lit.
39
42
  export function chipHtml(summary, escapeHtml) {
40
43
  const chip = chipModel(summary);
41
44
  if (!chip) return "";
@@ -50,32 +53,31 @@ export function commandLine(level, initial = null) {
50
53
  return `/thinking ${level}`;
51
54
  }
52
55
 
53
- function option(value, label, chosen, escapeHtml) {
54
- return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
55
- }
56
-
57
56
  // "Now: low (session)", "Now: default (the model's own)".
58
57
  export function nowText(summary) {
59
58
  return `Now: ${summary.level} (${summary.source || "the model's own"})`;
60
59
  }
61
60
 
62
- // The popover's form. For the new chat (kind NEW_CHAT), +summary+ is the
63
- // model's and +choice+ the level the form shows.
64
- export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
61
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
62
+
63
+ // The popover's form (a lit-html template). For the new chat (kind
64
+ // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
65
+ // shows; +now+ is the summary its "Now:" line reads (refresh passes a
66
+ // newer one, the rest stays as the form opened).
67
+ export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
65
68
  const fresh = kind === NEW_CHAT;
66
69
  const chosen = (fresh ? choice : summary.own) || DEFAULT;
67
70
  const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
68
71
  const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
69
72
  : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
70
- return `<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong>` +
71
- `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
72
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
73
- `<div class="ctx-pop-meta think-now">${escapeHtml(nowText(summary))}</div>` +
74
- `<label class="llmctx-field"><span>Level</span><select name="level">` +
75
- option(DEFAULT, follow, chosen, escapeHtml) +
76
- LEVELS.map((level) => option(level, level, chosen, escapeHtml)).join("") + `</select></label>` +
77
- `<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
78
- `<div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
73
+ return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
74
+ ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
75
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
76
+ ><div class="ctx-pop-meta think-now">${nowText(now)}</div
77
+ ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
78
+ LEVELS.map((level) => option(level, level, chosen))}</select></label
79
+ ><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
80
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
79
81
  }
80
82
 
81
83
  // The chip's popover, as createLlmContextControl's: el is where the chip
@@ -86,7 +88,7 @@ export function formHtml(summary, escapeHtml, { running = false, kind = SESSION,
86
88
  // and choose(next) takes a Set's (null for "follow the model"). id and
87
89
  // anchor (the chip's selector) keep it apart from the llm ctx control on
88
90
  // the same element.
89
- export function createThinkingControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
91
+ export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
90
92
  kind = SESSION, choice = () => null, choose = null,
91
93
  id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
92
94
  doc = document, win = window }) {
@@ -99,6 +101,11 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
99
101
  doc.body.appendChild(pop);
100
102
  let initial = null;
101
103
  let openFor = null;
104
+ // What the open form was drawn from: the model's or session's summary
105
+ // and the form's options. A closed popover is empty, so each open draws
106
+ // a fresh form (an unsent pick is gone).
107
+ let shown = null;
108
+ const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
102
109
 
103
110
  function place(anchor) {
104
111
  const width = Math.min(320, win.innerWidth - 32);
@@ -117,7 +124,8 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
117
124
  if (!data) return;
118
125
  initial = (fresh ? choice() : data.own) || DEFAULT;
119
126
  openFor = session();
120
- pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
127
+ shown = { data, opts: { running: running(), kind, choice: choice() } };
128
+ draw(data);
121
129
  pop.hidden = false;
122
130
  place(anchor);
123
131
  }
@@ -126,17 +134,19 @@ export function createThinkingControl({ el, escapeHtml, summary, session = () =>
126
134
  pop.hidden = true;
127
135
  initial = null;
128
136
  openFor = null;
137
+ shown = null;
138
+ render(nothing, pop);
129
139
  }
130
140
 
131
141
  function isOpen() { return !pop.hidden; }
132
142
 
133
- // A new summary: an open form keeps the user's pick; only "Now:" follows.
143
+ // A new summary: an open form keeps the user's pick; only "Now:" follows
144
+ // (the same template again: lit writes that line alone).
134
145
  function refresh() {
135
146
  if (!isOpen()) return;
136
147
  const data = summary();
137
148
  if (!data || session() !== openFor) return close();
138
- const now = pop.querySelector(".think-now");
139
- if (now) now.textContent = nowText(data);
149
+ draw(data);
140
150
  }
141
151
 
142
152
  el.addEventListener("click", (e) => {
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.52.0
4
+ version: 0.53.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -146,6 +146,7 @@ files:
146
146
  - docs/sub-agent.md
147
147
  - docs/testing.md
148
148
  - lib/samagotchi.rb
149
+ - lib/samagotchi/analytics_file.rb
149
150
  - lib/samagotchi/answer_command.rb
150
151
  - lib/samagotchi/answer_display.rb
151
152
  - lib/samagotchi/answer_tail.rb
@@ -332,6 +333,7 @@ files:
332
333
  - lib/samagotchi/log_subscriber.rb
333
334
  - lib/samagotchi/memory_bundle.rb
334
335
  - lib/samagotchi/memory_bundle/builder.rb
336
+ - lib/samagotchi/memory_bundle/bundle_hook.rb
335
337
  - lib/samagotchi/memory_bundle/index_size.rb
336
338
  - lib/samagotchi/memory_bundle/index_sync.rb
337
339
  - lib/samagotchi/memory_bundle/index_updater.rb