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 +4 -4
- data/CHANGELOG.md +27 -1
- data/docs/releasing.md +6 -3
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bundle_command.rb +9 -13
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +15 -1
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- data/lib/samagotchi/memory_bundle/builder.rb +23 -21
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installer.rb +4 -4
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
- data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
- data/lib/samagotchi/send_command.rb +3 -27
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_manager.rb +36 -4
- data/lib/samagotchi/session_metrics.rb +15 -20
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +13 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -7
- data/lib/samagotchi/web/public/app.js +0 -2
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/thinking_chip.js +31 -21
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1d170176f088bc87560a05b7596d57925d955011cc5818b93f5ae33ae2b1f55
|
|
4
|
+
data.tar.gz: 941c3bd879e09b78144ede81d4c6cf4724f9ca3a7ce8fa62f0174524a26f25bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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`, `
|
|
19
|
-
`import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
|
|
20
|
-
file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
|
|
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
|
-
|
|
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=#{
|
|
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)
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
74
|
-
priority =
|
|
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,
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
485
|
-
|
|
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
|
-
|
|
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,
|
|
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 ==
|
|
1062
|
-
|
|
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
|
-
|
|
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 =
|
|
176
|
-
return nil unless data
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
981
|
-
return unless
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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?(
|
|
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
|
|
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)).
|
|
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: #{
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|